[Security Solution] expandable flyout - add paywall to prevalence details (#165382)

This commit is contained in:
Philippe Oberti 2023-09-01 19:46:26 +02:00 committed by GitHub
parent 88c04e5c94
commit 8267c56322
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 196 additions and 21 deletions

View file

@ -11,11 +11,18 @@ import { LeftPanelContext } from '../context';
import { PrevalenceDetails } from './prevalence_details'; import { PrevalenceDetails } from './prevalence_details';
import { import {
PREVALENCE_DETAILS_LOADING_TEST_ID, PREVALENCE_DETAILS_LOADING_TEST_ID,
PREVALENCE_DETAILS_TABLE_ALERT_COUNT_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_DOC_COUNT_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_ERROR_TEST_ID, PREVALENCE_DETAILS_TABLE_ERROR_TEST_ID,
PREVALENCE_DETAILS_TABLE_FIELD_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_TEST_ID, PREVALENCE_DETAILS_TABLE_TEST_ID,
PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_VALUE_CELL_TEST_ID,
} from './test_ids'; } from './test_ids';
import { usePrevalence } from '../../shared/hooks/use_prevalence'; import { usePrevalence } from '../../shared/hooks/use_prevalence';
import { TestProviders } from '../../../common/mock'; import { TestProviders } from '../../../common/mock';
import { licenseService } from '../../../common/hooks/use_license';
jest.mock('../../shared/hooks/use_prevalence'); jest.mock('../../shared/hooks/use_prevalence');
@ -27,6 +34,17 @@ jest.mock('react-redux', () => {
useDispatch: () => mockDispatch, useDispatch: () => mockDispatch,
}; };
}); });
jest.mock('../../../common/hooks/use_license', () => {
const licenseServiceInstance = {
isPlatinumPlus: jest.fn(),
};
return {
licenseService: licenseServiceInstance,
useLicense: () => {
return licenseServiceInstance;
},
};
});
const panelContextValue = { const panelContextValue = {
eventId: 'event id', eventId: 'event id',
@ -36,7 +54,13 @@ const panelContextValue = {
} as unknown as LeftPanelContext; } as unknown as LeftPanelContext;
describe('PrevalenceDetails', () => { describe('PrevalenceDetails', () => {
it('should render the table', () => { const licenseServiceMock = licenseService as jest.Mocked<typeof licenseService>;
beforeEach(() => {
licenseServiceMock.isPlatinumPlus.mockReturnValue(true);
});
it('should render the table with all columns if license is platinum', () => {
const field1 = 'field1'; const field1 = 'field1';
const field2 = 'field2'; const field2 = 'field2';
(usePrevalence as jest.Mock).mockReturnValue({ (usePrevalence as jest.Mock).mockReturnValue({
@ -62,7 +86,7 @@ describe('PrevalenceDetails', () => {
], ],
}); });
const { getByTestId } = render( const { getByTestId, getAllByTestId, queryByTestId } = render(
<TestProviders> <TestProviders>
<LeftPanelContext.Provider value={panelContextValue}> <LeftPanelContext.Provider value={panelContextValue}>
<PrevalenceDetails /> <PrevalenceDetails />
@ -71,6 +95,74 @@ describe('PrevalenceDetails', () => {
); );
expect(getByTestId(PREVALENCE_DETAILS_TABLE_TEST_ID)).toBeInTheDocument(); expect(getByTestId(PREVALENCE_DETAILS_TABLE_TEST_ID)).toBeInTheDocument();
expect(getAllByTestId(PREVALENCE_DETAILS_TABLE_FIELD_CELL_TEST_ID).length).toBeGreaterThan(1);
expect(getAllByTestId(PREVALENCE_DETAILS_TABLE_VALUE_CELL_TEST_ID).length).toBeGreaterThan(1);
expect(
getAllByTestId(PREVALENCE_DETAILS_TABLE_ALERT_COUNT_CELL_TEST_ID).length
).toBeGreaterThan(1);
expect(getAllByTestId(PREVALENCE_DETAILS_TABLE_DOC_COUNT_CELL_TEST_ID).length).toBeGreaterThan(
1
);
expect(
getAllByTestId(PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID).length
).toBeGreaterThan(1);
expect(
getAllByTestId(PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID).length
).toBeGreaterThan(1);
expect(queryByTestId(`${PREVALENCE_DETAILS_TABLE_TEST_ID}UpSell`)).not.toBeInTheDocument();
});
it('should render the table with only basic columns if license is not platinum', () => {
const field1 = 'field1';
const field2 = 'field2';
(usePrevalence as jest.Mock).mockReturnValue({
loading: false,
error: false,
data: [
{
field: field1,
value: 'value1',
alertCount: 1,
docCount: 1,
hostPrevalence: 0.05,
userPrevalence: 0.1,
},
{
field: field2,
value: 'value2',
alertCount: 1,
docCount: 1,
hostPrevalence: 0.5,
userPrevalence: 0.05,
},
],
});
licenseServiceMock.isPlatinumPlus.mockReturnValue(false);
const { getByTestId, getAllByTestId } = render(
<TestProviders>
<LeftPanelContext.Provider value={panelContextValue}>
<PrevalenceDetails />
</LeftPanelContext.Provider>
</TestProviders>
);
expect(getByTestId(PREVALENCE_DETAILS_TABLE_TEST_ID)).toBeInTheDocument();
expect(getAllByTestId(PREVALENCE_DETAILS_TABLE_FIELD_CELL_TEST_ID).length).toBeGreaterThan(1);
expect(getAllByTestId(PREVALENCE_DETAILS_TABLE_VALUE_CELL_TEST_ID).length).toBeGreaterThan(1);
expect(
getAllByTestId(PREVALENCE_DETAILS_TABLE_ALERT_COUNT_CELL_TEST_ID).length
).toBeGreaterThan(1);
expect(getAllByTestId(PREVALENCE_DETAILS_TABLE_DOC_COUNT_CELL_TEST_ID).length).toBeGreaterThan(
1
);
expect(
getAllByTestId(PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID).length
).toBeGreaterThan(1);
expect(
getAllByTestId(PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID).length
).toBeGreaterThan(1);
expect(getByTestId(`${PREVALENCE_DETAILS_TABLE_TEST_ID}UpSell`)).toBeInTheDocument();
}); });
it('should render loading', () => { it('should render loading', () => {

View file

@ -5,19 +5,25 @@
* 2.0. * 2.0.
*/ */
import React, { useState } from 'react'; import dateMath from '@elastic/datemath';
import React, { useMemo, useState } from 'react';
import type { EuiBasicTableColumn, OnTimeChangeProps } from '@elastic/eui'; import type { EuiBasicTableColumn, OnTimeChangeProps } from '@elastic/eui';
import { import {
EuiCallOut,
EuiEmptyPrompt, EuiEmptyPrompt,
EuiFlexGroup, EuiFlexGroup,
EuiFlexItem, EuiFlexItem,
EuiInMemoryTable, EuiInMemoryTable,
EuiLink,
EuiLoadingSpinner, EuiLoadingSpinner,
EuiPanel, EuiPanel,
EuiSpacer, EuiSpacer,
EuiSuperDatePicker, EuiSuperDatePicker,
EuiText,
EuiToolTip, EuiToolTip,
} from '@elastic/eui'; } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useLicense } from '../../../common/hooks/use_license';
import { InvestigateInTimelineButton } from '../../../common/components/event_details/table/investigate_in_timeline_button'; import { InvestigateInTimelineButton } from '../../../common/components/event_details/table/investigate_in_timeline_button';
import type { PrevalenceData } from '../../shared/hooks/use_prevalence'; import type { PrevalenceData } from '../../shared/hooks/use_prevalence';
import { usePrevalence } from '../../shared/hooks/use_prevalence'; import { usePrevalence } from '../../shared/hooks/use_prevalence';
@ -63,16 +69,31 @@ export const PREVALENCE_TAB_ID = 'prevalence-details';
const DEFAULT_FROM = 'now-30d'; const DEFAULT_FROM = 'now-30d';
const DEFAULT_TO = 'now'; const DEFAULT_TO = 'now';
const columns: Array<EuiBasicTableColumn<PrevalenceData>> = [ interface PrevalenceDetailsRow extends PrevalenceData {
/**
* From datetime selected in the date picker to pass to timeline
*/
from: string;
/**
* To datetime selected in the date picker to pass to timeline
*/
to: string;
}
const columns: Array<EuiBasicTableColumn<PrevalenceDetailsRow>> = [
{ {
field: 'field', field: 'field',
name: PREVALENCE_TABLE_FIELD_COLUMN_TITLE, name: PREVALENCE_TABLE_FIELD_COLUMN_TITLE,
'data-test-subj': PREVALENCE_DETAILS_TABLE_FIELD_CELL_TEST_ID, 'data-test-subj': PREVALENCE_DETAILS_TABLE_FIELD_CELL_TEST_ID,
render: (field: string) => <EuiText size="xs">{field}</EuiText>,
width: '20%',
}, },
{ {
field: 'value', field: 'value',
name: PREVALENCE_TABLE_VALUE_COLUMN_TITLE, name: PREVALENCE_TABLE_VALUE_COLUMN_TITLE,
'data-test-subj': PREVALENCE_DETAILS_TABLE_VALUE_CELL_TEST_ID, 'data-test-subj': PREVALENCE_DETAILS_TABLE_VALUE_CELL_TEST_ID,
render: (value: string) => <EuiText size="xs">{value}</EuiText>,
width: '20%',
}, },
{ {
name: ( name: (
@ -84,7 +105,7 @@ const columns: Array<EuiBasicTableColumn<PrevalenceData>> = [
</EuiToolTip> </EuiToolTip>
), ),
'data-test-subj': PREVALENCE_DETAILS_TABLE_ALERT_COUNT_CELL_TEST_ID, 'data-test-subj': PREVALENCE_DETAILS_TABLE_ALERT_COUNT_CELL_TEST_ID,
render: (data: PrevalenceData) => { render: (data: PrevalenceDetailsRow) => {
const dataProviders = [ const dataProviders = [
getDataProvider(data.field, `timeline-indicator-${data.field}-${data.value}`, data.value), getDataProvider(data.field, `timeline-indicator-${data.field}-${data.value}`, data.value),
]; ];
@ -93,6 +114,7 @@ const columns: Array<EuiBasicTableColumn<PrevalenceData>> = [
asEmptyButton={true} asEmptyButton={true}
dataProviders={dataProviders} dataProviders={dataProviders}
filters={[]} filters={[]}
timeRange={{ kind: 'absolute', from: data.from, to: data.to }}
> >
<>{data.alertCount}</> <>{data.alertCount}</>
</InvestigateInTimelineButton> </InvestigateInTimelineButton>
@ -112,7 +134,7 @@ const columns: Array<EuiBasicTableColumn<PrevalenceData>> = [
</EuiToolTip> </EuiToolTip>
), ),
'data-test-subj': PREVALENCE_DETAILS_TABLE_DOC_COUNT_CELL_TEST_ID, 'data-test-subj': PREVALENCE_DETAILS_TABLE_DOC_COUNT_CELL_TEST_ID,
render: (data: PrevalenceData) => { render: (data: PrevalenceDetailsRow) => {
const dataProviders = [ const dataProviders = [
{ {
...getDataProvider( ...getDataProvider(
@ -136,6 +158,7 @@ const columns: Array<EuiBasicTableColumn<PrevalenceData>> = [
asEmptyButton={true} asEmptyButton={true}
dataProviders={dataProviders} dataProviders={dataProviders}
filters={[]} filters={[]}
timeRange={{ kind: 'absolute', from: data.from, to: data.to }}
keepDataView // changing dataview from only detections to include non-alerts docs keepDataView // changing dataview from only detections to include non-alerts docs
> >
<>{data.docCount}</> <>{data.docCount}</>
@ -158,10 +181,7 @@ const columns: Array<EuiBasicTableColumn<PrevalenceData>> = [
), ),
'data-test-subj': PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID, 'data-test-subj': PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID,
render: (hostPrevalence: number) => ( render: (hostPrevalence: number) => (
<> <EuiText size="xs">{`${Math.round(hostPrevalence * 100)}%`}</EuiText>
{Math.round(hostPrevalence * 100)}
{'%'}
</>
), ),
width: '10%', width: '10%',
}, },
@ -177,10 +197,7 @@ const columns: Array<EuiBasicTableColumn<PrevalenceData>> = [
), ),
'data-test-subj': PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID, 'data-test-subj': PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID,
render: (userPrevalence: number) => ( render: (userPrevalence: number) => (
<> <EuiText size="xs">{`${Math.round(userPrevalence * 100)}%`}</EuiText>
{Math.round(userPrevalence * 100)}
{'%'}
</>
), ),
width: '10%', width: '10%',
}, },
@ -193,12 +210,38 @@ export const PrevalenceDetails: React.FC = () => {
const { browserFields, dataFormattedForFieldBrowser, eventId, investigationFields } = const { browserFields, dataFormattedForFieldBrowser, eventId, investigationFields } =
useLeftPanelContext(); useLeftPanelContext();
const isPlatinumPlus = useLicense().isPlatinumPlus();
// these two are used by the usePrevalence hook to fetch the data
const [start, setStart] = useState(DEFAULT_FROM); const [start, setStart] = useState(DEFAULT_FROM);
const [end, setEnd] = useState(DEFAULT_TO); const [end, setEnd] = useState(DEFAULT_TO);
const onTimeChange = ({ start: s, end: e }: OnTimeChangeProps) => { // these two are used to pass to timeline
const [absoluteStart, setAbsoluteStart] = useState(
(dateMath.parse(DEFAULT_FROM) || new Date()).toISOString()
);
const [absoluteEnd, setAbsoluteEnd] = useState(
(dateMath.parse(DEFAULT_TO) || new Date()).toISOString()
);
// TODO update the logic to use a single set of start/end dates
// currently as we're using this InvestigateInTimelineButton component we need to pass the timeRange
// as an AbsoluteTimeRange, which requires from/to values
const onTimeChange = ({ start: s, end: e, isInvalid }: OnTimeChangeProps) => {
if (isInvalid) return;
setStart(s); setStart(s);
setEnd(e); setEnd(e);
const from = dateMath.parse(s);
if (from && from.isValid()) {
setAbsoluteStart(from.toISOString());
}
const to = dateMath.parse(e);
if (to && to.isValid()) {
setAbsoluteEnd(to.toISOString());
}
}; };
const { loading, error, data } = usePrevalence({ const { loading, error, data } = usePrevalence({
@ -210,6 +253,12 @@ export const PrevalenceDetails: React.FC = () => {
}, },
}); });
// add timeRange to pass it down to timeline
const items = useMemo(
() => data.map((item) => ({ ...item, from: absoluteStart, to: absoluteEnd })),
[data, absoluteStart, absoluteEnd]
);
if (loading) { if (loading) {
return ( return (
<EuiFlexGroup <EuiFlexGroup
@ -235,8 +284,31 @@ export const PrevalenceDetails: React.FC = () => {
); );
} }
const upsell = (
<>
<EuiCallOut data-test-subj={`${PREVALENCE_DETAILS_TABLE_TEST_ID}UpSell`}>
<FormattedMessage
id="xpack.securitySolution.flyout.documentDetails.prevalenceTableAlertUpsell"
defaultMessage="Preview of a {subscription} feature showing host and user prevalence."
values={{
subscription: (
<EuiLink href="https://www.elastic.co/pricing/" target="_blank">
<FormattedMessage
id="xpack.securitySolution.flyout.documentDetails.prevalenceTableAlertUpsellLink"
defaultMessage="Platinum"
/>
</EuiLink>
),
}}
/>
</EuiCallOut>
<EuiSpacer size="s" />
</>
);
return ( return (
<> <>
{!isPlatinumPlus && upsell}
<EuiPanel> <EuiPanel>
<EuiSuperDatePicker <EuiSuperDatePicker
start={start} start={start}
@ -247,7 +319,7 @@ export const PrevalenceDetails: React.FC = () => {
<EuiSpacer size="m" /> <EuiSpacer size="m" />
{data.length > 0 ? ( {data.length > 0 ? (
<EuiInMemoryTable <EuiInMemoryTable
items={data} items={items}
columns={columns} columns={columns}
data-test-subj={PREVALENCE_DETAILS_TABLE_TEST_ID} data-test-subj={PREVALENCE_DETAILS_TABLE_TEST_ID}
/> />

View file

@ -69,7 +69,7 @@ export const ResponseDetails: React.FC = () => {
values={{ values={{
editRuleLink: ( editRuleLink: (
<EuiLink <EuiLink
href="https://www.elastic.co/guide/en/security/master/rules-ui-management.html#edit-rules-settings" href="https://www.elastic.co/guide/en/security/current/rules-ui-management.html#edit-rules-settings"
target="_blank" target="_blank"
> >
<FormattedMessage <FormattedMessage

View file

@ -54,11 +54,13 @@ const columns: Array<EuiBasicTableColumn<HighlightedFieldsTableRow>> = [
field: 'field', field: 'field',
name: HIGHLIGHTED_FIELDS_FIELD_COLUMN, name: HIGHLIGHTED_FIELDS_FIELD_COLUMN,
'data-test-subj': 'fieldCell', 'data-test-subj': 'fieldCell',
width: '50%',
}, },
{ {
field: 'description', field: 'description',
name: HIGHLIGHTED_FIELDS_VALUE_COLUMN, name: HIGHLIGHTED_FIELDS_VALUE_COLUMN,
'data-test-subj': 'valueCell', 'data-test-subj': 'valueCell',
width: '50%',
render: (description: { render: (description: {
field: string; field: string;
values: string[] | null | undefined; values: string[] | null | undefined;

View file

@ -11,6 +11,9 @@ import { useQuery } from '@tanstack/react-query';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { createFetchData } from '../utils/fetch_data'; import { createFetchData } from '../utils/fetch_data';
import { useKibana } from '../../../common/lib/kibana'; import { useKibana } from '../../../common/lib/kibana';
import { useTimelineDataFilters } from '../../../timelines/containers/use_timeline_data_filters';
import { isActiveTimeline } from '../../../helpers';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
const QUERY_KEY = 'useFetchFieldValuePairWithAggregation'; const QUERY_KEY = 'useFetchFieldValuePairWithAggregation';
@ -99,7 +102,10 @@ export const useFetchPrevalence = ({
}, },
} = useKibana(); } = useKibana();
const searchRequest = buildSearchRequest(highlightedFieldsFilters, from, to); // retrieves detections and non-detections indices (for example, the alert security index from the current space and 'logs-*' indices)
const { selectedPatterns } = useTimelineDataFilters(isActiveTimeline(SourcererScopeName.default));
const searchRequest = buildSearchRequest(highlightedFieldsFilters, from, to, selectedPatterns);
const { data, isLoading, isError } = useQuery( const { data, isLoading, isError } = useQuery(
[QUERY_KEY, highlightedFieldsFilters, from, to], [QUERY_KEY, highlightedFieldsFilters, from, to],
@ -120,7 +126,8 @@ export const useFetchPrevalence = ({
const buildSearchRequest = ( const buildSearchRequest = (
highlightedFieldsFilters: Record<string, QueryDslQueryContainer>, highlightedFieldsFilters: Record<string, QueryDslQueryContainer>,
from: string, from: string,
to: string to: string,
selectedPatterns: string[]
): IEsSearchRequest => { ): IEsSearchRequest => {
const query = buildEsQuery( const query = buildEsQuery(
undefined, undefined,
@ -146,14 +153,16 @@ const buildSearchRequest = (
] ]
); );
return buildAggregationSearchRequest(query, highlightedFieldsFilters); return buildAggregationSearchRequest(query, highlightedFieldsFilters, selectedPatterns);
}; };
const buildAggregationSearchRequest = ( const buildAggregationSearchRequest = (
query: QueryDslQueryContainer, query: QueryDslQueryContainer,
highlightedFieldsFilters: Record<string, QueryDslQueryContainer> highlightedFieldsFilters: Record<string, QueryDslQueryContainer>,
selectedPatterns: string[]
): IEsSearchRequest => ({ ): IEsSearchRequest => ({
params: { params: {
index: selectedPatterns,
body: { body: {
query, query,
aggs: { aggs: {