Skip to content

Commit 1bfeab7

Browse files
authored
[Cloud Posture] add findings distribution bar (#129639)
1 parent ac5aca4 commit 1bfeab7

8 files changed

+449
-107
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
import React from 'react';
8+
import { render } from '@testing-library/react';
9+
import { FindingsContainer, getDefaultQuery } from './findings_container';
10+
import { createStubDataView } from '@kbn/data-views-plugin/common/mocks';
11+
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
12+
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
13+
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
14+
import { TestProvider } from '../../test/test_provider';
15+
import { getFindingsQuery } from './use_findings';
16+
import { encodeQuery } from '../../common/navigation/query_utils';
17+
import { useLocation } from 'react-router-dom';
18+
import { RisonObject } from 'rison-node';
19+
import { buildEsQuery } from '@kbn/es-query';
20+
import { getFindingsCountAggQuery } from './use_findings_count';
21+
22+
jest.mock('../../common/api/use_kubebeat_data_view');
23+
jest.mock('../../common/api/use_cis_kubernetes_integration');
24+
25+
jest.mock('react-router-dom', () => ({
26+
...jest.requireActual('react-router-dom'),
27+
useHistory: () => ({ push: jest.fn() }),
28+
useLocation: jest.fn(),
29+
}));
30+
31+
beforeEach(() => {
32+
jest.restoreAllMocks();
33+
});
34+
35+
describe('<FindingsContainer />', () => {
36+
it('data#search.search fn called with URL query', () => {
37+
const query = getDefaultQuery();
38+
const dataMock = dataPluginMock.createStartContract();
39+
const dataView = createStubDataView({
40+
spec: {
41+
id: CSP_KUBEBEAT_INDEX_PATTERN,
42+
},
43+
});
44+
45+
(useLocation as jest.Mock).mockReturnValue({
46+
search: encodeQuery(query as unknown as RisonObject),
47+
});
48+
49+
render(
50+
<TestProvider
51+
deps={{
52+
data: dataMock,
53+
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
54+
}}
55+
>
56+
<FindingsContainer dataView={dataView} />
57+
</TestProvider>
58+
);
59+
60+
const baseQuery = {
61+
index: dataView.title,
62+
query: buildEsQuery(dataView, query.query, query.filters),
63+
};
64+
65+
expect(dataMock.search.search).toHaveBeenNthCalledWith(1, {
66+
params: getFindingsCountAggQuery(baseQuery),
67+
});
68+
69+
expect(dataMock.search.search).toHaveBeenNthCalledWith(2, {
70+
params: getFindingsQuery({
71+
...baseQuery,
72+
sort: query.sort,
73+
size: query.size,
74+
from: query.from,
75+
}),
76+
});
77+
});
78+
});

x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.tsx

+90-9
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,32 @@
44
* 2.0; you may not use this file except in compliance with the Elastic License
55
* 2.0.
66
*/
7-
import React, { useMemo } from 'react';
7+
import React, { useEffect, useMemo } from 'react';
88
import { EuiComboBoxOptionOption, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui';
99
import { css } from '@emotion/react';
1010
import { FormattedMessage } from '@kbn/i18n-react';
1111
import { i18n } from '@kbn/i18n';
1212
import type { DataView } from '@kbn/data-plugin/common';
1313
import { SortDirection } from '@kbn/data-plugin/common';
14+
import { buildEsQuery } from '@kbn/es-query';
1415
import { FindingsTable } from './findings_table';
1516
import { FindingsSearchBar } from './findings_search_bar';
1617
import * as TEST_SUBJECTS from './test_subjects';
1718
import { useUrlQuery } from '../../common/hooks/use_url_query';
1819
import { useFindings, type CspFindingsRequest } from './use_findings';
1920
import { FindingsGroupBySelector } from './findings_group_by_selector';
2021
import { INTERNAL_FEATURE_FLAGS } from '../../../common/constants';
22+
import { useFindingsCounter } from './use_findings_count';
23+
import { FindingsDistributionBar } from './findings_distribution_bar';
24+
import type { CspClientPluginStartDeps } from '../../types';
25+
import { useKibana } from '../../common/hooks/use_kibana';
26+
import * as TEXT from './translations';
2127

2228
export type GroupBy = 'none' | 'resourceType';
29+
export type FindingsBaseQuery = ReturnType<typeof getFindingsBaseEsQuery>;
2330

2431
// TODO: define this as a schema with default values
25-
const getDefaultQuery = (): CspFindingsRequest & { groupBy: GroupBy } => ({
32+
export const getDefaultQuery = (): CspFindingsRequest & { groupBy: GroupBy } => ({
2633
query: { language: 'kuery', query: '' },
2734
filters: [],
2835
sort: [{ ['@timestamp']: SortDirection.desc }],
@@ -46,23 +53,81 @@ const getGroupByOptions = (): Array<EuiComboBoxOptionOption<GroupBy>> => [
4653
},
4754
];
4855

56+
const getFindingsBaseEsQuery = ({
57+
query,
58+
dataView,
59+
filters,
60+
queryService,
61+
}: Pick<CspFindingsRequest, 'filters' | 'query'> & {
62+
dataView: DataView;
63+
queryService: CspClientPluginStartDeps['data']['query'];
64+
}) => {
65+
if (query) queryService.queryString.setQuery(query);
66+
queryService.filterManager.setFilters(filters);
67+
68+
try {
69+
return {
70+
index: dataView.title,
71+
query: buildEsQuery(
72+
dataView,
73+
queryService.queryString.getQuery(),
74+
queryService.filterManager.getFilters()
75+
),
76+
};
77+
} catch (error) {
78+
return {
79+
error:
80+
error instanceof Error
81+
? error
82+
: new Error(
83+
i18n.translate('xpack.csp.findings.unknownError', {
84+
defaultMessage: 'Unknown Error',
85+
})
86+
),
87+
};
88+
}
89+
};
90+
4991
export const FindingsContainer = ({ dataView }: { dataView: DataView }) => {
92+
const { euiTheme } = useEuiTheme();
93+
const groupByOptions = useMemo(getGroupByOptions, []);
94+
const {
95+
data,
96+
notifications: { toasts },
97+
} = useKibana().services;
98+
5099
const {
51100
urlQuery: { groupBy, ...findingsQuery },
52101
setUrlQuery,
53-
key,
54102
} = useUrlQuery(getDefaultQuery);
55-
const findingsResult = useFindings(dataView, findingsQuery, key);
56-
const { euiTheme } = useEuiTheme();
57-
const groupByOptions = useMemo(getGroupByOptions, []);
103+
104+
const baseQuery = useMemo(
105+
() => getFindingsBaseEsQuery({ ...findingsQuery, dataView, queryService: data.query }),
106+
[data.query, dataView, findingsQuery]
107+
);
108+
109+
const countResult = useFindingsCounter(baseQuery);
110+
const findingsResult = useFindings({
111+
...baseQuery,
112+
size: findingsQuery.size,
113+
from: findingsQuery.from,
114+
sort: findingsQuery.sort,
115+
});
116+
117+
useEffect(() => {
118+
if (baseQuery.error) {
119+
toasts.addError(baseQuery.error, { title: TEXT.SEARCH_FAILED });
120+
}
121+
}, [baseQuery.error, toasts]);
58122

59123
return (
60124
<div data-test-subj={TEST_SUBJECTS.FINDINGS_CONTAINER}>
61125
<FindingsSearchBar
62126
dataView={dataView}
63127
setQuery={setUrlQuery}
64-
{...findingsQuery}
65-
{...findingsResult}
128+
query={findingsQuery.query}
129+
filters={findingsQuery.filters}
130+
loading={findingsResult.isLoading}
66131
/>
67132
<div
68133
css={css`
@@ -80,7 +145,23 @@ export const FindingsContainer = ({ dataView }: { dataView: DataView }) => {
80145
)}
81146
<EuiSpacer />
82147
{groupBy === 'none' && (
83-
<FindingsTable setQuery={setUrlQuery} {...findingsQuery} {...findingsResult} />
148+
<>
149+
<FindingsDistributionBar
150+
total={findingsResult.data?.total || 0}
151+
passed={countResult.data?.passed || 0}
152+
failed={countResult.data?.failed || 0}
153+
pageStart={findingsQuery.from + 1} // API index is 0, but UI is 1
154+
pageEnd={findingsQuery.from + findingsQuery.size}
155+
/>
156+
<EuiSpacer />
157+
<FindingsTable
158+
{...findingsQuery}
159+
setQuery={setUrlQuery}
160+
data={findingsResult.data}
161+
error={findingsResult.error}
162+
loading={findingsResult.isLoading}
163+
/>
164+
</>
84165
)}
85166
{groupBy === 'resourceType' && <div />}
86167
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
import React, { useMemo } from 'react';
8+
import { css } from '@emotion/react';
9+
import {
10+
EuiHealth,
11+
EuiBadge,
12+
EuiTextColor,
13+
EuiSpacer,
14+
EuiFlexGroup,
15+
EuiFlexItem,
16+
useEuiTheme,
17+
} from '@elastic/eui';
18+
import { FormattedMessage } from '@kbn/i18n-react';
19+
import { i18n } from '@kbn/i18n';
20+
import numeral from '@elastic/numeral';
21+
22+
interface Props {
23+
total: number;
24+
passed: number;
25+
failed: number;
26+
pageStart: number;
27+
pageEnd: number;
28+
}
29+
30+
const formatNumber = (value: number) => (value < 1000 ? value : numeral(value).format('0.0a'));
31+
32+
export const FindingsDistributionBar = ({ failed, passed, total, pageEnd, pageStart }: Props) => {
33+
const count = useMemo(
34+
() =>
35+
total
36+
? { total, passed: passed / total, failed: failed / total }
37+
: { total: 0, passed: 0, failed: 0 },
38+
[total, failed, passed]
39+
);
40+
41+
return (
42+
<div>
43+
<Counters {...{ failed, passed, total, pageEnd, pageStart }} />
44+
<EuiSpacer size="s" />
45+
<DistributionBar {...count} />
46+
</div>
47+
);
48+
};
49+
50+
const Counters = ({ pageStart, pageEnd, total, failed, passed }: Props) => (
51+
<EuiFlexGroup justifyContent="spaceBetween">
52+
<EuiFlexItem>
53+
{!!total && <CurrentPageOfTotal pageStart={pageStart} pageEnd={pageEnd} total={total} />}
54+
</EuiFlexItem>
55+
<EuiFlexItem
56+
css={css`
57+
align-items: flex-end;
58+
`}
59+
>
60+
{!!total && <PassedFailedCounters passed={passed} failed={failed} />}
61+
</EuiFlexItem>
62+
</EuiFlexGroup>
63+
);
64+
65+
const PassedFailedCounters = ({ passed, failed }: Pick<Props, 'passed' | 'failed'>) => {
66+
const { euiTheme } = useEuiTheme();
67+
return (
68+
<div
69+
css={css`
70+
display: grid;
71+
grid-template-columns: auto auto;
72+
grid-column-gap: ${euiTheme.size.m};
73+
`}
74+
>
75+
<Counter
76+
label={i18n.translate('xpack.csp.findings.distributionBar.totalPassedLabel', {
77+
defaultMessage: 'Passed',
78+
})}
79+
color={euiTheme.colors.success}
80+
value={passed}
81+
/>
82+
<Counter
83+
label={i18n.translate('xpack.csp.findings.distributionBar.totalFailedLabel', {
84+
defaultMessage: 'Failed',
85+
})}
86+
color={euiTheme.colors.danger}
87+
value={failed}
88+
/>
89+
</div>
90+
);
91+
};
92+
93+
const CurrentPageOfTotal = ({
94+
pageEnd,
95+
pageStart,
96+
total,
97+
}: Pick<Props, 'pageEnd' | 'pageStart' | 'total'>) => (
98+
<EuiTextColor color="subdued">
99+
<FormattedMessage
100+
id="xpack.csp.findings.distributionBar.showingPageOfTotalLabel"
101+
defaultMessage="Showing {pageStart}-{pageEnd} of {total} Findings"
102+
values={{
103+
pageStart: <b>{pageStart}</b>,
104+
pageEnd: <b>{pageEnd}</b>,
105+
total: <b>{formatNumber(total)}</b>,
106+
}}
107+
/>
108+
</EuiTextColor>
109+
);
110+
111+
const DistributionBar: React.FC<Omit<Props, 'pageEnd' | 'pageStart'>> = ({ passed, failed }) => {
112+
const { euiTheme } = useEuiTheme();
113+
return (
114+
<EuiFlexGroup
115+
gutterSize="none"
116+
css={css`
117+
height: 8px;
118+
background: ${euiTheme.colors.subdued};
119+
`}
120+
>
121+
<DistributionBarPart value={passed} color={euiTheme.colors.success} />
122+
<DistributionBarPart value={failed} color={euiTheme.colors.danger} />
123+
</EuiFlexGroup>
124+
);
125+
};
126+
127+
const DistributionBarPart = ({ value, color }: { value: number; color: string }) => (
128+
<div
129+
css={css`
130+
flex: ${value};
131+
background: ${color};
132+
height: 100%;
133+
`}
134+
/>
135+
);
136+
137+
const Counter = ({ label, value, color }: { label: string; value: number; color: string }) => (
138+
<EuiFlexGroup gutterSize="s" alignItems="center">
139+
<EuiFlexItem>
140+
<EuiHealth color={color}>{label}</EuiHealth>
141+
</EuiFlexItem>
142+
<EuiFlexItem>
143+
<EuiBadge>{formatNumber(value)}</EuiBadge>
144+
</EuiFlexItem>
145+
</EuiFlexGroup>
146+
);

0 commit comments

Comments
 (0)