[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.
*/
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<typeof getFindingsBaseEsQuery>;
// 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<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 }) => {
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 (
<div data-test-subj={TEST_SUBJECTS.FINDINGS_CONTAINER}>
<FindingsSearchBar
dataView={dataView}
setQuery={setUrlQuery}
{...findingsQuery}
{...findingsResult}
query={findingsQuery.query}
filters={findingsQuery.filters}
loading={findingsResult.isLoading}
/>
<div
css={css`
@ -80,7 +145,23 @@ export const FindingsContainer = ({ dataView }: { dataView: DataView }) => {
)}
<EuiSpacer />
{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 />}
</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 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<CspFindingsRequest, 'query' | 'filters'>;
interface BaseFindingsSearchBarProps extends SearchBarQueryProps {
interface FindingsSearchBarProps extends SearchBarQueryProps {
setQuery(v: Partial<SearchBarQueryProps>): 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}

View file

@ -57,8 +57,8 @@ type TableProps = PropsOf<typeof FindingsTable>;
describe('<FindingsTable />', () => {
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('<FindingsTable />', () => {
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,

View file

@ -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<CspFindingsRequest, 'sort' | 'from' | 'size'>;
@ -31,7 +31,7 @@ interface BaseFindingsTableProps extends TableQueryProps {
setQuery(query: Partial<TableQueryProps>): 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<CspFinding>();
@ -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 (
<EuiEmptyPrompt
@ -84,9 +85,9 @@ const FindingsTableComponent = ({
<>
<EuiBasicTable
data-test-subj={TEST_SUBJECTS.FINDINGS_TABLE}
loading={props.status === 'loading'}
loading={loading}
error={error ? extractErrorMessage(error) : undefined}
items={props.data?.data || []}
items={data?.page || []}
columns={columns}
pagination={pagination}
sorting={sorting}
@ -108,11 +109,11 @@ const getEuiPaginationFromEsSearchSource = ({
size: pageSize,
total,
}: Pick<FindingsTableProps, 'from' | 'size'> & {
total: number;
total: number | undefined;
}): EuiBasicTableProps<CspFinding>['pagination'] => ({
pageSize,
pageIndex: Math.ceil(pageIndex / pageSize),
totalItemCount: total,
totalItemCount: total || 0,
pageSizeOptions: [10, 25, 100],
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.
*/
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<Pick<SerializedSearchSourceFields, 'sort' | 'size' | 'from' | 'query'>> {
filters: Filter[];
}
type ResponseProps = 'data' | 'error' | 'status';
type Result = UseQueryResult<CspFindings, unknown>;
type UseFindingsOptions = FindingsBaseQuery & Omit<CspFindingsRequest, 'filters' | 'query'>;
// TODO: use distributive Pick
export type CspFindingsResponse =
| Pick<Extract<Result, { status: 'success' }>, ResponseProps>
| Pick<Extract<Result, { status: 'error' }>, ResponseProps>
| Pick<Extract<Result, { status: 'idle' }>, ResponseProps>
| Pick<Extract<Result, { status: 'loading' }>, ResponseProps>;
interface CspFindingsData {
page: CspFinding[];
total: number;
}
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']);
@ -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));
};
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 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));
};
/**
* @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 getFindingsQuery = ({
index,
query,
size,
from,
sort,
}: Omit<UseFindingsOptions, 'error'>) => ({
query,
size,
from,
sort: mapEsQuerySortKey(sort),
});
export const useFindings = ({ error, index, query, sort, from, size }: UseFindingsOptions) => {
const {
notifications,
data: { query, search },
} = useKibana<CspClientPluginStartDeps>().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<IEsSearchResponse<CspFinding>>(
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),
}
);
};

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