[Cloud Posture] add findings distribution bar (#129639)

This commit is contained in:
Or Ouziel 2022-04-17 15:55:43 +03:00 committed by GitHub
parent ac5aca44e8
commit 1bfeab7553
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 450 additions and 108 deletions

View file

@ -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('<FindingsContainer />', () => {
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(
<TestProvider
deps={{
data: dataMock,
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
}}
>
<FindingsContainer dataView={dataView} />
</TestProvider>
);
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,
}),
});
});
});

View file

@ -4,13 +4,14 @@
* 2.0; you may not use this file except in compliance with the Elastic License * 2.0; you may not use this file except in compliance with the Elastic License
* 2.0. * 2.0.
*/ */
import React, { useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
import { EuiComboBoxOptionOption, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui'; import { EuiComboBoxOptionOption, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import type { DataView } from '@kbn/data-plugin/common'; import type { DataView } from '@kbn/data-plugin/common';
import { SortDirection } from '@kbn/data-plugin/common'; import { SortDirection } from '@kbn/data-plugin/common';
import { buildEsQuery } from '@kbn/es-query';
import { FindingsTable } from './findings_table'; import { FindingsTable } from './findings_table';
import { FindingsSearchBar } from './findings_search_bar'; import { FindingsSearchBar } from './findings_search_bar';
import * as TEST_SUBJECTS from './test_subjects'; 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 { useFindings, type CspFindingsRequest } from './use_findings';
import { FindingsGroupBySelector } from './findings_group_by_selector'; import { FindingsGroupBySelector } from './findings_group_by_selector';
import { INTERNAL_FEATURE_FLAGS } from '../../../common/constants'; 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 GroupBy = 'none' | 'resourceType';
export type FindingsBaseQuery = ReturnType<typeof getFindingsBaseEsQuery>;
// TODO: define this as a schema with default values // TODO: define this as a schema with default values
const getDefaultQuery = (): CspFindingsRequest & { groupBy: GroupBy } => ({ export const getDefaultQuery = (): CspFindingsRequest & { groupBy: GroupBy } => ({
query: { language: 'kuery', query: '' }, query: { language: 'kuery', query: '' },
filters: [], filters: [],
sort: [{ ['@timestamp']: SortDirection.desc }], sort: [{ ['@timestamp']: SortDirection.desc }],
@ -46,23 +53,81 @@ const getGroupByOptions = (): Array<EuiComboBoxOptionOption<GroupBy>> => [
}, },
]; ];
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 }) => { export const FindingsContainer = ({ dataView }: { dataView: DataView }) => {
const { euiTheme } = useEuiTheme();
const groupByOptions = useMemo(getGroupByOptions, []);
const {
data,
notifications: { toasts },
} = useKibana().services;
const { const {
urlQuery: { groupBy, ...findingsQuery }, urlQuery: { groupBy, ...findingsQuery },
setUrlQuery, setUrlQuery,
key,
} = useUrlQuery(getDefaultQuery); } = useUrlQuery(getDefaultQuery);
const findingsResult = useFindings(dataView, findingsQuery, key);
const { euiTheme } = useEuiTheme(); const baseQuery = useMemo(
const groupByOptions = useMemo(getGroupByOptions, []); () => 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 ( return (
<div data-test-subj={TEST_SUBJECTS.FINDINGS_CONTAINER}> <div data-test-subj={TEST_SUBJECTS.FINDINGS_CONTAINER}>
<FindingsSearchBar <FindingsSearchBar
dataView={dataView} dataView={dataView}
setQuery={setUrlQuery} setQuery={setUrlQuery}
{...findingsQuery} query={findingsQuery.query}
{...findingsResult} filters={findingsQuery.filters}
loading={findingsResult.isLoading}
/> />
<div <div
css={css` css={css`
@ -80,7 +145,23 @@ export const FindingsContainer = ({ dataView }: { dataView: DataView }) => {
)} )}
<EuiSpacer /> <EuiSpacer />
{groupBy === 'none' && ( {groupBy === 'none' && (
<FindingsTable setQuery={setUrlQuery} {...findingsQuery} {...findingsResult} /> <>
<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}
/>
<EuiSpacer />
<FindingsTable
{...findingsQuery}
setQuery={setUrlQuery}
data={findingsResult.data}
error={findingsResult.error}
loading={findingsResult.isLoading}
/>
</>
)} )}
{groupBy === 'resourceType' && <div />} {groupBy === 'resourceType' && <div />}
</div> </div>

View file

@ -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 (
<div>
<Counters {...{ failed, passed, total, pageEnd, pageStart }} />
<EuiSpacer size="s" />
<DistributionBar {...count} />
</div>
);
};
const Counters = ({ pageStart, pageEnd, total, failed, passed }: Props) => (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
{!!total && <CurrentPageOfTotal pageStart={pageStart} pageEnd={pageEnd} total={total} />}
</EuiFlexItem>
<EuiFlexItem
css={css`
align-items: flex-end;
`}
>
{!!total && <PassedFailedCounters passed={passed} failed={failed} />}
</EuiFlexItem>
</EuiFlexGroup>
);
const PassedFailedCounters = ({ passed, failed }: Pick<Props, 'passed' | 'failed'>) => {
const { euiTheme } = useEuiTheme();
return (
<div
css={css`
display: grid;
grid-template-columns: auto auto;
grid-column-gap: ${euiTheme.size.m};
`}
>
<Counter
label={i18n.translate('xpack.csp.findings.distributionBar.totalPassedLabel', {
defaultMessage: 'Passed',
})}
color={euiTheme.colors.success}
value={passed}
/>
<Counter
label={i18n.translate('xpack.csp.findings.distributionBar.totalFailedLabel', {
defaultMessage: 'Failed',
})}
color={euiTheme.colors.danger}
value={failed}
/>
</div>
);
};
const CurrentPageOfTotal = ({
pageEnd,
pageStart,
total,
}: Pick<Props, 'pageEnd' | 'pageStart' | 'total'>) => (
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.csp.findings.distributionBar.showingPageOfTotalLabel"
defaultMessage="Showing {pageStart}-{pageEnd} of {total} Findings"
values={{
pageStart: <b>{pageStart}</b>,
pageEnd: <b>{pageEnd}</b>,
total: <b>{formatNumber(total)}</b>,
}}
/>
</EuiTextColor>
);
const DistributionBar: React.FC<Omit<Props, 'pageEnd' | 'pageStart'>> = ({ passed, failed }) => {
const { euiTheme } = useEuiTheme();
return (
<EuiFlexGroup
gutterSize="none"
css={css`
height: 8px;
background: ${euiTheme.colors.subdued};
`}
>
<DistributionBarPart value={passed} color={euiTheme.colors.success} />
<DistributionBarPart value={failed} color={euiTheme.colors.danger} />
</EuiFlexGroup>
);
};
const DistributionBarPart = ({ value, color }: { value: number; color: string }) => (
<div
css={css`
flex: ${value};
background: ${color};
height: 100%;
`}
/>
);
const Counter = ({ label, value, color }: { label: string; value: number; color: string }) => (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem>
<EuiHealth color={color}>{label}</EuiHealth>
</EuiFlexItem>
<EuiFlexItem>
<EuiBadge>{formatNumber(value)}</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -10,24 +10,23 @@ import { EuiThemeComputed, useEuiTheme } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { DataView } from '@kbn/data-plugin/common'; import type { DataView } from '@kbn/data-plugin/common';
import * as TEST_SUBJECTS from './test_subjects'; 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 type { CspClientPluginStartDeps } from '../../types';
import { PLUGIN_NAME } from '../../../common'; import { PLUGIN_NAME } from '../../../common';
import { FINDINGS_SEARCH_PLACEHOLDER } from './translations'; import { FINDINGS_SEARCH_PLACEHOLDER } from './translations';
type SearchBarQueryProps = Pick<CspFindingsRequest, 'query' | 'filters'>; type SearchBarQueryProps = Pick<CspFindingsRequest, 'query' | 'filters'>;
interface BaseFindingsSearchBarProps extends SearchBarQueryProps { interface FindingsSearchBarProps extends SearchBarQueryProps {
setQuery(v: Partial<SearchBarQueryProps>): void; setQuery(v: Partial<SearchBarQueryProps>): void;
loading: CspFindingsResult['loading'];
} }
type FindingsSearchBarProps = CspFindingsResponse & BaseFindingsSearchBarProps;
export const FindingsSearchBar = ({ export const FindingsSearchBar = ({
dataView, dataView,
query, query,
filters, filters,
status, loading,
setQuery, setQuery,
}: FindingsSearchBarProps & { dataView: DataView }) => { }: FindingsSearchBarProps & { dataView: DataView }) => {
const { euiTheme } = useEuiTheme(); const { euiTheme } = useEuiTheme();
@ -47,7 +46,7 @@ export const FindingsSearchBar = ({
showQueryInput={true} showQueryInput={true}
showDatePicker={false} showDatePicker={false}
showSaveQuery={false} showSaveQuery={false}
isLoading={status === 'loading'} isLoading={loading}
indexPatterns={[dataView]} indexPatterns={[dataView]}
query={query} query={query}
filters={filters} filters={filters}

View file

@ -57,8 +57,8 @@ type TableProps = PropsOf<typeof FindingsTable>;
describe('<FindingsTable />', () => { describe('<FindingsTable />', () => {
it('renders the zero state when status success and data has a length of zero ', async () => { it('renders the zero state when status success and data has a length of zero ', async () => {
const props: TableProps = { const props: TableProps = {
status: 'success', loading: false,
data: { data: [], total: 0 }, data: { page: [], total: 0 },
error: null, error: null,
sort: [], sort: [],
from: 1, from: 1,
@ -76,8 +76,8 @@ describe('<FindingsTable />', () => {
const data = names.map(getFakeFindings); const data = names.map(getFakeFindings);
const props: TableProps = { const props: TableProps = {
status: 'success', loading: false,
data: { data, total: 10 }, data: { page: data, total: 10 },
error: null, error: null,
sort: [], sort: [],
from: 0, from: 0,

View file

@ -22,7 +22,7 @@ import * as TEST_SUBJECTS from './test_subjects';
import * as TEXT from './translations'; import * as TEXT from './translations';
import type { CspFinding } from './types'; import type { CspFinding } from './types';
import { CspEvaluationBadge } from '../../components/csp_evaluation_badge'; 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'; import { FindingsRuleFlyout } from './findings_flyout';
type TableQueryProps = Pick<CspFindingsRequest, 'sort' | 'from' | 'size'>; type TableQueryProps = Pick<CspFindingsRequest, 'sort' | 'from' | 'size'>;
@ -31,7 +31,7 @@ interface BaseFindingsTableProps extends TableQueryProps {
setQuery(query: Partial<TableQueryProps>): void; setQuery(query: Partial<TableQueryProps>): void;
} }
type FindingsTableProps = CspFindingsResponse & BaseFindingsTableProps; type FindingsTableProps = CspFindingsResult & BaseFindingsTableProps;
const FindingsTableComponent = ({ const FindingsTableComponent = ({
setQuery, setQuery,
@ -39,7 +39,8 @@ const FindingsTableComponent = ({
size, size,
sort = [], sort = [],
error, error,
...props data,
loading,
}: FindingsTableProps) => { }: FindingsTableProps) => {
const [selectedFinding, setSelectedFinding] = useState<CspFinding>(); const [selectedFinding, setSelectedFinding] = useState<CspFinding>();
@ -48,9 +49,9 @@ const FindingsTableComponent = ({
getEuiPaginationFromEsSearchSource({ getEuiPaginationFromEsSearchSource({
from, from,
size, size,
total: props.status === 'success' ? props.data.total : 0, total: data?.total,
}), }),
[from, size, props] [from, size, data]
); );
const sorting = useMemo(() => getEuiSortFromEsSearchSource(sort), [sort]); const sorting = useMemo(() => getEuiSortFromEsSearchSource(sort), [sort]);
@ -70,7 +71,7 @@ const FindingsTableComponent = ({
); );
// Show "zero state" // Show "zero state"
if (props.status === 'success' && !props.data.data.length) if (!loading && !data?.page.length)
// TODO: use our own logo // TODO: use our own logo
return ( return (
<EuiEmptyPrompt <EuiEmptyPrompt
@ -84,9 +85,9 @@ const FindingsTableComponent = ({
<> <>
<EuiBasicTable <EuiBasicTable
data-test-subj={TEST_SUBJECTS.FINDINGS_TABLE} data-test-subj={TEST_SUBJECTS.FINDINGS_TABLE}
loading={props.status === 'loading'} loading={loading}
error={error ? extractErrorMessage(error) : undefined} error={error ? extractErrorMessage(error) : undefined}
items={props.data?.data || []} items={data?.page || []}
columns={columns} columns={columns}
pagination={pagination} pagination={pagination}
sorting={sorting} sorting={sorting}
@ -108,11 +109,11 @@ const getEuiPaginationFromEsSearchSource = ({
size: pageSize, size: pageSize,
total, total,
}: Pick<FindingsTableProps, 'from' | 'size'> & { }: Pick<FindingsTableProps, 'from' | 'size'> & {
total: number; total: number | undefined;
}): EuiBasicTableProps<CspFinding>['pagination'] => ({ }): EuiBasicTableProps<CspFinding>['pagination'] => ({
pageSize, pageSize,
pageIndex: Math.ceil(pageIndex / pageSize), pageIndex: Math.ceil(pageIndex / pageSize),
totalItemCount: total, totalItemCount: total || 0,
pageSizeOptions: [10, 25, 100], pageSizeOptions: [10, 25, 100],
showPerPageOptions: true, showPerPageOptions: true,
}); });

View file

@ -4,43 +4,41 @@
* 2.0; you may not use this file except in compliance with the Elastic License * 2.0; you may not use this file except in compliance with the Elastic License
* 2.0. * 2.0.
*/ */
import type { Filter } from '@kbn/es-query';
import { type UseQueryResult, useQuery } from 'react-query'; import { type UseQueryResult, useQuery } from 'react-query';
import type { AggregationsAggregate, SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import { number } from 'io-ts'; import { number } from 'io-ts';
import type { Filter } from '@kbn/es-query';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import type { import type {
DataView,
EsQuerySortValue, EsQuerySortValue,
IKibanaSearchResponse, IEsSearchResponse,
SerializedSearchSourceFields, SerializedSearchSourceFields,
} from '@kbn/data-plugin/common'; } from '@kbn/data-plugin/common';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { CoreStart } from '@kbn/core/public'; import type { CoreStart } from '@kbn/core/public';
import { extractErrorMessage } from '../../../common/utils/helpers'; import { extractErrorMessage } from '../../../common/utils/helpers';
import type { CspClientPluginStartDeps } from '../../types';
import * as TEXT from './translations'; import * as TEXT from './translations';
import type { CspFinding } from './types'; import type { CspFinding } from './types';
import { useKibana } from '../../common/hooks/use_kibana';
interface CspFindings { import type { FindingsBaseQuery } from './findings_container';
data: CspFinding[];
total: number;
}
export interface CspFindingsRequest export interface CspFindingsRequest
extends Required<Pick<SerializedSearchSourceFields, 'sort' | 'size' | 'from' | 'query'>> { extends Required<Pick<SerializedSearchSourceFields, 'sort' | 'size' | 'from' | 'query'>> {
filters: Filter[]; filters: Filter[];
} }
type ResponseProps = 'data' | 'error' | 'status'; type UseFindingsOptions = FindingsBaseQuery & Omit<CspFindingsRequest, 'filters' | 'query'>;
type Result = UseQueryResult<CspFindings, unknown>;
// TODO: use distributive Pick interface CspFindingsData {
export type CspFindingsResponse = page: CspFinding[];
| Pick<Extract<Result, { status: 'success' }>, ResponseProps> total: number;
| Pick<Extract<Result, { status: 'error' }>, ResponseProps> }
| Pick<Extract<Result, { status: 'idle' }>, ResponseProps>
| Pick<Extract<Result, { status: 'loading' }>, ResponseProps>; type Result = UseQueryResult<CspFindingsData, unknown>;
export interface CspFindingsResult {
loading: Result['isLoading'];
error: Result['error'];
data: CspFindingsData | undefined;
}
const FIELDS_WITHOUT_KEYWORD_MAPPING = new Set(['@timestamp']); const FIELDS_WITHOUT_KEYWORD_MAPPING = new Set(['@timestamp']);
@ -65,72 +63,49 @@ const mapEsQuerySortKey = (sort: readonly EsQuerySortValue[]): EsQuerySortValue[
return acc; return acc;
}, []); }, []);
const showResponseErrorToast = export const showErrorToast = (
({ toasts }: CoreStart['notifications']) => toasts: CoreStart['notifications']['toasts'],
(error: unknown): void => { error: unknown
if (error instanceof Error) toasts.addError(error, { title: TEXT.SEARCH_FAILED }); ): void => {
else toasts.addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED)); 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<CspFinding, Record<string, AggregationsAggregate>>
>): CspFindings => ({
// TODO: use 'fields' instead of '_source' ?
data: hits.hits.map((hit) => hit._source!),
total: number.is(hits.total) ? hits.total : 0,
});
const createFindingsSearchSource = (
{
query,
dataView,
filters,
...rest
}: Omit<CspFindingsRequest, 'queryKey'> & { 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
};
}; };
/** export const getFindingsQuery = ({
* @description a react-query#mutation wrapper on the data plugin searchSource index,
* @todo use 'searchAfter'. currently limited to 10k docs. see https://github.com/elastic/kibana/issues/116776 query,
*/ size,
export const useFindings = ( from,
dataView: DataView, sort,
searchProps: CspFindingsRequest, }: Omit<UseFindingsOptions, 'error'>) => ({
urlKey?: string // Needed when URL query (searchProps) didn't change (now-15) but require a refetch query,
): CspFindingsResponse => { size,
from,
sort: mapEsQuerySortKey(sort),
});
export const useFindings = ({ error, index, query, sort, from, size }: UseFindingsOptions) => {
const { const {
notifications, data,
data: { query, search }, notifications: { toasts },
} = useKibana<CspClientPluginStartDeps>().services; } = useKibana().services;
return useQuery( return useQuery(
['csp_findings', { searchProps, urlKey }], ['csp_findings', { from, size, query, sort }],
async () => { () =>
const source = await search.searchSource.create( lastValueFrom<IEsSearchResponse<CspFinding>>(
createFindingsSearchSource({ ...searchProps, dataView }, query) data.search.search({
); params: getFindingsQuery({ index, query, sort, from, size }),
})
const response = await lastValueFrom(source.fetch$()); ),
return response;
},
{ {
cacheTime: 0, enabled: !error,
onError: showResponseErrorToast(notifications!), select: ({ rawResponse: { hits } }) => ({
select: extractFindings, // 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),
} }
); );
}; };

View file

@ -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<estypes.SearchRequest>;
type FindingsAggResponse = IKibanaSearchResponse<estypes.SearchResponse<{}, FindingsAggs>>;
interface FindingsAggs extends estypes.AggregationsMultiBucketAggregateBase {
count: {
buckets: Array<{
key: string;
doc_count: number;
}>;
};
}
export const getFindingsCountAggQuery = ({ index, query }: Omit<FindingsBaseQuery, 'error'>) => ({
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<FindingsAggRequest, FindingsAggResponse>({
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>,
}
);
};