Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
}),
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,32 @@
* 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';
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 }],
Expand All @@ -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`
Expand All @@ -80,7 +145,23 @@ export const FindingsContainer = ({ dataView }: { dataView: DataView }) => {
)}
<EuiSpacer />
{groupBy === 'none' && (
<FindingsTable setQuery={setUrlQuery} {...findingsQuery} {...findingsResult} />
<>
<FindingsDistributionBar
Comment on lines 148 to 149
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will change later on once we add data to the new table which will use this distribution bar too. for now it needs to be hidden as it reflects only results of group by none

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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
Loading