{
)}
{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>,
+ }
+ );
+};