mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[Cloud Posture] add findings distribution bar (#129639)
This commit is contained in:
parent
ac5aca44e8
commit
1bfeab7553
8 changed files with 450 additions and 108 deletions
|
@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>,
|
||||
}
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue