diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.test.tsx new file mode 100644 index 0000000000000..ea2cd9ce7c0cf --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.test.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 React from 'react'; +import { render } from '@testing-library/react'; +import { FindingsContainer, getDefaultQuery } from './findings_container'; +import { createStubDataView } from '@kbn/data-views-plugin/common/mocks'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; +import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { TestProvider } from '../../test/test_provider'; +import { getFindingsQuery } from './use_findings'; +import { encodeQuery } from '../../common/navigation/query_utils'; +import { useLocation } from 'react-router-dom'; +import { RisonObject } from 'rison-node'; +import { buildEsQuery } from '@kbn/es-query'; +import { getFindingsCountAggQuery } from './use_findings_count'; + +jest.mock('../../common/api/use_kubebeat_data_view'); +jest.mock('../../common/api/use_cis_kubernetes_integration'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ push: jest.fn() }), + useLocation: jest.fn(), +})); + +beforeEach(() => { + jest.restoreAllMocks(); +}); + +describe('', () => { + it('data#search.search fn called with URL query', () => { + const query = getDefaultQuery(); + const dataMock = dataPluginMock.createStartContract(); + const dataView = createStubDataView({ + spec: { + id: CSP_KUBEBEAT_INDEX_PATTERN, + }, + }); + + (useLocation as jest.Mock).mockReturnValue({ + search: encodeQuery(query as unknown as RisonObject), + }); + + render( + + + + ); + + const baseQuery = { + index: dataView.title, + query: buildEsQuery(dataView, query.query, query.filters), + }; + + expect(dataMock.search.search).toHaveBeenNthCalledWith(1, { + params: getFindingsCountAggQuery(baseQuery), + }); + + expect(dataMock.search.search).toHaveBeenNthCalledWith(2, { + params: getFindingsQuery({ + ...baseQuery, + sort: query.sort, + size: query.size, + from: query.from, + }), + }); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.tsx index 3656a129df2db..e41a4be639d20 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.tsx @@ -4,13 +4,14 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { EuiComboBoxOptionOption, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import type { DataView } from '@kbn/data-plugin/common'; import { SortDirection } from '@kbn/data-plugin/common'; +import { buildEsQuery } from '@kbn/es-query'; import { FindingsTable } from './findings_table'; import { FindingsSearchBar } from './findings_search_bar'; import * as TEST_SUBJECTS from './test_subjects'; @@ -18,11 +19,17 @@ import { useUrlQuery } from '../../common/hooks/use_url_query'; import { useFindings, type CspFindingsRequest } from './use_findings'; 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; // TODO: define this as a schema with default values -const getDefaultQuery = (): CspFindingsRequest & { groupBy: GroupBy } => ({ +export const getDefaultQuery = (): CspFindingsRequest & { groupBy: GroupBy } => ({ query: { language: 'kuery', query: '' }, filters: [], sort: [{ ['@timestamp']: SortDirection.desc }], @@ -46,23 +53,81 @@ const getGroupByOptions = (): Array> => [ }, ]; +const getFindingsBaseEsQuery = ({ + query, + dataView, + filters, + queryService, +}: Pick & { + 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: { groupBy, ...findingsQuery }, setUrlQuery, - key, } = useUrlQuery(getDefaultQuery); - const findingsResult = useFindings(dataView, findingsQuery, key); - const { euiTheme } = useEuiTheme(); - const groupByOptions = useMemo(getGroupByOptions, []); + + const baseQuery = useMemo( + () => getFindingsBaseEsQuery({ ...findingsQuery, dataView, queryService: data.query }), + [data.query, dataView, findingsQuery] + ); + + const countResult = useFindingsCounter(baseQuery); + const findingsResult = useFindings({ + ...baseQuery, + size: findingsQuery.size, + from: findingsQuery.from, + sort: findingsQuery.sort, + }); + + useEffect(() => { + if (baseQuery.error) { + toasts.addError(baseQuery.error, { title: TEXT.SEARCH_FAILED }); + } + }, [baseQuery.error, toasts]); return (
{ )} {groupBy === 'none' && ( - + <> + + + + )} {groupBy === 'resourceType' &&
}
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_distribution_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_distribution_bar.tsx new file mode 100644 index 0000000000000..0654f7d9f0999 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_distribution_bar.tsx @@ -0,0 +1,146 @@ +/* + * 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, { useMemo } from 'react'; +import { css } from '@emotion/react'; +import { + EuiHealth, + EuiBadge, + EuiTextColor, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + useEuiTheme, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import numeral from '@elastic/numeral'; + +interface Props { + total: number; + passed: number; + failed: number; + pageStart: number; + pageEnd: number; +} + +const formatNumber = (value: number) => (value < 1000 ? value : numeral(value).format('0.0a')); + +export const FindingsDistributionBar = ({ failed, passed, total, pageEnd, pageStart }: Props) => { + const count = useMemo( + () => + total + ? { total, passed: passed / total, failed: failed / total } + : { total: 0, passed: 0, failed: 0 }, + [total, failed, passed] + ); + + return ( +
+ + + +
+ ); +}; + +const Counters = ({ pageStart, pageEnd, total, failed, passed }: Props) => ( + + + {!!total && } + + + {!!total && } + + +); + +const PassedFailedCounters = ({ passed, failed }: Pick) => { + const { euiTheme } = useEuiTheme(); + return ( +
+ + +
+ ); +}; + +const CurrentPageOfTotal = ({ + pageEnd, + pageStart, + total, +}: Pick) => ( + + {pageStart}, + pageEnd: {pageEnd}, + total: {formatNumber(total)}, + }} + /> + +); + +const DistributionBar: React.FC> = ({ passed, failed }) => { + const { euiTheme } = useEuiTheme(); + return ( + + + + + ); +}; + +const DistributionBarPart = ({ value, color }: { value: number; color: string }) => ( +
+); + +const Counter = ({ label, value, color }: { label: string; value: number; color: string }) => ( + + + {label} + + + {formatNumber(value)} + + +); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_search_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_search_bar.tsx index eaa3daa8c29a3..a3c1a0bfccb9a 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_search_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_search_bar.tsx @@ -10,24 +10,23 @@ 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, CspFindingsResponse } from './use_findings'; +import type { CspFindingsRequest, CspFindingsResult } from './use_findings'; import type { CspClientPluginStartDeps } from '../../types'; import { PLUGIN_NAME } from '../../../common'; import { FINDINGS_SEARCH_PLACEHOLDER } from './translations'; type SearchBarQueryProps = Pick; -interface BaseFindingsSearchBarProps extends SearchBarQueryProps { +interface FindingsSearchBarProps extends SearchBarQueryProps { setQuery(v: Partial): void; + loading: CspFindingsResult['loading']; } -type FindingsSearchBarProps = CspFindingsResponse & BaseFindingsSearchBarProps; - export const FindingsSearchBar = ({ dataView, query, filters, - status, + loading, setQuery, }: FindingsSearchBarProps & { dataView: DataView }) => { const { euiTheme } = useEuiTheme(); @@ -47,7 +46,7 @@ export const FindingsSearchBar = ({ showQueryInput={true} showDatePicker={false} showSaveQuery={false} - isLoading={status === 'loading'} + isLoading={loading} indexPatterns={[dataView]} query={query} filters={filters} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.test.tsx index 55c8a4a703867..28d14b42ae099 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.test.tsx @@ -57,8 +57,8 @@ type TableProps = PropsOf; describe('', () => { it('renders the zero state when status success and data has a length of zero ', async () => { const props: TableProps = { - status: 'success', - data: { data: [], total: 0 }, + loading: false, + data: { page: [], total: 0 }, error: null, sort: [], from: 1, @@ -76,8 +76,8 @@ describe('', () => { const data = names.map(getFakeFindings); const props: TableProps = { - status: 'success', - data: { data, total: 10 }, + loading: false, + data: { page: data, total: 10 }, error: null, sort: [], from: 0, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.tsx index 6b9c1b96281ef..b1e404767d03e 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.tsx @@ -22,7 +22,7 @@ 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, CspFindingsResponse } from './use_findings'; +import type { CspFindingsRequest, CspFindingsResult } from './use_findings'; import { FindingsRuleFlyout } from './findings_flyout'; type TableQueryProps = Pick; @@ -31,7 +31,7 @@ interface BaseFindingsTableProps extends TableQueryProps { setQuery(query: Partial): void; } -type FindingsTableProps = CspFindingsResponse & BaseFindingsTableProps; +type FindingsTableProps = CspFindingsResult & BaseFindingsTableProps; const FindingsTableComponent = ({ setQuery, @@ -39,7 +39,8 @@ const FindingsTableComponent = ({ size, sort = [], error, - ...props + data, + loading, }: FindingsTableProps) => { const [selectedFinding, setSelectedFinding] = useState(); @@ -48,9 +49,9 @@ const FindingsTableComponent = ({ getEuiPaginationFromEsSearchSource({ from, size, - total: props.status === 'success' ? props.data.total : 0, + total: data?.total, }), - [from, size, props] + [from, size, data] ); const sorting = useMemo(() => getEuiSortFromEsSearchSource(sort), [sort]); @@ -70,7 +71,7 @@ const FindingsTableComponent = ({ ); // Show "zero state" - if (props.status === 'success' && !props.data.data.length) + if (!loading && !data?.page.length) // TODO: use our own logo return ( & { - total: number; + total: number | undefined; }): EuiBasicTableProps['pagination'] => ({ pageSize, pageIndex: Math.ceil(pageIndex / pageSize), - totalItemCount: total, + totalItemCount: total || 0, pageSizeOptions: [10, 25, 100], showPerPageOptions: true, }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts index e7d20674cd63b..cf101c2a8445d 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts @@ -4,43 +4,41 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { Filter } from '@kbn/es-query'; import { type UseQueryResult, useQuery } from 'react-query'; -import type { AggregationsAggregate, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import { number } from 'io-ts'; +import type { Filter } from '@kbn/es-query'; import { lastValueFrom } from 'rxjs'; import type { - DataView, EsQuerySortValue, - IKibanaSearchResponse, + IEsSearchResponse, SerializedSearchSourceFields, } from '@kbn/data-plugin/common'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { CoreStart } from '@kbn/core/public'; import { extractErrorMessage } from '../../../common/utils/helpers'; -import type { CspClientPluginStartDeps } from '../../types'; import * as TEXT from './translations'; import type { CspFinding } from './types'; - -interface CspFindings { - data: CspFinding[]; - total: number; -} +import { useKibana } from '../../common/hooks/use_kibana'; +import type { FindingsBaseQuery } from './findings_container'; export interface CspFindingsRequest extends Required> { filters: Filter[]; } -type ResponseProps = 'data' | 'error' | 'status'; -type Result = UseQueryResult; +type UseFindingsOptions = FindingsBaseQuery & Omit; + +interface CspFindingsData { + page: CspFinding[]; + total: number; +} + +type Result = UseQueryResult; -// TODO: use distributive Pick -export type CspFindingsResponse = - | Pick, ResponseProps> - | Pick, ResponseProps> - | Pick, ResponseProps> - | Pick, ResponseProps>; +export interface CspFindingsResult { + loading: Result['isLoading']; + error: Result['error']; + data: CspFindingsData | undefined; +} const FIELDS_WITHOUT_KEYWORD_MAPPING = new Set(['@timestamp']); @@ -65,72 +63,49 @@ const mapEsQuerySortKey = (sort: readonly EsQuerySortValue[]): EsQuerySortValue[ return acc; }, []); -const showResponseErrorToast = - ({ toasts }: CoreStart['notifications']) => - (error: unknown): void => { - if (error instanceof Error) toasts.addError(error, { title: TEXT.SEARCH_FAILED }); - else toasts.addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED)); - }; +export const showErrorToast = ( + toasts: CoreStart['notifications']['toasts'], + error: unknown +): void => { + if (error instanceof Error) toasts.addError(error, { title: TEXT.SEARCH_FAILED }); + else toasts.addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED)); +}; -const extractFindings = ({ - rawResponse: { hits }, -}: IKibanaSearchResponse< - SearchResponse> ->): CspFindings => ({ - // TODO: use 'fields' instead of '_source' ? - data: hits.hits.map((hit) => hit._source!), - total: number.is(hits.total) ? hits.total : 0, +export const getFindingsQuery = ({ + index, + query, + size, + from, + sort, +}: Omit) => ({ + query, + size, + from, + sort: mapEsQuerySortKey(sort), }); -const createFindingsSearchSource = ( - { - query, - dataView, - filters, - ...rest - }: Omit & { dataView: DataView }, - queryService: CspClientPluginStartDeps['data']['query'] -): SerializedSearchSourceFields => { - if (query) queryService.queryString.setQuery(query); - - return { - ...rest, - sort: mapEsQuerySortKey(rest.sort), - filter: queryService.filterManager.getFilters(), - query: queryService.queryString.getQuery(), - index: dataView.id, // TODO: constant - }; -}; - -/** - * @description a react-query#mutation wrapper on the data plugin searchSource - * @todo use 'searchAfter'. currently limited to 10k docs. see https://github.com/elastic/kibana/issues/116776 - */ -export const useFindings = ( - dataView: DataView, - searchProps: CspFindingsRequest, - urlKey?: string // Needed when URL query (searchProps) didn't change (now-15) but require a refetch -): CspFindingsResponse => { +export const useFindings = ({ error, index, query, sort, from, size }: UseFindingsOptions) => { const { - notifications, - data: { query, search }, - } = useKibana().services; + data, + notifications: { toasts }, + } = useKibana().services; return useQuery( - ['csp_findings', { searchProps, urlKey }], - async () => { - const source = await search.searchSource.create( - createFindingsSearchSource({ ...searchProps, dataView }, query) - ); - - const response = await lastValueFrom(source.fetch$()); - - return response; - }, + ['csp_findings', { from, size, query, sort }], + () => + lastValueFrom>( + data.search.search({ + params: getFindingsQuery({ index, query, sort, from, size }), + }) + ), { - cacheTime: 0, - onError: showResponseErrorToast(notifications!), - select: extractFindings, + enabled: !error, + select: ({ rawResponse: { hits } }) => ({ + // TODO: use 'fields' instead of '_source' ? + page: hits.hits.map((hit) => hit._source!), + total: number.is(hits.total) ? hits.total : 0, + }), + onError: (err) => showErrorToast(toasts, err), } ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts new file mode 100644 index 0000000000000..e29944d94a929 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts @@ -0,0 +1,62 @@ +/* + * 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 type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +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'; + +type FindingsAggRequest = IKibanaSearchRequest; +type FindingsAggResponse = IKibanaSearchResponse>; +interface FindingsAggs extends estypes.AggregationsMultiBucketAggregateBase { + count: { + buckets: Array<{ + key: string; + doc_count: number; + }>; + }; +} + +export const getFindingsCountAggQuery = ({ index, query }: Omit) => ({ + index, + size: 0, + track_total_hits: true, + body: { + query, + aggs: { count: { terms: { field: 'result.evaluation' } } }, + }, +}); + +export const useFindingsCounter = ({ index, query, error }: FindingsBaseQuery) => { + const { + data, + notifications: { toasts }, + } = useKibana().services; + + return useQuery( + ['csp_findings_counts', { index, query }], + () => + lastValueFrom( + data.search.search({ + params: getFindingsCountAggQuery({ index, query }), + }) + ), + { + enabled: !error, + onError: (err) => showErrorToast(toasts, err), + select: (response) => + Object.fromEntries( + response.rawResponse.aggregations!.count.buckets.map((bucket) => [ + bucket.key, + bucket.doc_count, + ])! + ) as Record<'passed' | 'failed', number>, + } + ); +};