[Cloud Posture] use query service (#132849)

This commit is contained in:
Or Ouziel 2022-06-06 15:19:40 +03:00 committed by GitHub
parent 9431231502
commit e7a1cef66f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 451 additions and 371 deletions

View file

@ -19,7 +19,6 @@ 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';
import { getPaginationQuery } from '../utils';
import { FindingsEsPitContext } from '../es_pit/findings_es_pit_context';
@ -38,7 +37,10 @@ beforeEach(() => {
describe('<LatestFindingsContainer />', () => {
it('data#search.search fn called with URL query', () => {
const query = getDefaultQuery();
const query = getDefaultQuery({
filters: [],
query: { language: 'kuery', query: '' },
});
const dataMock = dataPluginMock.createStartContract();
const dataView = createStubDataView({
spec: {
@ -76,14 +78,11 @@ describe('<LatestFindingsContainer />', () => {
};
expect(dataMock.search.search).toHaveBeenNthCalledWith(1, {
params: getFindingsCountAggQuery(baseQuery),
});
expect(dataMock.search.search).toHaveBeenNthCalledWith(2, {
params: getFindingsQuery({
...baseQuery,
...getPaginationQuery(query),
sort: query.sort,
enabled: true,
}),
});
});

View file

@ -4,29 +4,36 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { EuiSpacer } from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { number } from 'io-ts';
import { EuiSpacer } from '@elastic/eui';
import type { FindingsBaseProps } from '../types';
import { FindingsTable } from './latest_findings_table';
import { FindingsSearchBar } from '../layout/findings_search_bar';
import * as TEST_SUBJECTS from '../test_subjects';
import { useUrlQuery } from '../../../common/hooks/use_url_query';
import { useLatestFindings } from './use_latest_findings';
import type { FindingsGroupByNoneQuery } from './use_latest_findings';
import type { FindingsBaseURLQuery } from '../types';
import { useFindingsCounter } from '../use_findings_count';
import { FindingsDistributionBar } from '../layout/findings_distribution_bar';
import { getBaseQuery, getPaginationQuery, getPaginationTableParams } from '../utils';
import {
getPaginationQuery,
getPaginationTableParams,
useBaseEsQuery,
usePersistedQuery,
} from '../utils';
import { PageWrapper, PageTitle, PageTitleText } from '../layout/findings_layout';
import { FindingsGroupBySelector } from '../layout/findings_group_by_selector';
import { useCspBreadcrumbs } from '../../../common/navigation/use_csp_breadcrumbs';
import { findingsNavigation } from '../../../common/navigation/constants';
import { useUrlQuery } from '../../../common/hooks/use_url_query';
import { ErrorCallout } from '../layout/error_callout';
export const getDefaultQuery = (): FindingsBaseURLQuery & FindingsGroupByNoneQuery => ({
query: { language: 'kuery', query: '' },
filters: [],
export const getDefaultQuery = ({
query,
filters,
}: FindingsBaseURLQuery): FindingsBaseURLQuery & FindingsGroupByNoneQuery => ({
query,
filters,
sort: { field: '@timestamp', direction: 'desc' },
pageIndex: 0,
pageSize: 10,
@ -34,28 +41,30 @@ export const getDefaultQuery = (): FindingsBaseURLQuery & FindingsGroupByNoneQue
export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => {
useCspBreadcrumbs([findingsNavigation.findings_default]);
const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery);
const baseEsQuery = useMemo(
() => getBaseQuery({ dataView, filters: urlQuery.filters, query: urlQuery.query }),
[dataView, urlQuery.filters, urlQuery.query]
);
const getPersistedDefaultQuery = usePersistedQuery(getDefaultQuery);
const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery);
const findingsCount = useFindingsCounter(baseEsQuery);
/**
* Page URL query to ES query
*/
const baseEsQuery = useBaseEsQuery({
dataView,
filters: urlQuery.filters,
query: urlQuery.query,
});
/**
* Page ES query result
*/
const findingsGroupByNone = useLatestFindings({
...baseEsQuery,
...getPaginationQuery({ pageIndex: urlQuery.pageIndex, pageSize: urlQuery.pageSize }),
query: baseEsQuery.query,
sort: urlQuery.sort,
enabled: !baseEsQuery.error,
});
const findingsDistribution = getFindingsDistribution({
total: findingsGroupByNone.data?.total,
passed: findingsCount.data?.passed,
failed: findingsCount.data?.failed,
pageIndex: urlQuery.pageIndex,
pageSize: urlQuery.pageSize,
currentPageSize: findingsGroupByNone.data?.page.length,
});
const error = findingsGroupByNone.error || baseEsQuery.error;
return (
<div data-test-subj={TEST_SUBJECTS.FINDINGS_CONTAINER}>
@ -64,31 +73,50 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => {
setQuery={(query) => {
setUrlQuery({ ...query, pageIndex: 0 });
}}
query={urlQuery.query}
filters={urlQuery.filters}
loading={findingsCount.isFetching}
loading={findingsGroupByNone.isFetching}
/>
<PageWrapper>
<LatestFindingsPageTitle />
<FindingsGroupBySelector type="default" />
{findingsDistribution && <FindingsDistributionBar {...findingsDistribution} />}
<EuiSpacer />
<FindingsTable
data={findingsGroupByNone.data}
error={findingsGroupByNone.error}
loading={findingsGroupByNone.isFetching}
pagination={getPaginationTableParams({
pageSize: urlQuery.pageSize,
pageIndex: urlQuery.pageIndex,
totalItemCount: findingsGroupByNone.data?.total || 0,
})}
sorting={{
sort: { field: urlQuery.sort.field, direction: urlQuery.sort.direction },
}}
setTableOptions={({ page, sort }) =>
setUrlQuery({ pageIndex: page.index, pageSize: page.size, sort })
}
/>
{error && <ErrorCallout error={error} />}
{!error && (
<>
<FindingsGroupBySelector type="default" />
{findingsGroupByNone.isSuccess && (
<FindingsDistributionBar
{...{
total: findingsGroupByNone.data.total,
passed: findingsGroupByNone.data.count.passed,
failed: findingsGroupByNone.data.count.failed,
...getFindingsPageSizeInfo({
pageIndex: urlQuery.pageIndex,
pageSize: urlQuery.pageSize,
currentPageSize: findingsGroupByNone.data.page.length,
}),
}}
/>
)}
<EuiSpacer />
<FindingsTable
loading={findingsGroupByNone.isFetching}
items={findingsGroupByNone.data?.page || []}
pagination={getPaginationTableParams({
pageSize: urlQuery.pageSize,
pageIndex: urlQuery.pageIndex,
totalItemCount: findingsGroupByNone.data?.total || 0,
})}
sorting={{
sort: { field: urlQuery.sort.field, direction: urlQuery.sort.direction },
}}
setTableOptions={({ page, sort }) =>
setUrlQuery({
sort,
pageIndex: page.index,
pageSize: page.size,
})
}
/>
</>
)}
</PageWrapper>
</div>
);
@ -102,23 +130,11 @@ const LatestFindingsPageTitle = () => (
</PageTitle>
);
const getFindingsDistribution = ({
total,
passed,
failed,
const getFindingsPageSizeInfo = ({
currentPageSize,
pageIndex,
pageSize,
}: Record<'currentPageSize' | 'total' | 'passed' | 'failed', number | undefined> &
Record<'pageIndex' | 'pageSize', number>) => {
if (!number.is(total) || !number.is(passed) || !number.is(failed) || !number.is(currentPageSize))
return;
return {
total,
passed,
failed,
pageStart: pageIndex * pageSize + 1,
pageEnd: pageIndex * pageSize + currentPageSize,
};
};
}: Record<'pageIndex' | 'pageSize' | 'currentPageSize', number>) => ({
pageStart: pageIndex * pageSize + 1,
pageEnd: pageIndex * pageSize + currentPageSize,
});

View file

@ -68,8 +68,7 @@ describe('<FindingsTable />', () => {
it('renders the zero state when status success and data has a length of zero ', async () => {
const props: TableProps = {
loading: false,
data: { page: [], total: 0 },
error: null,
items: [],
sorting: { sort: { field: '@timestamp', direction: 'desc' } },
pagination: { pageSize: 10, pageIndex: 1, totalItemCount: 0 },
setTableOptions: jest.fn(),
@ -90,8 +89,7 @@ describe('<FindingsTable />', () => {
const props: TableProps = {
loading: false,
data: { page: data, total: 10 },
error: null,
items: data,
sorting: { sort: { field: '@timestamp', direction: 'desc' } },
pagination: { pageSize: 10, pageIndex: 1, totalItemCount: 0 },
setTableOptions: jest.fn(),

View file

@ -14,32 +14,29 @@ import {
type CriteriaWithPagination,
type EuiTableActionsColumnType,
} from '@elastic/eui';
import { extractErrorMessage } from '../../../../common/utils/helpers';
import * as TEST_SUBJECTS from '../test_subjects';
import * as TEXT from '../translations';
import type { CspFinding } from '../types';
import type { CspFindingsResult } from './use_latest_findings';
import { FindingsRuleFlyout } from '../findings_flyout/findings_flyout';
import { getExpandColumn, getFindingsColumns } from '../layout/findings_layout';
type TableProps = Required<EuiBasicTableProps<CspFinding>>;
interface BaseFindingsTableProps {
interface Props {
loading: boolean;
items: CspFinding[];
pagination: Pagination;
sorting: TableProps['sorting'];
setTableOptions(options: CriteriaWithPagination<CspFinding>): void;
}
type FindingsTableProps = CspFindingsResult & BaseFindingsTableProps;
const FindingsTableComponent = ({
error,
data,
loading,
items,
pagination,
sorting,
setTableOptions,
}: FindingsTableProps) => {
}: Props) => {
const [selectedFinding, setSelectedFinding] = useState<CspFinding>();
const columns: [
@ -51,7 +48,7 @@ const FindingsTableComponent = ({
);
// Show "zero state"
if (!loading && !data?.page.length)
if (!loading && !items.length)
// TODO: use our own logo
return (
<EuiEmptyPrompt
@ -64,10 +61,9 @@ const FindingsTableComponent = ({
return (
<>
<EuiBasicTable
data-test-subj={TEST_SUBJECTS.FINDINGS_TABLE}
loading={loading}
error={error ? extractErrorMessage(error) : undefined}
items={data?.page || []}
data-test-subj={TEST_SUBJECTS.FINDINGS_TABLE}
items={items}
columns={columns}
pagination={pagination}
sorting={sorting}

View file

@ -8,15 +8,14 @@ import { useContext } from 'react';
import { useQuery } from 'react-query';
import { number } from 'io-ts';
import { lastValueFrom } from 'rxjs';
import type { IEsSearchResponse } from '@kbn/data-plugin/common';
import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common';
import type { CoreStart } from '@kbn/core/public';
import type { Criteria, Pagination } from '@elastic/eui';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { FindingsEsPitContext } from '../es_pit/findings_es_pit_context';
import { extractErrorMessage } from '../../../../common/utils/helpers';
import * as TEXT from '../translations';
import type { CspFindingsQueryData } from '../types';
import type { CspFinding, FindingsQueryResult } from '../types';
import type { CspFinding } from '../types';
import { useKibana } from '../../../common/hooks/use_kibana';
import type { FindingsBaseEsQuery } from '../types';
import { FINDINGS_REFETCH_INTERVAL_MS } from '../constants';
@ -25,6 +24,7 @@ interface UseFindingsOptions extends FindingsBaseEsQuery {
from: NonNullable<estypes.SearchRequest['from']>;
size: NonNullable<estypes.SearchRequest['size']>;
sort: Sort;
enabled: boolean;
}
type Sort = NonNullable<Criteria<CspFinding>['sort']>;
@ -35,7 +35,14 @@ export interface FindingsGroupByNoneQuery {
sort: Sort;
}
export type CspFindingsResult = FindingsQueryResult<CspFindingsQueryData | undefined, unknown>;
type LatestFindingsRequest = IKibanaSearchRequest<estypes.SearchRequest>;
type LatestFindingsResponse = IKibanaSearchResponse<
estypes.SearchResponse<CspFinding, FindingsAggs>
>;
interface FindingsAggs {
count: estypes.AggregationsMultiBucketAggregateBase<estypes.AggregationsStringRareTermsBucketKeys>;
}
const FIELDS_WITHOUT_KEYWORD_MAPPING = new Set([
'@timestamp',
@ -63,42 +70,52 @@ export const getFindingsQuery = ({
sort,
pitId,
}: UseFindingsOptions & { pitId: string }) => ({
query,
size,
from,
sort: [{ [getSortKey(sort.field)]: sort.direction }],
body: {
query,
sort: [{ [getSortKey(sort.field)]: sort.direction }],
size,
from,
aggs: { count: { terms: { field: 'result.evaluation.keyword' } } },
},
pit: { id: pitId },
ignore_unavailable: false,
});
export const useLatestFindings = ({ query, sort, from, size }: UseFindingsOptions) => {
export const useLatestFindings = (options: UseFindingsOptions) => {
const {
data,
notifications: { toasts },
} = useKibana().services;
const { pitIdRef, setPitId } = useContext(FindingsEsPitContext);
const pitId = pitIdRef.current;
const params = { ...options, pitId: pitIdRef.current };
return useQuery<
IEsSearchResponse<CspFinding>,
unknown,
CspFindingsQueryData & { newPitId: string }
>(
['csp_findings', { query, sort, from, size, pitId }],
() =>
lastValueFrom<IEsSearchResponse<CspFinding>>(
data.search.search({
params: getFindingsQuery({ query, sort, from, size, pitId }),
return useQuery(
['csp_findings', { params }],
async () => {
const {
rawResponse: { hits, aggregations, pit_id: newPitId },
} = await lastValueFrom(
data.search.search<LatestFindingsRequest, LatestFindingsResponse>({
params: getFindingsQuery(params),
})
),
{
keepPreviousData: true,
select: ({ rawResponse: { hits, pit_id: newPitId } }) => ({
);
if (!aggregations) throw new Error('expected aggregations to be an defined');
if (!Array.isArray(aggregations.count.buckets))
throw new Error('expected buckets to be an array');
return {
page: hits.hits.map((hit) => hit._source!),
total: number.is(hits.total) ? hits.total : 0,
count: getAggregationCount(aggregations.count.buckets),
newPitId: newPitId!,
}),
onError: (err) => showErrorToast(toasts, err),
};
},
{
enabled: options.enabled,
keepPreviousData: true,
onError: (err: Error) => showErrorToast(toasts, err),
onSuccess: ({ newPitId }) => {
setPitId(newPitId);
},
@ -108,3 +125,13 @@ export const useLatestFindings = ({ query, sort, from, size }: UseFindingsOption
}
);
};
const getAggregationCount = (buckets: estypes.AggregationsStringRareTermsBucketKeys[]) => {
const passed = buckets.find((bucket) => bucket.key === 'passed');
const failed = buckets.find((bucket) => bucket.key === 'failed');
return {
passed: passed?.doc_count || 0,
failed: failed?.doc_count || 0,
};
};

View file

@ -13,16 +13,25 @@ import { useUrlQuery } from '../../../common/hooks/use_url_query';
import type { FindingsBaseProps, FindingsBaseURLQuery } from '../types';
import { FindingsByResourceQuery, useFindingsByResource } from './use_findings_by_resource';
import { FindingsByResourceTable } from './findings_by_resource_table';
import { getBaseQuery, getPaginationQuery, getPaginationTableParams } from '../utils';
import {
getPaginationQuery,
getPaginationTableParams,
useBaseEsQuery,
usePersistedQuery,
} from '../utils';
import { PageTitle, PageTitleText, PageWrapper } from '../layout/findings_layout';
import { FindingsGroupBySelector } from '../layout/findings_group_by_selector';
import { findingsNavigation } from '../../../common/navigation/constants';
import { useCspBreadcrumbs } from '../../../common/navigation/use_csp_breadcrumbs';
import { ResourceFindings } from './resource_findings/resource_findings_container';
import { ErrorCallout } from '../layout/error_callout';
const getDefaultQuery = (): FindingsBaseURLQuery & FindingsByResourceQuery => ({
query: { language: 'kuery', query: '' },
filters: [],
const getDefaultQuery = ({
query,
filters,
}: FindingsBaseURLQuery): FindingsBaseURLQuery & FindingsByResourceQuery => ({
query,
filters,
pageIndex: 0,
pageSize: 10,
});
@ -43,12 +52,30 @@ export const FindingsByResourceContainer = ({ dataView }: FindingsBaseProps) =>
const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => {
useCspBreadcrumbs([findingsNavigation.findings_by_resource]);
const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery);
const findingsGroupByResource = useFindingsByResource({
...getBaseQuery({ dataView, filters: urlQuery.filters, query: urlQuery.query }),
...getPaginationQuery(urlQuery),
const getPersistedDefaultQuery = usePersistedQuery(getDefaultQuery);
const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery);
/**
* Page URL query to ES query
*/
const baseEsQuery = useBaseEsQuery({
dataView,
filters: urlQuery.filters,
query: urlQuery.query,
});
/**
* Page ES query result
*/
const findingsGroupByResource = useFindingsByResource({
...getPaginationQuery(urlQuery),
query: baseEsQuery.query,
enabled: !baseEsQuery.error,
});
const error = findingsGroupByResource.error || baseEsQuery.error;
return (
<div data-test-subj={TEST_SUBJECTS.FINDINGS_CONTAINER}>
<FindingsSearchBar
@ -56,9 +83,7 @@ const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => {
setQuery={(query) => {
setUrlQuery({ ...query, pageIndex: 0 });
}}
query={urlQuery.query}
filters={urlQuery.filters}
loading={findingsGroupByResource.isLoading}
loading={findingsGroupByResource.isFetching}
/>
<PageWrapper>
<PageTitle>
@ -71,20 +96,25 @@ const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => {
}
/>
</PageTitle>
<FindingsGroupBySelector type="resource" />
<FindingsByResourceTable
data={findingsGroupByResource.data}
error={findingsGroupByResource.error}
loading={findingsGroupByResource.isLoading}
pagination={getPaginationTableParams({
pageSize: urlQuery.pageSize,
pageIndex: urlQuery.pageIndex,
totalItemCount: findingsGroupByResource.data?.total || 0,
})}
setTableOptions={({ page }) =>
setUrlQuery({ pageIndex: page.index, pageSize: page.size })
}
/>
{error && <ErrorCallout error={error} />}
{!error && (
<>
<FindingsGroupBySelector type="resource" />
<FindingsByResourceTable
loading={findingsGroupByResource.isFetching}
items={findingsGroupByResource.data?.page || []}
pagination={getPaginationTableParams({
pageSize: urlQuery.pageSize,
pageIndex: urlQuery.pageIndex,
totalItemCount: findingsGroupByResource.data?.total || 0,
})}
setTableOptions={({ page }) =>
setUrlQuery({ pageIndex: page.index, pageSize: page.size })
}
/>
</>
)}
</PageWrapper>
</div>
);

View file

@ -7,21 +7,17 @@
import React from 'react';
import { render, screen, within } from '@testing-library/react';
import * as TEST_SUBJECTS from '../test_subjects';
import {
FindingsByResourceTable,
formatNumber,
getResourceId,
type CspFindingsByResource,
} from './findings_by_resource_table';
import { FindingsByResourceTable, formatNumber, getResourceId } from './findings_by_resource_table';
import * as TEXT from '../translations';
import type { PropsOf } from '@elastic/eui';
import Chance from 'chance';
import numeral from '@elastic/numeral';
import { TestProvider } from '../../../test/test_provider';
import type { FindingsByResourcePage } from './use_findings_by_resource';
const chance = new Chance();
const getFakeFindingsByResource = (): CspFindingsByResource => {
const getFakeFindingsByResource = (): FindingsByResourcePage => {
const count = chance.integer();
const total = chance.integer() + count + 1;
const normalized = count / total;
@ -46,8 +42,7 @@ describe('<FindingsByResourceTable />', () => {
it('renders the zero state when status success and data has a length of zero ', async () => {
const props: TableProps = {
loading: false,
data: { page: [], total: 0 },
error: null,
items: [],
pagination: { pageIndex: 0, pageSize: 10, totalItemCount: 0 },
setTableOptions: jest.fn(),
};
@ -66,8 +61,7 @@ describe('<FindingsByResourceTable />', () => {
const props: TableProps = {
loading: false,
data: { page: data, total: data.length },
error: null,
items: data,
pagination: { pageIndex: 0, pageSize: 10, totalItemCount: 0 },
setTableOptions: jest.fn(),
};

View file

@ -19,46 +19,41 @@ import { FormattedMessage } from '@kbn/i18n-react';
import numeral from '@elastic/numeral';
import { Link, generatePath } from 'react-router-dom';
import { ColumnNameWithTooltip } from '../../../components/column_name_with_tooltip';
import { extractErrorMessage } from '../../../../common/utils/helpers';
import * as TEST_SUBJECTS from '../test_subjects';
import * as TEXT from '../translations';
import type { CspFindingsByResourceResult } from './use_findings_by_resource';
import type { FindingsByResourcePage } from './use_findings_by_resource';
import { findingsNavigation } from '../../../common/navigation/constants';
export const formatNumber = (value: number) =>
value < 1000 ? value : numeral(value).format('0.0a');
export type CspFindingsByResource = NonNullable<
CspFindingsByResourceResult['data']
>['page'][number];
interface Props extends CspFindingsByResourceResult {
interface Props {
items: FindingsByResourcePage[];
loading: boolean;
pagination: Pagination;
setTableOptions(options: CriteriaWithPagination<CspFindingsByResource>): void;
setTableOptions(options: CriteriaWithPagination<FindingsByResourcePage>): void;
}
export const getResourceId = (resource: CspFindingsByResource) =>
export const getResourceId = (resource: FindingsByResourcePage) =>
[resource.resource_id, ...resource.cis_sections].join('/');
const FindingsByResourceTableComponent = ({
error,
data,
items,
loading,
pagination,
setTableOptions,
}: Props) => {
const getRowProps = (row: CspFindingsByResource) => ({
const getRowProps = (row: FindingsByResourcePage) => ({
'data-test-subj': TEST_SUBJECTS.getFindingsByResourceTableRowTestId(getResourceId(row)),
});
if (!loading && !data?.page.length)
if (!loading && !items.length)
return <EuiEmptyPrompt iconType="logoKibana" title={<h2>{TEXT.NO_FINDINGS}</h2>} />;
return (
<EuiBasicTable
loading={loading}
error={error ? extractErrorMessage(error) : undefined}
items={data?.page || []}
items={items}
columns={columns}
rowProps={getRowProps}
pagination={pagination}
@ -67,7 +62,7 @@ const FindingsByResourceTableComponent = ({
);
};
const columns: Array<EuiTableFieldDataColumnType<CspFindingsByResource>> = [
const columns: Array<EuiTableFieldDataColumnType<FindingsByResourcePage>> = [
{
field: 'resource_id',
name: (
@ -81,7 +76,7 @@ const columns: Array<EuiTableFieldDataColumnType<CspFindingsByResource>> = [
)}
/>
),
render: (resourceId: CspFindingsByResource['resource_id']) => (
render: (resourceId: FindingsByResourcePage['resource_id']) => (
<Link to={generatePath(findingsNavigation.resource_findings.path, { resourceId })}>
{resourceId}
</Link>
@ -143,7 +138,7 @@ const columns: Array<EuiTableFieldDataColumnType<CspFindingsByResource>> = [
defaultMessage="Failed Findings"
/>
),
render: (failedFindings: CspFindingsByResource['failed_findings']) => (
render: (failedFindings: FindingsByResourcePage['failed_findings']) => (
<EuiToolTip
content={i18n.translate('xpack.csp.findings.groupByResourceTable.failedFindingsToolTip', {
defaultMessage: '{failed} out of {total}',

View file

@ -16,14 +16,23 @@ import { useCspBreadcrumbs } from '../../../../common/navigation/use_csp_breadcr
import { findingsNavigation } from '../../../../common/navigation/constants';
import { ResourceFindingsQuery, useResourceFindings } from './use_resource_findings';
import { useUrlQuery } from '../../../../common/hooks/use_url_query';
import type { FindingsBaseProps, FindingsBaseURLQuery } from '../../types';
import { getBaseQuery, getPaginationQuery, getPaginationTableParams } from '../../utils';
import type { FindingsBaseURLQuery, FindingsBaseProps } from '../../types';
import {
getPaginationQuery,
getPaginationTableParams,
useBaseEsQuery,
usePersistedQuery,
} from '../../utils';
import { ResourceFindingsTable } from './resource_findings_table';
import { FindingsSearchBar } from '../../layout/findings_search_bar';
import { ErrorCallout } from '../../layout/error_callout';
const getDefaultQuery = (): FindingsBaseURLQuery & ResourceFindingsQuery => ({
query: { language: 'kuery', query: '' },
filters: [],
const getDefaultQuery = ({
query,
filters,
}: FindingsBaseURLQuery): FindingsBaseURLQuery & ResourceFindingsQuery => ({
query,
filters,
pageIndex: 0,
pageSize: 10,
});
@ -43,21 +52,34 @@ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => {
useCspBreadcrumbs([findingsNavigation.findings_default]);
const { euiTheme } = useEuiTheme();
const params = useParams<{ resourceId: string }>();
const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery);
const getPersistedDefaultQuery = usePersistedQuery(getDefaultQuery);
const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery);
/**
* Page URL query to ES query
*/
const baseEsQuery = useBaseEsQuery({
dataView,
filters: urlQuery.filters,
query: urlQuery.query,
});
/**
* Page ES query result
*/
const resourceFindings = useResourceFindings({
resourceId: params.resourceId,
...getBaseQuery({
dataView,
filters: urlQuery.filters,
query: urlQuery.query,
}),
...getPaginationQuery({
pageSize: urlQuery.pageSize,
pageIndex: urlQuery.pageIndex,
}),
query: baseEsQuery.query,
resourceId: params.resourceId,
enabled: !baseEsQuery.error,
});
const error = resourceFindings.error || baseEsQuery.error;
return (
<div data-test-subj={TEST_SUBJECTS.FINDINGS_CONTAINER}>
<FindingsSearchBar
@ -65,8 +87,6 @@ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => {
setQuery={(query) => {
setUrlQuery({ ...query, pageIndex: 0 });
}}
query={urlQuery.query}
filters={urlQuery.filters}
loading={resourceFindings.isFetching}
/>
<PageWrapper>
@ -85,19 +105,21 @@ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => {
/>
</PageTitle>
<EuiSpacer />
<ResourceFindingsTable
loading={resourceFindings.isFetching}
data={resourceFindings.data}
error={resourceFindings.error}
pagination={getPaginationTableParams({
pageSize: urlQuery.pageSize,
pageIndex: urlQuery.pageIndex,
totalItemCount: resourceFindings.data?.total || 0,
})}
setTableOptions={({ page }) =>
setUrlQuery({ pageIndex: page.index, pageSize: page.size })
}
/>
{error && <ErrorCallout error={error} />}
{!error && (
<ResourceFindingsTable
loading={resourceFindings.isFetching}
items={resourceFindings.data?.page || []}
pagination={getPaginationTableParams({
pageSize: urlQuery.pageSize,
pageIndex: urlQuery.pageIndex,
totalItemCount: resourceFindings.data?.total || 0,
})}
setTableOptions={({ page }) =>
setUrlQuery({ pageIndex: page.index, pageSize: page.size })
}
/>
)}
</PageWrapper>
</div>
);

View file

@ -13,25 +13,19 @@ import {
EuiBasicTableColumn,
EuiTableActionsColumnType,
} from '@elastic/eui';
import { extractErrorMessage } from '../../../../../common/utils/helpers';
import * as TEXT from '../../translations';
import type { ResourceFindingsResult } from './use_resource_findings';
import { getExpandColumn, getFindingsColumns } from '../../layout/findings_layout';
import type { CspFinding } from '../../types';
import { FindingsRuleFlyout } from '../../findings_flyout/findings_flyout';
interface Props extends ResourceFindingsResult {
interface Props {
items: CspFinding[];
loading: boolean;
pagination: Pagination;
setTableOptions(options: CriteriaWithPagination<CspFinding>): void;
}
const ResourceFindingsTableComponent = ({
error,
data,
loading,
pagination,
setTableOptions,
}: Props) => {
const ResourceFindingsTableComponent = ({ items, loading, pagination, setTableOptions }: Props) => {
const [selectedFinding, setSelectedFinding] = useState<CspFinding>();
const columns: [
@ -41,16 +35,14 @@ const ResourceFindingsTableComponent = ({
() => [getExpandColumn<CspFinding>({ onClick: setSelectedFinding }), ...getFindingsColumns()],
[]
);
if (!loading && !data?.page.length)
if (!loading && !items.length)
return <EuiEmptyPrompt iconType="logoKibana" title={<h2>{TEXT.NO_FINDINGS}</h2>} />;
return (
<>
<EuiBasicTable
loading={loading}
error={error ? extractErrorMessage(error) : undefined}
items={data?.page || []}
items={items}
columns={columns}
onChange={setTableOptions}
pagination={pagination}

View file

@ -10,17 +10,18 @@ import { IEsSearchResponse } from '@kbn/data-plugin/common';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { Pagination } from '@elastic/eui';
import { useContext } from 'react';
import { number } from 'io-ts';
import { FindingsEsPitContext } from '../../es_pit/findings_es_pit_context';
import { FINDINGS_REFETCH_INTERVAL_MS } from '../../constants';
import { useKibana } from '../../../../common/hooks/use_kibana';
import { showErrorToast } from '../../latest_findings/use_latest_findings';
import type { CspFindingsQueryData } from '../../types';
import type { CspFinding, FindingsBaseEsQuery, FindingsQueryResult } from '../../types';
import type { CspFinding, FindingsBaseEsQuery } from '../../types';
interface UseResourceFindingsOptions extends FindingsBaseEsQuery {
resourceId: string;
from: NonNullable<estypes.SearchRequest['from']>;
size: NonNullable<estypes.SearchRequest['size']>;
enabled: boolean;
}
export interface ResourceFindingsQuery {
@ -28,8 +29,6 @@ export interface ResourceFindingsQuery {
pageSize: Pagination['pageSize'];
}
export type ResourceFindingsResult = FindingsQueryResult<CspFindingsQueryData | undefined, unknown>;
const getResourceFindingsQuery = ({
query,
resourceId,
@ -52,40 +51,32 @@ const getResourceFindingsQuery = ({
ignore_unavailable: false,
});
export const useResourceFindings = ({
query,
resourceId,
from,
size,
}: UseResourceFindingsOptions) => {
export const useResourceFindings = (options: UseResourceFindingsOptions) => {
const {
data,
notifications: { toasts },
} = useKibana().services;
const { pitIdRef, setPitId } = useContext(FindingsEsPitContext);
const pitId = pitIdRef.current;
const params = { ...options, pitId: pitIdRef.current };
return useQuery<
IEsSearchResponse<CspFinding>,
unknown,
CspFindingsQueryData & { newPitId: string }
>(
['csp_resource_findings', { query, resourceId, from, size, pitId }],
return useQuery(
['csp_resource_findings', { params }],
() =>
lastValueFrom<IEsSearchResponse<CspFinding>>(
lastValueFrom(
data.search.search({
params: getResourceFindingsQuery({ query, resourceId, from, size, pitId }),
params: getResourceFindingsQuery(params),
})
),
{
enabled: options.enabled,
keepPreviousData: true,
select: ({ rawResponse: { hits, pit_id: newPitId } }) => ({
select: ({ rawResponse: { hits, pit_id: newPitId } }: IEsSearchResponse<CspFinding>) => ({
page: hits.hits.map((hit) => hit._source!),
total: hits.total as number,
total: number.is(hits.total) ? hits.total : 0,
newPitId: newPitId!,
}),
onError: (err) => showErrorToast(toasts, err),
onError: (err: Error) => showErrorToast(toasts, err),
onSuccess: ({ newPitId }) => {
setPitId(newPitId);
},

View file

@ -14,7 +14,13 @@ import { FindingsEsPitContext } from '../es_pit/findings_es_pit_context';
import { FINDINGS_REFETCH_INTERVAL_MS } from '../constants';
import { useKibana } from '../../../common/hooks/use_kibana';
import { showErrorToast } from '../latest_findings/use_latest_findings';
import type { FindingsBaseEsQuery, FindingsQueryResult } from '../types';
import type { FindingsBaseEsQuery } from '../types';
interface UseFindingsByResourceOptions extends FindingsBaseEsQuery {
from: NonNullable<estypes.SearchRequest['from']>;
size: NonNullable<estypes.SearchRequest['size']>;
enabled: boolean;
}
// Maximum number of grouped findings, default limit in elasticsearch is set to 65,536 (ref: https://www.elastic.co/guide/en/elasticsearch/reference/current/search-settings.html#search-settings-max-buckets)
const MAX_BUCKETS = 60 * 1000;
@ -34,7 +40,7 @@ type FindingsAggResponse = IKibanaSearchResponse<
estypes.SearchResponse<{}, FindingsByResourceAggs>
>;
interface FindingsByResourcePage {
export interface FindingsByResourcePage {
failed_findings: {
count: number;
normalized: number;
@ -47,16 +53,6 @@ interface FindingsByResourcePage {
cis_sections: string[];
}
interface UseFindingsByResourceData {
page: FindingsByResourcePage[];
total: number;
}
export type CspFindingsByResourceResult = FindingsQueryResult<
UseFindingsByResourceData | undefined,
unknown
>;
interface FindingsByResourceAggs {
resource_total: estypes.AggregationsCardinalityAggregate;
resources: estypes.AggregationsMultiBucketAggregateBase<FindingsAggBucket>;
@ -120,37 +116,41 @@ export const getFindingsByResourceAggQuery = ({
ignore_unavailable: false,
});
export const useFindingsByResource = ({ query, from, size }: UseResourceFindingsOptions) => {
export const useFindingsByResource = (options: UseFindingsByResourceOptions) => {
const {
data,
notifications: { toasts },
} = useKibana().services;
const { pitIdRef, setPitId } = useContext(FindingsEsPitContext);
const pitId = pitIdRef.current;
const params = { ...options, pitId: pitIdRef.current };
return useQuery<UseFindingsByResourceData & { newPitId: string }>(
['csp_findings_resource', { query, size, from, pitId }],
() =>
lastValueFrom(
return useQuery(
['csp_findings_resource', { params }],
async () => {
const {
rawResponse: { aggregations, pit_id: newPitId },
} = await lastValueFrom(
data.search.search<FindingsAggRequest, FindingsAggResponse>({
params: getFindingsByResourceAggQuery({ query, from, size, pitId }),
params: getFindingsByResourceAggQuery(params),
})
).then(({ rawResponse: { aggregations, pit_id: newPitId } }) => {
if (!aggregations) throw new Error('expected aggregations to be defined');
);
if (!Array.isArray(aggregations.resources.buckets))
throw new Error('expected resources buckets to be an array');
if (!aggregations) throw new Error('expected aggregations to be defined');
return {
page: aggregations.resources.buckets.map(createFindingsByResource),
total: aggregations.resource_total.value,
newPitId: newPitId!,
};
}),
if (!Array.isArray(aggregations.resources.buckets))
throw new Error('expected buckets to be an array');
return {
page: aggregations.resources.buckets.map(createFindingsByResource),
total: aggregations.resource_total.value,
newPitId: newPitId!,
};
},
{
enabled: options.enabled,
keepPreviousData: true,
onError: (err) => showErrorToast(toasts, err),
onError: (err: Error) => showErrorToast(toasts, err),
onSuccess: ({ newPitId }) => {
setPitId(newPitId);
},
@ -176,9 +176,9 @@ const createFindingsByResource = (resource: FindingsAggBucket): FindingsByResour
return {
resource_id: resource.key,
resource_name: resource.name.buckets[0].key,
resource_subtype: resource.subtype.buckets[0].key,
cluster_id: resource.cluster_id.buckets[0].key,
resource_name: resource.name.buckets[0]?.key,
resource_subtype: resource.subtype.buckets[0]?.key,
cluster_id: resource.cluster_id.buckets[0]?.key,
cis_sections: resource.cis_sections.buckets.map((v) => v.key),
failed_findings: {
count: resource.failed_findings.doc_count,

View file

@ -0,0 +1,43 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiCallOut,
EuiSpacer,
EuiButton,
} from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { PAGE_SEARCH_ERROR_MESSAGE } from '../translations';
import { useKibana } from '../../../common/hooks/use_kibana';
export const ErrorCallout = ({ error }: { error: Error }) => {
const {
data: { search },
} = useKibana().services;
return (
<EuiFlexGroup justifyContent="center" alignItems="center">
<EuiFlexItem grow={false}>
<EuiPanel paddingSize="l" grow>
<EuiCallOut title={PAGE_SEARCH_ERROR_MESSAGE} color="danger" iconType="alert">
<EuiSpacer />
<EuiButton size="s" color="danger" onClick={() => search.showError(error)}>
<FormattedMessage
id="xpack.csp.findings.showErrorButtonLabel"
defaultMessage="Show error message"
/>
</EuiButton>
</EuiCallOut>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -9,8 +9,8 @@ import { css } from '@emotion/react';
import {
EuiHealth,
EuiBadge,
EuiTextColor,
EuiSpacer,
EuiTextColor,
EuiFlexGroup,
EuiFlexItem,
useEuiTheme,
@ -33,21 +33,21 @@ export const FindingsDistributionBar = (props: Props) => (
<div>
<Counters {...props} />
<EuiSpacer size="s" />
<DistributionBar {...props} />
{<DistributionBar {...props} />}
</div>
);
const Counters = ({ pageStart, pageEnd, total, failed, passed }: Props) => (
const Counters = (props: Props) => (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<CurrentPageOfTotal pageStart={pageStart} pageEnd={pageEnd} total={total} />
<CurrentPageOfTotal {...props} />
</EuiFlexItem>
<EuiFlexItem
css={css`
align-items: flex-end;
`}
>
<PassedFailedCounters passed={passed} failed={failed} />
<PassedFailedCounters {...props} />
</EuiFlexItem>
</EuiFlexGroup>
);
@ -100,6 +100,7 @@ const CurrentPageOfTotal = ({
const DistributionBar: React.FC<Omit<Props, 'pageEnd' | 'pageStart'>> = ({ passed, failed }) => {
const { euiTheme } = useEuiTheme();
return (
<EuiFlexGroup
gutterSize="none"

View file

@ -17,15 +17,13 @@ import { FINDINGS_SEARCH_PLACEHOLDER } from '../translations';
type SearchBarQueryProps = Pick<FindingsBaseURLQuery, 'query' | 'filters'>;
interface FindingsSearchBarProps extends SearchBarQueryProps {
interface FindingsSearchBarProps {
setQuery(v: Partial<SearchBarQueryProps>): void;
loading: boolean;
}
export const FindingsSearchBar = ({
dataView,
query,
filters,
loading,
setQuery,
}: FindingsSearchBarProps & { dataView: DataView }) => {
@ -48,8 +46,6 @@ export const FindingsSearchBar = ({
showSaveQuery={false}
isLoading={loading}
indexPatterns={[dataView]}
query={query}
filters={filters}
onQuerySubmit={setQuery}
// @ts-expect-error onFiltersUpdated is a valid prop on SearchBar
onFiltersUpdated={(value: Filter[]) => setQuery({ filters: value })}

View file

@ -268,3 +268,8 @@ export const FINDINGS_SEARCH_PLACEHOLDER = i18n.translate(
'xpack.csp.findings.searchBar.searchPlaceholder',
{ defaultMessage: 'Search findings (eg. rule.section.keyword : "API Server" )' }
);
export const PAGE_SEARCH_ERROR_MESSAGE = i18n.translate(
'xpack.csp.findings.errorCallout.pageSearchErrorTitle',
{ defaultMessage: 'We encountered an error retrieving search results' }
);

View file

@ -6,7 +6,6 @@
*/
import type { DataView } from '@kbn/data-views-plugin/common';
import type { BoolQuery, Filter, Query } from '@kbn/es-query';
import type { UseQueryResult } from 'react-query';
export type FindingsGroupByKind = 'default' | 'resource';
@ -25,12 +24,6 @@ export interface FindingsBaseEsQuery {
};
}
export interface FindingsQueryResult<TData = unknown, TError = unknown> {
loading: UseQueryResult['isLoading'];
error: TError;
data: TData;
}
// TODO: this needs to be defined in a versioned schema
export interface CspFinding {
'@timestamp': string;

View file

@ -1,81 +0,0 @@
/*
* 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 { useContext } from 'react';
import { useKibana } from '../../common/hooks/use_kibana';
import { showErrorToast } from './latest_findings/use_latest_findings';
import type { FindingsBaseEsQuery } from './types';
import { FindingsEsPitContext } from './es_pit/findings_es_pit_context';
type FindingsAggRequest = IKibanaSearchRequest<estypes.SearchRequest>;
type FindingsAggResponse = IKibanaSearchResponse<estypes.SearchResponse<{}, FindingsAggs>>;
interface FindingsAggs extends estypes.AggregationsMultiBucketAggregateBase {
count: {
buckets: Array<{
key: string;
doc_count: number;
}>;
};
}
interface UseFindingsCounterData {
passed: number;
failed: number;
}
export const getFindingsCountAggQuery = ({
query,
pitId,
}: FindingsBaseEsQuery & { pitId: string }) => ({
size: 0,
track_total_hits: true,
body: {
query,
aggs: { count: { terms: { field: 'result.evaluation.keyword' } } },
pit: { id: pitId },
},
ignore_unavailable: false,
});
export const useFindingsCounter = ({ query }: FindingsBaseEsQuery) => {
const {
data,
notifications: { toasts },
} = useKibana().services;
const { pitIdRef, setPitId } = useContext(FindingsEsPitContext);
const pitId = pitIdRef.current;
return useQuery<FindingsAggResponse, unknown, UseFindingsCounterData & { newPitId: string }>(
['csp_findings_counts', { query, pitId }],
() =>
lastValueFrom(
data.search.search<FindingsAggRequest, FindingsAggResponse>({
params: getFindingsCountAggQuery({ query, pitId }),
})
),
{
keepPreviousData: true,
onError: (err) => showErrorToast(toasts, err),
select: (response) => ({
...(Object.fromEntries(
response.rawResponse.aggregations!.count.buckets.map((bucket) => [
bucket.key,
bucket.doc_count,
])!
) as { passed: number; failed: number }),
newPitId: response.rawResponse.pit_id!,
}),
onSuccess: ({ newPitId }) => {
setPitId(newPitId);
},
}
);
};

View file

@ -7,19 +7,23 @@
import { buildEsQuery } from '@kbn/es-query';
import { EuiBasicTableProps, Pagination } from '@elastic/eui';
import { FindingsBaseProps } from './types';
import type { FindingsBaseEsQuery, FindingsBaseURLQuery } from './types';
import { useCallback, useEffect, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import type { FindingsBaseProps, FindingsBaseURLQuery } from './types';
import { useKibana } from '../../common/hooks/use_kibana';
export const getBaseQuery = ({
dataView,
query,
filters,
}: FindingsBaseURLQuery & FindingsBaseProps): FindingsBaseEsQuery => ({
// TODO: this will throw for malformed query
// page will display an error boundary with the JS error
// will be accounted for before releasing the feature
query: buildEsQuery(dataView, query, filters),
});
const getBaseQuery = ({ dataView, query, filters }: FindingsBaseURLQuery & FindingsBaseProps) => {
try {
return {
query: buildEsQuery(dataView, query, filters), // will throw for malformed query
};
} catch (error) {
return {
query: undefined,
error: error instanceof Error ? error : new Error('Unknown Error'),
};
}
};
type TablePagination = NonNullable<EuiBasicTableProps<unknown>['pagination']>;
@ -33,6 +37,23 @@ export const getPaginationTableParams = (
showPerPageOptions,
});
export const usePersistedQuery = <T>(getter: ({ filters, query }: FindingsBaseURLQuery) => T) => {
const {
data: {
query: { filterManager, queryString },
},
} = useKibana().services;
return useCallback(
() =>
getter({
filters: filterManager.getAppFilters(),
query: queryString.getQuery(),
}),
[getter, filterManager, queryString]
);
};
export const getPaginationQuery = ({
pageIndex,
pageSize,
@ -40,3 +61,45 @@ export const getPaginationQuery = ({
from: pageIndex * pageSize,
size: pageSize,
});
export const useBaseEsQuery = ({
dataView,
filters,
query,
}: FindingsBaseURLQuery & FindingsBaseProps) => {
const {
notifications: { toasts },
data: {
query: { filterManager, queryString },
},
} = useKibana().services;
const baseEsQuery = useMemo(
() => getBaseQuery({ dataView, filters, query }),
[dataView, filters, query]
);
/**
* Sync filters with the URL query
*/
useEffect(() => {
filterManager.setAppFilters(filters);
queryString.setQuery(query);
}, [filters, filterManager, queryString, query]);
const handleMalformedQueryError = () => {
const error = baseEsQuery.error;
if (error) {
toasts.addError(error, {
title: i18n.translate('xpack.csp.findings.search.queryErrorToastMessage', {
defaultMessage: 'Query Error',
}),
toastLifeTimeMs: 1000 * 5,
});
}
};
useEffect(handleMalformedQueryError, [baseEsQuery.error, toasts]);
return baseEsQuery;
};