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