[Cloud Posture] add findings group by resource table (#130455)

This commit is contained in:
Or Ouziel 2022-04-20 18:33:36 +03:00 committed by GitHub
parent 8c991cfaef
commit 7df6a0dad5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 406 additions and 131 deletions

View file

@ -9,9 +9,9 @@ import { useHistory } from 'react-router-dom';
import { Query } from '@kbn/es-query';
import { allNavigationItems } from '../navigation/constants';
import { encodeQuery } from '../navigation/query_utils';
import { CspFindingsRequest } from '../../pages/findings/use_findings';
import { FindingsBaseURLQuery } from '../../pages/findings/types';
const getFindingsQuery = (queryValue: Query['query']): Pick<CspFindingsRequest, 'query'> => {
const getFindingsQuery = (queryValue: Query['query']): Pick<FindingsBaseURLQuery, 'query'> => {
const query =
typeof queryValue === 'string'
? queryValue

View file

@ -0,0 +1,68 @@
/*
* 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 { FindingsByResourceTable, formatNumber, getResourceId } from './findings_by_resource_table';
import * as TEXT from './translations';
import type { PropsOf } from '@elastic/eui';
import Chance from 'chance';
import numeral from '@elastic/numeral';
const chance = new Chance();
const getFakeFindingsByResource = () => ({
resource_id: chance.guid(),
cluster_id: chance.guid(),
cis_section: chance.word(),
failed_findings: {
total: chance.integer(),
normalized: chance.integer({ min: 0, max: 1 }),
},
});
type TableProps = PropsOf<typeof FindingsByResourceTable>;
describe('<FindingsByResourceTable />', () => {
it('renders the zero state when status success and data has a length of zero ', async () => {
const props: TableProps = {
loading: false,
data: { page: [] },
error: null,
};
render(<FindingsByResourceTable {...props} />);
expect(screen.getByText(TEXT.NO_FINDINGS)).toBeInTheDocument();
});
it('renders the table with provided items', () => {
const data = Array.from({ length: 10 }, getFakeFindingsByResource);
const props: TableProps = {
loading: false,
data: { page: data },
error: null,
};
render(<FindingsByResourceTable {...props} />);
data.forEach((item, i) => {
const row = screen.getByTestId(
TEST_SUBJECTS.getFindingsByResourceTableRowTestId(getResourceId(item))
);
expect(row).toBeInTheDocument();
expect(within(row).getByText(item.resource_id)).toBeInTheDocument();
expect(within(row).getByText(item.cluster_id)).toBeInTheDocument();
expect(within(row).getByText(item.cis_section)).toBeInTheDocument();
expect(within(row).getByText(formatNumber(item.failed_findings.total))).toBeInTheDocument();
expect(
within(row).getByText(new RegExp(numeral(item.failed_findings.normalized).format('0%')))
).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,109 @@
/*
* 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 {
EuiTableFieldDataColumnType,
EuiEmptyPrompt,
EuiBasicTable,
EuiTextColor,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import numeral from '@elastic/numeral';
import { extractErrorMessage } from '../../../common/utils/helpers';
import * as TEST_SUBJECTS from './test_subjects';
import * as TEXT from './translations';
import type { CspFindingsByResourceResult } from './use_findings_by_resource';
export const formatNumber = (value: number) =>
value < 1000 ? value : numeral(value).format('0.0a');
type FindingsGroupByResourceProps = CspFindingsByResourceResult;
type CspFindingsByResource = NonNullable<CspFindingsByResourceResult['data']>['page'][number];
export const getResourceId = (resource: CspFindingsByResource) =>
[resource.resource_id, resource.cluster_id, resource.cis_section].join('/');
const FindingsByResourceTableComponent = ({
error,
data,
loading,
}: FindingsGroupByResourceProps) => {
const getRowProps = (row: CspFindingsByResource) => ({
'data-test-subj': TEST_SUBJECTS.getFindingsByResourceTableRowTestId(getResourceId(row)),
});
if (!loading && !data?.page.length)
return <EuiEmptyPrompt iconType="logoKibana" title={<h2>{TEXT.NO_FINDINGS}</h2>} />;
return (
<EuiBasicTable
loading={loading}
error={error ? extractErrorMessage(error) : undefined}
items={data?.page || []}
columns={columns}
rowProps={getRowProps}
/>
);
};
const columns: Array<EuiTableFieldDataColumnType<CspFindingsByResource>> = [
{
field: 'resource_id',
name: (
<FormattedMessage
id="xpack.csp.findings.groupByResourceTable.resourceIdColumnLabel"
defaultMessage="Resource ID"
/>
),
render: (resourceId: CspFindingsByResource['resource_id']) => <EuiLink>{resourceId}</EuiLink>,
},
{
field: 'cis_section',
truncateText: true,
name: (
<FormattedMessage
id="xpack.csp.findings.groupByResourceTable.cisSectionColumnLabel"
defaultMessage="CIS Section"
/>
),
},
{
field: 'cluster_id',
truncateText: true,
name: (
<FormattedMessage
id="xpack.csp.findings.groupByResourceTable.clusterIdColumnLabel"
defaultMessage="Cluster ID"
/>
),
},
{
field: 'failed_findings',
truncateText: true,
name: (
<FormattedMessage
id="xpack.csp.findings.groupByResourceTable.failedFindingsColumnLabel"
defaultMessage="Failed Findings"
/>
),
render: (failedFindings: CspFindingsByResource['failed_findings']) => (
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiTextColor color="danger">{formatNumber(failedFindings.total)}</EuiTextColor>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span>({numeral(failedFindings.normalized).format('0%')})</span>
</EuiFlexItem>
</EuiFlexGroup>
),
},
];
export const FindingsByResourceTable = React.memo(FindingsByResourceTableComponent);

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useMemo } from 'react';
import React, { useMemo } from 'react';
import { EuiComboBoxOptionOption, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
@ -16,20 +16,18 @@ import { FindingsTable } from './findings_table';
import { FindingsSearchBar } from './findings_search_bar';
import * as TEST_SUBJECTS from './test_subjects';
import { useUrlQuery } from '../../common/hooks/use_url_query';
import { useFindings, type CspFindingsRequest } from './use_findings';
import { useFindings } from './use_findings';
import type { FindingsGroupByNoneQuery } from './use_findings';
import type { FindingsBaseURLQuery } from './types';
import { useFindingsByResource } from './use_findings_by_resource';
import { FindingsGroupBySelector } from './findings_group_by_selector';
import { INTERNAL_FEATURE_FLAGS } from '../../../common/constants';
import { useFindingsCounter } from './use_findings_count';
import { FindingsDistributionBar } from './findings_distribution_bar';
import type { CspClientPluginStartDeps } from '../../types';
import { useKibana } from '../../common/hooks/use_kibana';
import * as TEXT from './translations';
export type GroupBy = 'none' | 'resourceType';
export type FindingsBaseQuery = ReturnType<typeof getFindingsBaseEsQuery>;
import { FindingsByResourceTable } from './findings_by_resource_table';
// TODO: define this as a schema with default values
export const getDefaultQuery = (): CspFindingsRequest & { groupBy: GroupBy } => ({
export const getDefaultQuery = (): FindingsBaseURLQuery & FindingsGroupByNoneQuery => ({
query: { language: 'kuery', query: '' },
filters: [],
sort: [{ ['@timestamp']: SortDirection.desc }],
@ -38,7 +36,7 @@ export const getDefaultQuery = (): CspFindingsRequest & { groupBy: GroupBy } =>
groupBy: 'none',
});
const getGroupByOptions = (): Array<EuiComboBoxOptionOption<GroupBy>> => [
const getGroupByOptions = (): Array<EuiComboBoxOptionOption<FindingsBaseURLQuery['groupBy']>> => [
{
value: 'none',
label: i18n.translate('xpack.csp.findings.groupBySelector.groupByNoneLabel', {
@ -46,88 +44,55 @@ const getGroupByOptions = (): Array<EuiComboBoxOptionOption<GroupBy>> => [
}),
},
{
value: 'resourceType',
label: i18n.translate('xpack.csp.findings.groupBySelector.groupByResourceTypeLabel', {
defaultMessage: 'Resource Type',
value: 'resource',
label: i18n.translate('xpack.csp.findings.groupBySelector.groupByResourceIdLabel', {
defaultMessage: 'Resource',
}),
},
];
const getFindingsBaseEsQuery = ({
query,
dataView,
filters,
queryService,
}: Pick<CspFindingsRequest, 'filters' | 'query'> & {
dataView: DataView;
queryService: CspClientPluginStartDeps['data']['query'];
}) => {
if (query) queryService.queryString.setQuery(query);
queryService.filterManager.setFilters(filters);
try {
return {
index: dataView.title,
query: buildEsQuery(
dataView,
queryService.queryString.getQuery(),
queryService.filterManager.getFilters()
),
};
} catch (error) {
return {
error:
error instanceof Error
? error
: new Error(
i18n.translate('xpack.csp.findings.unknownError', {
defaultMessage: 'Unknown Error',
})
),
};
}
};
export const FindingsContainer = ({ dataView }: { dataView: DataView }) => {
const { euiTheme } = useEuiTheme();
const groupByOptions = useMemo(getGroupByOptions, []);
const {
data,
notifications: { toasts },
} = useKibana().services;
const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery);
const {
urlQuery: { groupBy, ...findingsQuery },
setUrlQuery,
} = useUrlQuery(getDefaultQuery);
const baseQuery = useMemo(
() => getFindingsBaseEsQuery({ ...findingsQuery, dataView, queryService: data.query }),
[data.query, dataView, findingsQuery]
const baseEsQuery = useMemo(
() => ({
index: dataView.title,
// TODO: this will throw for malformed query
// page will display an error boundary with the JS error
// will be accounted for before releasing the feature
query: buildEsQuery(dataView, urlQuery.query, urlQuery.filters),
}),
[dataView, urlQuery]
);
const countResult = useFindingsCounter(baseQuery);
const findingsResult = useFindings({
...baseQuery,
size: findingsQuery.size,
from: findingsQuery.from,
sort: findingsQuery.sort,
const findingsGroupByResource = useFindingsByResource({
...baseEsQuery,
enabled: urlQuery.groupBy === 'resource',
});
useEffect(() => {
if (baseQuery.error) {
toasts.addError(baseQuery.error, { title: TEXT.SEARCH_FAILED });
}
}, [baseQuery.error, toasts]);
const findingsCount = useFindingsCounter({
...baseEsQuery,
enabled: urlQuery.groupBy === 'none',
});
const findingsGroupByNone = useFindings({
...baseEsQuery,
enabled: urlQuery.groupBy === 'none',
size: urlQuery.size,
from: urlQuery.from,
sort: urlQuery.sort,
});
return (
<div data-test-subj={TEST_SUBJECTS.FINDINGS_CONTAINER}>
<FindingsSearchBar
dataView={dataView}
setQuery={setUrlQuery}
query={findingsQuery.query}
filters={findingsQuery.filters}
loading={findingsResult.isLoading}
query={urlQuery.query}
filters={urlQuery.filters}
loading={findingsGroupByNone.isLoading}
/>
<div
css={css`
@ -138,32 +103,41 @@ export const FindingsContainer = ({ dataView }: { dataView: DataView }) => {
<EuiSpacer />
{INTERNAL_FEATURE_FLAGS.showFindingsGroupBy && (
<FindingsGroupBySelector
type={groupBy}
type={urlQuery.groupBy}
onChange={(type) => setUrlQuery({ groupBy: type[0]?.value })}
options={groupByOptions}
/>
)}
<EuiSpacer />
{groupBy === 'none' && (
{urlQuery.groupBy === 'none' && (
<>
<FindingsDistributionBar
total={findingsResult.data?.total || 0}
passed={countResult.data?.passed || 0}
failed={countResult.data?.failed || 0}
pageStart={findingsQuery.from + 1} // API index is 0, but UI is 1
pageEnd={findingsQuery.from + findingsQuery.size}
total={findingsGroupByNone.data?.total || 0}
passed={findingsCount.data?.passed || 0}
failed={findingsCount.data?.failed || 0}
pageStart={urlQuery.from + 1} // API index is 0, but UI is 1
pageEnd={urlQuery.from + urlQuery.size}
/>
<EuiSpacer />
<FindingsTable
{...findingsQuery}
{...urlQuery}
setQuery={setUrlQuery}
data={findingsResult.data}
error={findingsResult.error}
loading={findingsResult.isLoading}
data={findingsGroupByNone.data}
error={findingsGroupByNone.error}
loading={findingsGroupByNone.isLoading}
/>
</>
)}
{urlQuery.groupBy === 'resource' && (
<>
<FindingsByResourceTable
{...urlQuery}
data={findingsGroupByResource.data}
error={findingsGroupByResource.error}
loading={findingsGroupByResource.isLoading}
/>
</>
)}
{groupBy === 'resourceType' && <div />}
</div>
</div>
);

View file

@ -7,12 +7,12 @@
import React from 'react';
import { EuiComboBox, EuiFormLabel, type EuiComboBoxOptionOption } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { GroupBy } from './findings_container';
import type { FindingsGroupByKind } from './types';
interface Props {
type: GroupBy;
options: Array<EuiComboBoxOptionOption<GroupBy>>;
onChange(selectedOptions: Array<EuiComboBoxOptionOption<GroupBy>>): void;
type: FindingsGroupByKind;
options: Array<EuiComboBoxOptionOption<FindingsGroupByKind>>;
onChange(selectedOptions: Array<EuiComboBoxOptionOption<FindingsGroupByKind>>): void;
}
export const FindingsGroupBySelector = ({ type, options, onChange }: Props) => (

View file

@ -10,12 +10,13 @@ import { EuiThemeComputed, useEuiTheme } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { DataView } from '@kbn/data-plugin/common';
import * as TEST_SUBJECTS from './test_subjects';
import type { CspFindingsRequest, CspFindingsResult } from './use_findings';
import type { CspFindingsResult } from './use_findings';
import type { FindingsBaseURLQuery } from './types';
import type { CspClientPluginStartDeps } from '../../types';
import { PLUGIN_NAME } from '../../../common';
import { FINDINGS_SEARCH_PLACEHOLDER } from './translations';
type SearchBarQueryProps = Pick<CspFindingsRequest, 'query' | 'filters'>;
type SearchBarQueryProps = Pick<FindingsBaseURLQuery, 'query' | 'filters'>;
interface FindingsSearchBarProps extends SearchBarQueryProps {
setQuery(v: Partial<SearchBarQueryProps>): void;

View file

@ -22,13 +22,11 @@ import * as TEST_SUBJECTS from './test_subjects';
import * as TEXT from './translations';
import type { CspFinding } from './types';
import { CspEvaluationBadge } from '../../components/csp_evaluation_badge';
import type { CspFindingsRequest, CspFindingsResult } from './use_findings';
import type { FindingsGroupByNoneQuery, CspFindingsResult } from './use_findings';
import { FindingsRuleFlyout } from './findings_flyout';
type TableQueryProps = Pick<CspFindingsRequest, 'sort' | 'from' | 'size'>;
interface BaseFindingsTableProps extends TableQueryProps {
setQuery(query: Partial<TableQueryProps>): void;
interface BaseFindingsTableProps extends FindingsGroupByNoneQuery {
setQuery(query: Partial<FindingsGroupByNoneQuery>): void;
}
type FindingsTableProps = CspFindingsResult & BaseFindingsTableProps;
@ -119,7 +117,7 @@ const getEuiPaginationFromEsSearchSource = ({
});
const getEuiSortFromEsSearchSource = (
sort: TableQueryProps['sort']
sort: FindingsGroupByNoneQuery['sort']
): EuiBasicTableProps<CspFinding>['sorting'] => {
if (!sort.length) return;
@ -133,7 +131,7 @@ const getEuiSortFromEsSearchSource = (
const getEsSearchQueryFromEuiTableParams = ({
page,
sort,
}: Criteria<CspFinding>): Partial<TableQueryProps> => ({
}: Criteria<CspFinding>): Partial<FindingsGroupByNoneQuery> => ({
...(!!page && { from: page.index * page.size, size: page.size }),
sort: sort ? [{ [sort.field]: SortDirection[sort.direction] }] : undefined,
});

View file

@ -9,3 +9,5 @@ export const FINDINGS_SEARCH_BAR = 'findings_search_bar';
export const FINDINGS_TABLE = 'findings_table';
export const FINDINGS_CONTAINER = 'findings_container';
export const FINDINGS_TABLE_ZERO_STATE = 'findings_table_zero_state';
export const getFindingsByResourceTableRowTestId = (id: string) =>
`findings_resource_table_row_${id}`;

View file

@ -4,6 +4,33 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { BoolQuery, Filter, Query } from '@kbn/es-query';
import { UseQueryResult } from 'react-query';
export type FindingsGroupByKind = 'none' | 'resource';
export interface FindingsBaseURLQuery {
groupBy: FindingsGroupByKind;
query: Query;
filters: Filter[];
}
export interface FindingsBaseEsQuery {
index: string;
query?: {
bool: BoolQuery;
};
}
export interface FindingsQueryStatus {
enabled: boolean;
}
export interface FindingsQueryResult<TData = unknown, TError = unknown> {
loading: UseQueryResult['isLoading'];
error: TError;
data: TData;
}
// TODO: this needs to be defined in a versioned schema
export interface CspFinding {

View file

@ -4,41 +4,35 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { type UseQueryResult, useQuery } from 'react-query';
import { useQuery } from 'react-query';
import { number } from 'io-ts';
import type { Filter } from '@kbn/es-query';
import { lastValueFrom } from 'rxjs';
import type {
EsQuerySortValue,
IEsSearchResponse,
SerializedSearchSourceFields,
} from '@kbn/data-plugin/common';
import type { EsQuerySortValue, IEsSearchResponse } from '@kbn/data-plugin/common';
import type { CoreStart } from '@kbn/core/public';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { extractErrorMessage } from '../../../common/utils/helpers';
import * as TEXT from './translations';
import type { CspFinding } from './types';
import type { CspFinding, FindingsQueryResult } from './types';
import { useKibana } from '../../common/hooks/use_kibana';
import type { FindingsBaseQuery } from './findings_container';
import type { FindingsBaseEsQuery, FindingsQueryStatus } from './types';
export interface CspFindingsRequest
extends Required<Pick<SerializedSearchSourceFields, 'sort' | 'size' | 'from' | 'query'>> {
filters: Filter[];
interface UseFindingsOptions
extends FindingsBaseEsQuery,
FindingsGroupByNoneQuery,
FindingsQueryStatus {}
export interface FindingsGroupByNoneQuery {
from: NonNullable<estypes.SearchRequest['from']>;
size: NonNullable<estypes.SearchRequest['size']>;
sort: EsQuerySortValue[];
}
type UseFindingsOptions = FindingsBaseQuery & Omit<CspFindingsRequest, 'filters' | 'query'>;
interface CspFindingsData {
page: CspFinding[];
total: number;
}
type Result = UseQueryResult<CspFindingsData, unknown>;
export interface CspFindingsResult {
loading: Result['isLoading'];
error: Result['error'];
data: CspFindingsData | undefined;
}
export type CspFindingsResult = FindingsQueryResult<CspFindingsData | undefined, unknown>;
const FIELDS_WITHOUT_KEYWORD_MAPPING = new Set(['@timestamp']);
@ -77,7 +71,7 @@ export const getFindingsQuery = ({
size,
from,
sort,
}: Omit<UseFindingsOptions, 'error'>) => ({
}: Omit<UseFindingsOptions, 'enabled'>) => ({
index,
query,
size,
@ -85,14 +79,14 @@ export const getFindingsQuery = ({
sort: mapEsQuerySortKey(sort),
});
export const useFindings = ({ error, index, query, sort, from, size }: UseFindingsOptions) => {
export const useFindings = ({ enabled, index, query, sort, from, size }: UseFindingsOptions) => {
const {
data,
notifications: { toasts },
} = useKibana().services;
return useQuery(
['csp_findings', { from, size, query, sort }],
['csp_findings', { index, query, sort, from, size }],
() =>
lastValueFrom<IEsSearchResponse<CspFinding>>(
data.search.search({
@ -100,9 +94,8 @@ export const useFindings = ({ error, index, query, sort, from, size }: UseFindin
})
),
{
enabled: !error,
enabled,
select: ({ rawResponse: { hits } }) => ({
// TODO: use 'fields' instead of '_source' ?
page: hits.hits.map((hit) => hit._source!),
total: number.is(hits.total) ? hits.total : 0,
}),

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from 'react-query';
import { lastValueFrom } from 'rxjs';
import { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { useKibana } from '../../common/hooks/use_kibana';
import { showErrorToast } from './use_findings';
import type { FindingsBaseEsQuery, FindingsQueryResult, FindingsQueryStatus } from './types';
interface UseFindingsByResourceOptions extends FindingsBaseEsQuery, FindingsQueryStatus {}
type FindingsAggRequest = IKibanaSearchRequest<estypes.SearchRequest>;
type FindingsAggResponse = IKibanaSearchResponse<
estypes.SearchResponse<{}, FindingsByResourceAggs>
>;
export type CspFindingsByResourceResult = FindingsQueryResult<
ReturnType<typeof useFindingsByResource>['data'] | undefined,
unknown
>;
interface FindingsByResourceAggs extends estypes.AggregationsCompositeAggregate {
groupBy: {
buckets: FindingsAggBucket[];
};
}
interface FindingsAggBucket {
doc_count: number;
failed_findings: { doc_count: number };
key: {
resource_id: string;
cluster_id: string;
cis_section: string;
};
}
export const getFindingsByResourceAggQuery = ({
index,
query,
}: Omit<UseFindingsByResourceOptions, 'enabled'>): estypes.SearchRequest => ({
index,
size: 0,
body: {
query,
aggs: {
groupBy: {
composite: {
size: 10 * 1000,
sources: [
{ resource_id: { terms: { field: 'resource_id.keyword' } } },
{ cluster_id: { terms: { field: 'cluster_id.keyword' } } },
{ cis_section: { terms: { field: 'rule.section' } } },
],
},
aggs: {
failed_findings: {
filter: { term: { 'result.evaluation.keyword': 'failed' } },
},
},
},
},
},
});
export const useFindingsByResource = ({ enabled, index, query }: UseFindingsByResourceOptions) => {
const {
data,
notifications: { toasts },
} = useKibana().services;
return useQuery(
['csp_findings_resource', { index, query }],
() =>
lastValueFrom(
data.search.search<FindingsAggRequest, FindingsAggResponse>({
params: getFindingsByResourceAggQuery({ index, query }),
})
),
{
enabled,
select: ({ rawResponse }) => ({
page: rawResponse.aggregations?.groupBy.buckets.map(createFindingsByResource) || [],
}),
onError: (err) => showErrorToast(toasts, err),
}
);
};
const createFindingsByResource = (bucket: FindingsAggBucket) => ({
...bucket.key,
failed_findings: {
total: bucket.failed_findings.doc_count,
normalized: bucket.doc_count > 0 ? bucket.failed_findings.doc_count / bucket.doc_count : 0,
},
});

View file

@ -10,7 +10,9 @@ import { lastValueFrom } from 'rxjs';
import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/public';
import { useKibana } from '../../common/hooks/use_kibana';
import { showErrorToast } from './use_findings';
import type { FindingsBaseQuery } from './findings_container';
import type { FindingsBaseEsQuery, FindingsQueryStatus } from './types';
interface UseFindingsCountOptions extends FindingsBaseEsQuery, FindingsQueryStatus {}
type FindingsAggRequest = IKibanaSearchRequest<estypes.SearchRequest>;
type FindingsAggResponse = IKibanaSearchResponse<estypes.SearchResponse<{}, FindingsAggs>>;
@ -23,7 +25,7 @@ interface FindingsAggs extends estypes.AggregationsMultiBucketAggregateBase {
};
}
export const getFindingsCountAggQuery = ({ index, query }: Omit<FindingsBaseQuery, 'error'>) => ({
export const getFindingsCountAggQuery = ({ index, query }: FindingsBaseEsQuery) => ({
index,
size: 0,
track_total_hits: true,
@ -33,7 +35,7 @@ export const getFindingsCountAggQuery = ({ index, query }: Omit<FindingsBaseQuer
},
});
export const useFindingsCounter = ({ index, query, error }: FindingsBaseQuery) => {
export const useFindingsCounter = ({ enabled, index, query }: UseFindingsCountOptions) => {
const {
data,
notifications: { toasts },
@ -48,7 +50,7 @@ export const useFindingsCounter = ({ index, query, error }: FindingsBaseQuery) =
})
),
{
enabled: !error,
enabled,
onError: (err) => showErrorToast(toasts, err),
select: (response) =>
Object.fromEntries(