[8.10] [Security Solution] expandable flyout - prevalence details UI improvements (#164016) (#164915)

# Backport

This will backport the following commits from `main` to `8.10`:
- [[Security Solution] expandable flyout - prevalence details UI
improvements (#164016)](https://github.com/elastic/kibana/pull/164016)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Philippe
Oberti","email":"philippe.oberti@elastic.co"},"sourceCommit":{"committedDate":"2023-08-26T10:20:58Z","message":"[Security
Solution] expandable flyout - prevalence details UI improvements
(#164016)","sha":"225fc95488f8c5f52b26a8715ce76b7d19f7301f","branchLabelMapping":{"^v8.11.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:enhancement","Team:Threat
Hunting:Investigations","v8.10.0","v8.11.0"],"number":164016,"url":"https://github.com/elastic/kibana/pull/164016","mergeCommit":{"message":"[Security
Solution] expandable flyout - prevalence details UI improvements
(#164016)","sha":"225fc95488f8c5f52b26a8715ce76b7d19f7301f"}},"sourceBranch":"main","suggestedTargetBranches":["8.10"],"targetPullRequestStates":[{"branch":"8.10","label":"v8.10.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.11.0","labelRegex":"^v8.11.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/164016","number":164016,"mergeCommit":{"message":"[Security
Solution] expandable flyout - prevalence details UI improvements
(#164016)","sha":"225fc95488f8c5f52b26a8715ce76b7d19f7301f"}}]}]
BACKPORT-->

Co-authored-by: Philippe Oberti <philippe.oberti@elastic.co>
This commit is contained in:
Kibana Machine 2023-08-26 07:35:49 -04:00 committed by GitHub
parent ac42a09c3c
commit 5e12811ad4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1168 additions and 1679 deletions

View file

@ -7,7 +7,6 @@
*/
import React, { useCallback, useMemo } from 'react';
import { css } from '@emotion/react';
import type { EuiFlyoutProps } from '@elastic/eui';
import { EuiFlexGroup, EuiFlyout } from '@elastic/eui';
import { useExpandableFlyoutContext } from './context';
@ -28,10 +27,6 @@ export interface ExpandableFlyoutProps extends Omit<EuiFlyoutProps, 'onClose'> {
handleOnFlyoutClosed?: () => void;
}
const flyoutStyles = css`
overflow-y: scroll;
`;
const flyoutInnerStyles = { height: '100%' };
/**
@ -88,13 +83,7 @@ export const ExpandableFlyout: React.FC<ExpandableFlyoutProps> = ({
const previewSectionWidth: number = leftSection ? 0.4 : 1;
return (
<EuiFlyout
css={flyoutStyles}
{...flyoutProps}
size={flyoutWidth}
ownFocus={false}
onClose={onClose}
>
<EuiFlyout {...flyoutProps} size={flyoutWidth} ownFocus={false} onClose={onClose}>
<EuiFlexGroup
direction={leftSection ? 'row' : 'column'}
wrap={false}

View file

@ -7,21 +7,16 @@
import { render } from '@testing-library/react';
import React from 'react';
import { getSummaryRows } from '../../../common/components/event_details/get_alert_summary_rows';
import { LeftPanelContext } from '../context';
import { PrevalenceDetails } from './prevalence_details';
import {
PREVALENCE_DETAILS_LOADING_TEST_ID,
PREVALENCE_DETAILS_TABLE_ERROR_TEST_ID,
PREVALENCE_DETAILS_TABLE_TEST_ID,
} from './test_ids';
import { useFetchFieldValuePairByEventType } from '../../shared/hooks/use_fetch_field_value_pair_by_event_type';
import { useFetchFieldValuePairWithAggregation } from '../../shared/hooks/use_fetch_field_value_pair_with_aggregation';
import { useFetchUniqueByField } from '../../shared/hooks/use_fetch_unique_by_field';
import { usePrevalence } from '../../shared/hooks/use_prevalence';
jest.mock('../../../common/components/event_details/get_alert_summary_rows');
jest.mock('../../shared/hooks/use_fetch_field_value_pair_by_event_type');
jest.mock('../../shared/hooks/use_fetch_field_value_pair_with_aggregation');
jest.mock('../../shared/hooks/use_fetch_unique_by_field');
jest.mock('../../shared/hooks/use_prevalence');
const panelContextValue = {
eventId: 'event id',
@ -31,33 +26,31 @@ const panelContextValue = {
} as unknown as LeftPanelContext;
describe('PrevalenceDetails', () => {
jest.mocked(useFetchFieldValuePairByEventType).mockReturnValue({
loading: false,
error: false,
count: 1,
});
jest.mocked(useFetchFieldValuePairWithAggregation).mockReturnValue({
loading: false,
error: false,
count: 1,
});
jest.mocked(useFetchUniqueByField).mockReturnValue({
loading: false,
error: false,
count: 1,
});
it('should render the table', () => {
const mockSummaryRow = {
title: 'test',
description: {
data: {
field: 'field',
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,
},
values: ['value'],
},
};
(getSummaryRows as jest.Mock).mockReturnValue([mockSummaryRow]);
{
field: field2,
value: 'value2',
alertCount: 1,
docCount: 1,
hostPrevalence: 0.5,
userPrevalence: 0.05,
},
],
});
const { getByTestId } = render(
<LeftPanelContext.Provider value={panelContextValue}>
@ -68,8 +61,28 @@ describe('PrevalenceDetails', () => {
expect(getByTestId(PREVALENCE_DETAILS_TABLE_TEST_ID)).toBeInTheDocument();
});
it('should render the error message if no highlighted fields', () => {
jest.mocked(getSummaryRows).mockReturnValue([]);
it('should render loading', () => {
(usePrevalence as jest.Mock).mockReturnValue({
loading: true,
error: false,
data: [],
});
const { getByTestId } = render(
<LeftPanelContext.Provider value={panelContextValue}>
<PrevalenceDetails />
</LeftPanelContext.Provider>
);
expect(getByTestId(PREVALENCE_DETAILS_LOADING_TEST_ID)).toBeInTheDocument();
});
it('should render error if call errors out', () => {
(usePrevalence as jest.Mock).mockReturnValue({
loading: false,
error: true,
data: [],
});
const { getByTestId } = render(
<LeftPanelContext.Provider value={panelContextValue}>
@ -79,4 +92,64 @@ describe('PrevalenceDetails', () => {
expect(getByTestId(PREVALENCE_DETAILS_TABLE_ERROR_TEST_ID)).toBeInTheDocument();
});
it('should render error if event is null', () => {
const contextValue = {
...panelContextValue,
eventId: null,
} as unknown as LeftPanelContext;
(usePrevalence as jest.Mock).mockReturnValue({
loading: false,
error: true,
data: [],
});
const { getByTestId } = render(
<LeftPanelContext.Provider value={contextValue}>
<PrevalenceDetails />
</LeftPanelContext.Provider>
);
expect(getByTestId(PREVALENCE_DETAILS_TABLE_ERROR_TEST_ID)).toBeInTheDocument();
});
it('should render error if dataFormattedForFieldBrowser is null', () => {
const contextValue = {
...panelContextValue,
dataFormattedForFieldBrowser: null,
};
(usePrevalence as jest.Mock).mockReturnValue({
loading: false,
error: true,
data: [],
});
const { getByTestId } = render(
<LeftPanelContext.Provider value={contextValue}>
<PrevalenceDetails />
</LeftPanelContext.Provider>
);
expect(getByTestId(PREVALENCE_DETAILS_TABLE_ERROR_TEST_ID)).toBeInTheDocument();
});
it('should render error if browserFields is null', () => {
const contextValue = {
...panelContextValue,
browserFields: null,
};
(usePrevalence as jest.Mock).mockReturnValue({
loading: false,
error: true,
data: [],
});
const { getByTestId } = render(
<LeftPanelContext.Provider value={contextValue}>
<PrevalenceDetails />
</LeftPanelContext.Provider>
);
expect(getByTestId(PREVALENCE_DETAILS_TABLE_ERROR_TEST_ID)).toBeInTheDocument();
});
});

View file

@ -5,102 +5,118 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { EuiBasicTable, EuiEmptyPrompt } from '@elastic/eui';
import React, { useState } from 'react';
import type { EuiBasicTableColumn, OnTimeChangeProps } from '@elastic/eui';
import {
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiLoadingSpinner,
EuiPanel,
EuiSpacer,
EuiSuperDatePicker,
} from '@elastic/eui';
import type { PrevalenceData } from '../../shared/hooks/use_prevalence';
import { usePrevalence } from '../../shared/hooks/use_prevalence';
import { ERROR_MESSAGE, ERROR_TITLE } from '../../shared/translations';
import {
HOST_TITLE,
PREVALENCE_ERROR_MESSAGE,
PREVALENCE_TABLE_ALERT_COUNT_COLUMN_TITLE,
PREVALENCE_TABLE_COUNT_COLUMN_TITLE,
PREVALENCE_TABLE_DOC_COUNT_COLUMN_TITLE,
PREVALENCE_TABLE_HOST_PREVALENCE_COLUMN_TITLE,
PREVALENCE_TABLE_NAME_COLUMN_TITLE,
PREVALENCE_TABLE_TYPE_COLUMN_TITLE,
PREVALENCE_TABLE_USER_PREVALENCE_COLUMN_TITLE,
PREVALENCE_TABLE_VALUE_COLUMN_TITLE,
PREVALENCE_TABLE_PREVALENCE_COLUMN_TITLE,
PREVALENCE_TABLE_FIELD_COLUMN_TITLE,
USER_TITLE,
PREVALENCE_NO_DATA_MESSAGE,
} from './translations';
import {
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_HOST_PREVALENCE_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_NAME_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_TEST_ID,
PREVALENCE_DETAILS_TABLE_TYPE_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_VALUE_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_FIELD_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_NO_DATA_TEST_ID,
PREVALENCE_DETAILS_DATE_PICKER_TEST_ID,
PREVALENCE_DETAILS_TABLE_TEST_ID,
} from './test_ids';
import { PrevalenceDetailsCountCell } from './prevalence_details_count_cell';
import type { AlertSummaryRow } from '../../../common/components/event_details/helpers';
import { PrevalenceDetailsPrevalenceCell } from './prevalence_details_prevalence_cell';
import { getSummaryRows } from '../../../common/components/event_details/get_alert_summary_rows';
import { useLeftPanelContext } from '../context';
import { EventKind } from '../../shared/hooks/use_fetch_field_value_pair_by_event_type';
interface PrevalenceDetailsTableCell {
highlightedField: { name: string; values: string[] };
}
export const PREVALENCE_TAB_ID = 'prevalence-details';
const DEFAULT_FROM = 'now-30d';
const DEFAULT_TO = 'now';
const columns: Array<EuiBasicTableColumn<unknown>> = [
const columns: Array<EuiBasicTableColumn<PrevalenceData>> = [
{
field: 'type',
name: PREVALENCE_TABLE_TYPE_COLUMN_TITLE,
'data-test-subj': PREVALENCE_DETAILS_TABLE_TYPE_CELL_TEST_ID,
field: 'field',
name: PREVALENCE_TABLE_FIELD_COLUMN_TITLE,
'data-test-subj': PREVALENCE_DETAILS_TABLE_FIELD_CELL_TEST_ID,
},
{
field: 'name',
name: PREVALENCE_TABLE_NAME_COLUMN_TITLE,
'data-test-subj': PREVALENCE_DETAILS_TABLE_NAME_CELL_TEST_ID,
field: 'value',
name: PREVALENCE_TABLE_VALUE_COLUMN_TITLE,
'data-test-subj': PREVALENCE_DETAILS_TABLE_VALUE_CELL_TEST_ID,
},
{
field: 'alertCount',
name: PREVALENCE_TABLE_ALERT_COUNT_COLUMN_TITLE,
'data-test-subj': PREVALENCE_DETAILS_TABLE_ALERT_COUNT_CELL_TEST_ID,
render: (data: PrevalenceDetailsTableCell) => (
<PrevalenceDetailsCountCell
highlightedField={data.highlightedField}
type={{
eventKind: EventKind.signal,
include: true,
}}
/>
name: (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>{PREVALENCE_TABLE_ALERT_COUNT_COLUMN_TITLE}</EuiFlexItem>
<EuiFlexItem>{PREVALENCE_TABLE_COUNT_COLUMN_TITLE}</EuiFlexItem>
</EuiFlexGroup>
),
'data-test-subj': PREVALENCE_DETAILS_TABLE_ALERT_COUNT_CELL_TEST_ID,
width: '10%',
},
{
field: 'docCount',
name: PREVALENCE_TABLE_DOC_COUNT_COLUMN_TITLE,
'data-test-subj': PREVALENCE_DETAILS_TABLE_DOC_COUNT_CELL_TEST_ID,
render: (data: PrevalenceDetailsTableCell) => (
<PrevalenceDetailsCountCell
highlightedField={data.highlightedField}
type={{
eventKind: EventKind.signal,
exclude: true,
}}
/>
name: (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>{PREVALENCE_TABLE_DOC_COUNT_COLUMN_TITLE}</EuiFlexItem>
<EuiFlexItem>{PREVALENCE_TABLE_COUNT_COLUMN_TITLE}</EuiFlexItem>
</EuiFlexGroup>
),
'data-test-subj': PREVALENCE_DETAILS_TABLE_DOC_COUNT_CELL_TEST_ID,
width: '10%',
},
{
field: 'hostPrevalence',
name: PREVALENCE_TABLE_HOST_PREVALENCE_COLUMN_TITLE,
'data-test-subj': PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID,
render: (data: PrevalenceDetailsTableCell) => (
<PrevalenceDetailsPrevalenceCell
highlightedField={data.highlightedField}
aggregationField={'host.name'}
/>
name: (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>{HOST_TITLE}</EuiFlexItem>
<EuiFlexItem>{PREVALENCE_TABLE_PREVALENCE_COLUMN_TITLE}</EuiFlexItem>
</EuiFlexGroup>
),
'data-test-subj': PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID,
render: (hostPrevalence: number) => (
<>
{Math.round(hostPrevalence * 100)}
{'%'}
</>
),
width: '10%',
},
{
field: 'userPrevalence',
name: PREVALENCE_TABLE_USER_PREVALENCE_COLUMN_TITLE,
'data-test-subj': PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID,
render: (data: PrevalenceDetailsTableCell) => (
<PrevalenceDetailsPrevalenceCell
highlightedField={data.highlightedField}
aggregationField={'user.name'}
/>
name: (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>{USER_TITLE}</EuiFlexItem>
<EuiFlexItem>{PREVALENCE_TABLE_PREVALENCE_COLUMN_TITLE}</EuiFlexItem>
</EuiFlexGroup>
),
'data-test-subj': PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID,
render: (userPrevalence: number) => (
<>
{Math.round(userPrevalence * 100)}
{'%'}
</>
),
width: '10%',
},
];
@ -108,40 +124,40 @@ const columns: Array<EuiBasicTableColumn<unknown>> = [
* Prevalence table displayed in the document details expandable flyout left section under the Insights tab
*/
export const PrevalenceDetails: React.FC = () => {
const { browserFields, dataFormattedForFieldBrowser, eventId, scopeId, investigationFields } =
const { browserFields, dataFormattedForFieldBrowser, eventId, investigationFields } =
useLeftPanelContext();
const data = useMemo(() => {
const summaryRows = getSummaryRows({
browserFields: browserFields || {},
data: dataFormattedForFieldBrowser || [],
eventId,
scopeId,
investigationFields,
isReadOnly: false,
});
const [start, setStart] = useState(DEFAULT_FROM);
const [end, setEnd] = useState(DEFAULT_TO);
const getCellRenderFields = (summaryRow: AlertSummaryRow): PrevalenceDetailsTableCell => ({
highlightedField: {
name: summaryRow.description.data.field,
values: summaryRow.description.values || [],
},
});
const onTimeChange = ({ start: s, end: e }: OnTimeChangeProps) => {
setStart(s);
setEnd(e);
};
return (summaryRows || []).map((summaryRow) => {
const fields = getCellRenderFields(summaryRow);
return {
type: summaryRow.description.data.field,
name: summaryRow.description.values,
alertCount: fields,
docCount: fields,
hostPrevalence: fields,
userPrevalence: fields,
};
});
}, [browserFields, investigationFields, dataFormattedForFieldBrowser, eventId, scopeId]);
const { loading, error, data } = usePrevalence({
dataFormattedForFieldBrowser,
investigationFields,
interval: {
from: start,
to: end,
},
});
if (!eventId || !dataFormattedForFieldBrowser || !browserFields || !data || data.length === 0) {
if (loading) {
return (
<EuiFlexGroup
justifyContent="spaceAround"
data-test-subj={PREVALENCE_DETAILS_LOADING_TEST_ID}
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
</EuiFlexGroup>
);
}
if (!eventId || !dataFormattedForFieldBrowser || !browserFields || error) {
return (
<EuiEmptyPrompt
iconType="error"
@ -154,12 +170,29 @@ export const PrevalenceDetails: React.FC = () => {
}
return (
<EuiBasicTable
tableCaption=""
items={data}
columns={columns}
data-test-subj={PREVALENCE_DETAILS_TABLE_TEST_ID}
/>
<>
<EuiPanel>
<EuiSuperDatePicker
start={start}
end={end}
onTimeChange={onTimeChange}
data-test-subj={PREVALENCE_DETAILS_DATE_PICKER_TEST_ID}
/>
<EuiSpacer size="m" />
{data.length > 0 ? (
<EuiInMemoryTable
items={data}
columns={columns}
data-test-subj={PREVALENCE_DETAILS_TABLE_TEST_ID}
tableLayout="auto"
/>
) : (
<div data-test-subj={`${PREVALENCE_DETAILS_TABLE_NO_DATA_TEST_ID}Error`}>
{PREVALENCE_NO_DATA_MESSAGE}
</div>
)}
</EuiPanel>
</>
);
};

View file

@ -1,90 +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 { render } from '@testing-library/react';
import React from 'react';
import {
EventKind,
useFetchFieldValuePairByEventType,
} from '../../shared/hooks/use_fetch_field_value_pair_by_event_type';
import {
PREVALENCE_DETAILS_COUNT_CELL_ERROR_TEST_ID,
PREVALENCE_DETAILS_COUNT_CELL_LOADING_TEST_ID,
PREVALENCE_DETAILS_COUNT_CELL_VALUE_TEST_ID,
} from './test_ids';
import { PrevalenceDetailsCountCell } from './prevalence_details_count_cell';
jest.mock('../../shared/hooks/use_fetch_field_value_pair_by_event_type');
const highlightedField = {
name: 'field',
values: ['values'],
};
const type = {
eventKind: EventKind.signal,
include: true,
};
describe('PrevalenceDetailsAlertCountCell', () => {
it('should show loading spinner', () => {
jest.mocked(useFetchFieldValuePairByEventType).mockReturnValue({
loading: true,
error: false,
count: 0,
});
const { getByTestId } = render(
<PrevalenceDetailsCountCell highlightedField={highlightedField} type={type} />
);
expect(getByTestId(PREVALENCE_DETAILS_COUNT_CELL_LOADING_TEST_ID)).toBeInTheDocument();
});
it('should return error icon', () => {
jest.mocked(useFetchFieldValuePairByEventType).mockReturnValue({
loading: false,
error: true,
count: 0,
});
const { getByTestId } = render(
<PrevalenceDetailsCountCell highlightedField={highlightedField} type={type} />
);
expect(getByTestId(PREVALENCE_DETAILS_COUNT_CELL_ERROR_TEST_ID)).toBeInTheDocument();
});
it('should show count value with eventKind undefined', () => {
jest.mocked(useFetchFieldValuePairByEventType).mockReturnValue({
loading: false,
error: false,
count: 1,
});
const { getByTestId } = render(
<PrevalenceDetailsCountCell highlightedField={highlightedField} type={type} />
);
expect(getByTestId(PREVALENCE_DETAILS_COUNT_CELL_VALUE_TEST_ID)).toBeInTheDocument();
expect(getByTestId(PREVALENCE_DETAILS_COUNT_CELL_VALUE_TEST_ID)).toHaveTextContent('1');
});
it('should show count value with eventKind passed via props', () => {
jest.mocked(useFetchFieldValuePairByEventType).mockReturnValue({
loading: false,
error: false,
count: 1,
});
const { getByTestId } = render(
<PrevalenceDetailsCountCell highlightedField={highlightedField} type={type} />
);
expect(getByTestId(PREVALENCE_DETAILS_COUNT_CELL_VALUE_TEST_ID)).toBeInTheDocument();
expect(getByTestId(PREVALENCE_DETAILS_COUNT_CELL_VALUE_TEST_ID)).toHaveTextContent('1');
});
});

View file

@ -1,63 +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 type { VFC } from 'react';
import React from 'react';
import { EuiIcon, EuiLoadingSpinner } from '@elastic/eui';
import {
PREVALENCE_DETAILS_COUNT_CELL_ERROR_TEST_ID,
PREVALENCE_DETAILS_COUNT_CELL_LOADING_TEST_ID,
PREVALENCE_DETAILS_COUNT_CELL_VALUE_TEST_ID,
} from './test_ids';
import type { EventType } from '../../shared/hooks/use_fetch_field_value_pair_by_event_type';
import { useFetchFieldValuePairByEventType } from '../../shared/hooks/use_fetch_field_value_pair_by_event_type';
export interface PrevalenceDetailsCountCellProps {
/**
* The highlighted field name and values
* */
highlightedField: { name: string; values: string[] };
/**
* Limit the search to include or exclude a specific value for the event.kind field
* (alert, asset, enrichment, event, metric, state, pipeline_error, signal)
*/
type: EventType;
}
/**
* Component displaying a value in many PrevalenceDetails table cells. It is used for the third and fourth columns,
* which display the number of alerts and documents for a given field/value pair.
* For the doc columns, type should "signal" for its eventKind property, and exclude set to true.
* For the alert columns, type should have "signal" for its eventKind property, and include should be true.
*/
export const PrevalenceDetailsCountCell: VFC<PrevalenceDetailsCountCellProps> = ({
highlightedField,
type,
}) => {
const { loading, error, count } = useFetchFieldValuePairByEventType({
highlightedField,
type,
});
if (loading) {
return <EuiLoadingSpinner data-test-subj={PREVALENCE_DETAILS_COUNT_CELL_LOADING_TEST_ID} />;
}
if (error) {
return (
<EuiIcon
data-test-subj={PREVALENCE_DETAILS_COUNT_CELL_ERROR_TEST_ID}
type="error"
color="danger"
/>
);
}
return <div data-test-subj={PREVALENCE_DETAILS_COUNT_CELL_VALUE_TEST_ID}>{count}</div>;
};
PrevalenceDetailsCountCell.displayName = 'PrevalenceDetailsCountCell';

View file

@ -1,161 +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 { render } from '@testing-library/react';
import React from 'react';
import {
PREVALENCE_DETAILS_PREVALENCE_CELL_ERROR_TEST_ID,
PREVALENCE_DETAILS_PREVALENCE_CELL_LOADING_TEST_ID,
PREVALENCE_DETAILS_PREVALENCE_CELL_VALUE_TEST_ID,
} from './test_ids';
import { PrevalenceDetailsPrevalenceCell } from './prevalence_details_prevalence_cell';
import { useFetchFieldValuePairWithAggregation } from '../../shared/hooks/use_fetch_field_value_pair_with_aggregation';
import { useFetchUniqueByField } from '../../shared/hooks/use_fetch_unique_by_field';
jest.mock('../../shared/hooks/use_fetch_field_value_pair_with_aggregation');
jest.mock('../../shared/hooks/use_fetch_unique_by_field');
const highlightedField = {
name: 'field',
values: ['values'],
};
const aggregationField = 'aggregationField';
describe('PrevalenceDetailsAlertCountCell', () => {
it('should show loading spinner when useFetchFieldValuePairWithAggregation loading', () => {
jest.mocked(useFetchFieldValuePairWithAggregation).mockReturnValue({
loading: true,
error: false,
count: 0,
});
jest.mocked(useFetchUniqueByField).mockReturnValue({
loading: false,
error: false,
count: 0,
});
const { getByTestId } = render(
<PrevalenceDetailsPrevalenceCell
highlightedField={highlightedField}
aggregationField={aggregationField}
/>
);
expect(getByTestId(PREVALENCE_DETAILS_PREVALENCE_CELL_LOADING_TEST_ID)).toBeInTheDocument();
});
it('should show loading spinner when useFetchUniqueByField loading', () => {
jest.mocked(useFetchFieldValuePairWithAggregation).mockReturnValue({
loading: false,
error: false,
count: 0,
});
jest.mocked(useFetchUniqueByField).mockReturnValue({
loading: true,
error: false,
count: 0,
});
const { getByTestId } = render(
<PrevalenceDetailsPrevalenceCell
highlightedField={highlightedField}
aggregationField={aggregationField}
/>
);
expect(getByTestId(PREVALENCE_DETAILS_PREVALENCE_CELL_LOADING_TEST_ID)).toBeInTheDocument();
});
it('should return null if useFetchFieldValuePairWithAggregation errors out', () => {
jest.mocked(useFetchFieldValuePairWithAggregation).mockReturnValue({
loading: false,
error: true,
count: 0,
});
jest.mocked(useFetchUniqueByField).mockReturnValue({
loading: false,
error: false,
count: 0,
});
const { getByTestId } = render(
<PrevalenceDetailsPrevalenceCell
highlightedField={highlightedField}
aggregationField={aggregationField}
/>
);
expect(getByTestId(PREVALENCE_DETAILS_PREVALENCE_CELL_ERROR_TEST_ID)).toBeInTheDocument();
});
it('should return null if useFetchUniqueByField errors out', () => {
jest.mocked(useFetchFieldValuePairWithAggregation).mockReturnValue({
loading: false,
error: false,
count: 0,
});
jest.mocked(useFetchUniqueByField).mockReturnValue({
loading: false,
error: true,
count: 0,
});
const { getByTestId } = render(
<PrevalenceDetailsPrevalenceCell
highlightedField={highlightedField}
aggregationField={aggregationField}
/>
);
expect(getByTestId(PREVALENCE_DETAILS_PREVALENCE_CELL_ERROR_TEST_ID)).toBeInTheDocument();
});
it('should return null if prevalence is infinite', () => {
jest.mocked(useFetchFieldValuePairWithAggregation).mockReturnValue({
loading: false,
error: false,
count: 0,
});
jest.mocked(useFetchUniqueByField).mockReturnValue({
loading: false,
error: false,
count: 0,
});
const { getByTestId } = render(
<PrevalenceDetailsPrevalenceCell
highlightedField={highlightedField}
aggregationField={aggregationField}
/>
);
expect(getByTestId(PREVALENCE_DETAILS_PREVALENCE_CELL_ERROR_TEST_ID)).toBeInTheDocument();
});
it('should show prevalence value', () => {
jest.mocked(useFetchFieldValuePairWithAggregation).mockReturnValue({
loading: false,
error: false,
count: 1,
});
jest.mocked(useFetchUniqueByField).mockReturnValue({
loading: false,
error: false,
count: 1,
});
const { getByTestId } = render(
<PrevalenceDetailsPrevalenceCell
highlightedField={highlightedField}
aggregationField={aggregationField}
/>
);
expect(getByTestId(PREVALENCE_DETAILS_PREVALENCE_CELL_VALUE_TEST_ID)).toBeInTheDocument();
expect(getByTestId(PREVALENCE_DETAILS_PREVALENCE_CELL_VALUE_TEST_ID)).toHaveTextContent('1');
});
});

View file

@ -1,78 +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 type { VFC } from 'react';
import React from 'react';
import { EuiIcon, EuiLoadingSpinner } from '@elastic/eui';
import {
PREVALENCE_DETAILS_PREVALENCE_CELL_ERROR_TEST_ID,
PREVALENCE_DETAILS_PREVALENCE_CELL_LOADING_TEST_ID,
PREVALENCE_DETAILS_PREVALENCE_CELL_VALUE_TEST_ID,
} from './test_ids';
import { useFetchFieldValuePairWithAggregation } from '../../shared/hooks/use_fetch_field_value_pair_with_aggregation';
import { useFetchUniqueByField } from '../../shared/hooks/use_fetch_unique_by_field';
export interface PrevalenceDetailsPrevalenceCellProps {
/**
* The highlighted field name and values
* */
highlightedField: { name: string; values: string[] };
/**
* The aggregation field
*/
aggregationField: string;
}
/**
* Component displaying a value in many PrevalenceDetails table cells. It is used for the fifth and sixth columns,
* which displays the prevalence percentage for host.name and user.name fields.
*/
export const PrevalenceDetailsPrevalenceCell: VFC<PrevalenceDetailsPrevalenceCellProps> = ({
highlightedField,
aggregationField,
}) => {
const {
loading: aggregationLoading,
error: aggregationError,
count: aggregationCount,
} = useFetchFieldValuePairWithAggregation({
highlightedField,
aggregationField,
});
const {
loading: uniqueLoading,
error: uniqueError,
count: uniqueCount,
} = useFetchUniqueByField({ field: aggregationField });
if (aggregationLoading || uniqueLoading) {
return (
<EuiLoadingSpinner data-test-subj={PREVALENCE_DETAILS_PREVALENCE_CELL_LOADING_TEST_ID} />
);
}
const prevalence = aggregationCount / uniqueCount;
if (aggregationError || uniqueError || !isFinite(prevalence)) {
return (
<EuiIcon
data-test-subj={PREVALENCE_DETAILS_PREVALENCE_CELL_ERROR_TEST_ID}
type="error"
color="danger"
/>
);
}
return (
<div data-test-subj={PREVALENCE_DETAILS_PREVALENCE_CELL_VALUE_TEST_ID}>
{Math.round(prevalence * 100)}
{'%'}
</div>
);
};
PrevalenceDetailsPrevalenceCell.displayName = 'PrevalenceDetailsPrevalenceCell';

View file

@ -18,11 +18,14 @@ export const SESSION_VIEW_ERROR_TEST_ID = `${PREFIX}SessionViewError` as const;
/* Prevalence */
export const PREVALENCE_DETAILS_DATE_PICKER_TEST_ID =
`${PREFIX}PrevalenceDetailsDatePicker` as const;
export const PREVALENCE_DETAILS_TABLE_TEST_ID = `${PREFIX}PrevalenceDetailsTable` as const;
export const PREVALENCE_DETAILS_TABLE_TYPE_CELL_TEST_ID =
`${PREFIX}PrevalenceDetailsTableTypeCell` as const;
export const PREVALENCE_DETAILS_TABLE_NAME_CELL_TEST_ID =
`${PREFIX}PrevalenceDetailsTableNameCell` as const;
export const PREVALENCE_DETAILS_LOADING_TEST_ID = `${PREFIX}PrevalenceDetailsLoading` as const;
export const PREVALENCE_DETAILS_TABLE_FIELD_CELL_TEST_ID =
`${PREFIX}PrevalenceDetailsTableFieldCell` as const;
export const PREVALENCE_DETAILS_TABLE_VALUE_CELL_TEST_ID =
`${PREFIX}PrevalenceDetailsTableValueCell` as const;
export const PREVALENCE_DETAILS_TABLE_ALERT_COUNT_CELL_TEST_ID =
`${PREFIX}PrevalenceDetailsTableAlertCountCell` as const;
export const PREVALENCE_DETAILS_TABLE_DOC_COUNT_CELL_TEST_ID =
@ -31,19 +34,10 @@ export const PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID =
`${PREFIX}PrevalenceDetailsTableHostPrevalenceCell` as const;
export const PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID =
`${PREFIX}PrevalenceDetailsTableUserPrevalenceCell` as const;
export const PREVALENCE_DETAILS_TABLE_ERROR_TEST_ID = `${PREFIX}PrevalenceDetailsTable` as const;
export const PREVALENCE_DETAILS_COUNT_CELL_LOADING_TEST_ID =
`${PREFIX}PrevalenceDetailsCountCellLoading` as const;
export const PREVALENCE_DETAILS_COUNT_CELL_ERROR_TEST_ID =
`${PREFIX}PrevalenceDetailsCountCellError` as const;
export const PREVALENCE_DETAILS_COUNT_CELL_VALUE_TEST_ID =
`${PREFIX}PrevalenceDetailsCountCellValue` as const;
export const PREVALENCE_DETAILS_PREVALENCE_CELL_LOADING_TEST_ID =
`${PREFIX}PrevalenceDetailsPrevalenceCellLoading` as const;
export const PREVALENCE_DETAILS_PREVALENCE_CELL_ERROR_TEST_ID =
`${PREFIX}PrevalenceDetailsPrevalenceCellError` as const;
export const PREVALENCE_DETAILS_PREVALENCE_CELL_VALUE_TEST_ID =
`${PREFIX}PrevalenceDetailsPrevalenceCellValue` as const;
export const PREVALENCE_DETAILS_TABLE_ERROR_TEST_ID =
`${PREFIX}PrevalenceDetailsTableError` as const;
export const PREVALENCE_DETAILS_TABLE_NO_DATA_TEST_ID =
`${PREFIX}PrevalenceDetailsTableNoData` as const;
/* Entities */

View file

@ -99,45 +99,52 @@ export const PREVALENCE_ERROR_MESSAGE = i18n.translate(
}
);
export const PREVALENCE_TABLE_TYPE_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableTypeColumnTitle',
export const PREVALENCE_NO_DATA_MESSAGE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceNoDataMessage',
{
defaultMessage: 'Type',
defaultMessage: 'No prevalence data available',
}
);
export const PREVALENCE_TABLE_NAME_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableNameColumnTitle',
export const PREVALENCE_TABLE_FIELD_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableFieldColumnTitle',
{
defaultMessage: 'Name',
defaultMessage: 'Field',
}
);
export const PREVALENCE_TABLE_VALUE_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableValueColumnTitle',
{
defaultMessage: 'Value',
}
);
export const PREVALENCE_TABLE_ALERT_COUNT_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableAlertCountColumnTitle',
{
defaultMessage: 'Alert count',
defaultMessage: 'Alert',
}
);
export const PREVALENCE_TABLE_DOC_COUNT_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableDocCountColumnTitle',
{
defaultMessage: 'Doc count',
defaultMessage: 'Document',
}
);
export const PREVALENCE_TABLE_HOST_PREVALENCE_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableHostPrevalenceColumnTitle',
export const PREVALENCE_TABLE_COUNT_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableCountColumnTitle',
{
defaultMessage: 'Host prevalence',
defaultMessage: 'count',
}
);
export const PREVALENCE_TABLE_USER_PREVALENCE_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableUserPrevalenceColumnTitle',
export const PREVALENCE_TABLE_PREVALENCE_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTablePrevalenceColumnTitle',
{
defaultMessage: 'User prevalence',
defaultMessage: 'prevalence',
}
);

View file

@ -11,11 +11,11 @@ import { RightPanelContext } from '../context';
import { HIGHLIGHTED_FIELDS_DETAILS_TEST_ID, HIGHLIGHTED_FIELDS_TITLE_TEST_ID } from './test_ids';
import { HighlightedFields } from './highlighted_fields';
import { mockDataFormattedForFieldBrowser } from '../mocks/mock_context';
import { useHighlightedFields } from '../hooks/use_highlighted_fields';
import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields';
import { TestProviders } from '../../../common/mock';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
jest.mock('../hooks/use_highlighted_fields');
jest.mock('../../shared/hooks/use_highlighted_fields');
jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback');
describe('<HighlightedFields />', () => {
@ -27,15 +27,11 @@ describe('<HighlightedFields />', () => {
const panelContextValue = {
dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser,
} as unknown as RightPanelContext;
(useHighlightedFields as jest.Mock).mockReturnValue([
{
field: 'field',
description: {
field: 'field',
values: ['value'],
},
(useHighlightedFields as jest.Mock).mockReturnValue({
field: {
values: ['value'],
},
]);
});
const { getByTestId } = render(
<TestProviders>
@ -53,7 +49,7 @@ describe('<HighlightedFields />', () => {
const panelContextValue = {
dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser,
} as unknown as RightPanelContext;
(useHighlightedFields as jest.Mock).mockReturnValue([]);
(useHighlightedFields as jest.Mock).mockReturnValue({});
const { container } = render(
<RightPanelContext.Provider value={panelContextValue}>
@ -68,15 +64,11 @@ describe('<HighlightedFields />', () => {
const panelContextValue = {
dataFormattedForFieldBrowser: null,
} as unknown as RightPanelContext;
(useHighlightedFields as jest.Mock).mockReturnValue([
{
field: 'field',
description: {
field: 'field',
values: ['value'],
},
(useHighlightedFields as jest.Mock).mockReturnValue({
field: {
values: ['value'],
},
]);
});
const { container } = render(
<RightPanelContext.Provider value={panelContextValue}>

View file

@ -6,9 +6,10 @@
*/
import type { FC } from 'react';
import React from 'react';
import React, { useMemo } from 'react';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, EuiPanel, EuiTitle } from '@elastic/eui';
import { convertHighlightedFieldsToTableRow } from '../../shared/utils/highlighted_fields_helpers';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import { HighlightedFieldsCell } from './highlighted_fields_cell';
@ -17,7 +18,6 @@ import {
SecurityCellActions,
SecurityCellActionsTrigger,
} from '../../../common/components/cell_actions';
import type { UseHighlightedFieldsResult } from '../hooks/use_highlighted_fields';
import { HIGHLIGHTED_FIELDS_DETAILS_TEST_ID, HIGHLIGHTED_FIELDS_TITLE_TEST_ID } from './test_ids';
import {
HIGHLIGHTED_FIELDS_FIELD_COLUMN,
@ -25,14 +25,30 @@ import {
HIGHLIGHTED_FIELDS_VALUE_COLUMN,
} from './translations';
import { useRightPanelContext } from '../context';
import { useHighlightedFields } from '../hooks/use_highlighted_fields';
import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields';
const columns: Array<EuiBasicTableColumn<UseHighlightedFieldsResult>> = [
export interface HighlightedFieldsTableRow {
/**
* Highlighted field name (overrideField or if null, falls back to id)
*/
field: string;
description: {
/**
* Highlighted field name (overrideField or if null, falls back to id)
*/
field: string;
/**
* Highlighted field value
*/
values: string[] | null | undefined;
};
}
const columns: Array<EuiBasicTableColumn<HighlightedFieldsTableRow>> = [
{
field: 'field',
name: HIGHLIGHTED_FIELDS_FIELD_COLUMN,
'data-test-subj': 'fieldCell',
width: '125px',
},
{
field: 'description',
@ -66,8 +82,12 @@ export const HighlightedFields: FC = () => {
dataFormattedForFieldBrowser,
investigationFields: maybeRule?.investigation_fields ?? [],
});
const items = useMemo(
() => convertHighlightedFieldsToTableRow(highlightedFields),
[highlightedFields]
);
if (!dataFormattedForFieldBrowser || highlightedFields.length === 0) {
if (!dataFormattedForFieldBrowser || items.length === 0) {
return null;
}
@ -80,7 +100,7 @@ export const HighlightedFields: FC = () => {
</EuiFlexItem>
<EuiFlexItem data-test-subj={HIGHLIGHTED_FIELDS_DETAILS_TEST_ID}>
<EuiPanel hasBorder hasShadow={false}>
<EuiInMemoryTable items={highlightedFields} columns={columns} compressed />
<EuiInMemoryTable items={items} columns={columns} compressed tableLayout="auto" />
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -13,152 +13,187 @@ import { INSIGHTS_PREVALENCE_TEST_ID } from './test_ids';
import { LeftPanelInsightsTab, LeftPanelKey } from '../../left';
import React from 'react';
import { PrevalenceOverview } from './prevalence_overview';
import { usePrevalence } from '../hooks/use_prevalence';
import { PrevalenceOverviewRow } from './prevalence_overview_row';
import { useFetchFieldValuePairWithAggregation } from '../../shared/hooks/use_fetch_field_value_pair_with_aggregation';
import { useFetchUniqueByField } from '../../shared/hooks/use_fetch_unique_by_field';
import { PREVALENCE_TAB_ID } from '../../left/components/prevalence_details';
import type { BrowserFields, TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import {
EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID,
EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID,
EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID,
EXPANDABLE_PANEL_LOADING_TEST_ID,
EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID,
} from '../../shared/components/test_ids';
import { usePrevalence } from '../../shared/hooks/use_prevalence';
import { mockContextValue } from '../mocks/mock_right_panel_context';
jest.mock('../../shared/hooks/use_fetch_field_value_pair_with_aggregation');
jest.mock('../../shared/hooks/use_fetch_unique_by_field');
jest.mock('../hooks/use_prevalence');
jest.mock('../../shared/hooks/use_prevalence');
const TOGGLE_ICON_TEST_ID = EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID(INSIGHTS_PREVALENCE_TEST_ID);
const TITLE_LINK_TEST_ID = EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(INSIGHTS_PREVALENCE_TEST_ID);
const TITLE_ICON_TEST_ID = EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(INSIGHTS_PREVALENCE_TEST_ID);
const TITLE_TEXT_TEST_ID = EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(INSIGHTS_PREVALENCE_TEST_ID);
const highlightedField = {
name: 'field',
values: ['values'],
};
const panelContextValue = (
eventId: string | null,
browserFields: BrowserFields | null,
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[] | null
) =>
({
eventId,
indexName: 'indexName',
browserFields,
dataFormattedForFieldBrowser,
scopeId: 'scopeId',
} as unknown as RightPanelContext);
const flyoutContextValue = {
openLeftPanel: jest.fn(),
} as unknown as ExpandableFlyoutContext;
const renderPrevalenceOverview = (contextValue: RightPanelContext) => (
const renderPrevalenceOverview = (contextValue: RightPanelContext = mockContextValue) => (
<TestProviders>
<RightPanelContext.Provider value={contextValue}>
<PrevalenceOverview />
</RightPanelContext.Provider>
<ExpandableFlyoutContext.Provider value={flyoutContextValue}>
<RightPanelContext.Provider value={contextValue}>
<PrevalenceOverview />
</RightPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
</TestProviders>
);
describe('<PrevalenceOverview />', () => {
it('should render wrapper component', () => {
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
(usePrevalence as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 1,
data: [],
});
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 10,
});
(usePrevalence as jest.Mock).mockReturnValue([]);
const { getByTestId, queryByTestId } = render(
renderPrevalenceOverview(panelContextValue('eventId', {}, []))
);
const { getByTestId, queryByTestId } = render(renderPrevalenceOverview());
expect(queryByTestId(TOGGLE_ICON_TEST_ID)).not.toBeInTheDocument();
expect(getByTestId(TITLE_LINK_TEST_ID)).toBeInTheDocument();
expect(getByTestId(TITLE_LINK_TEST_ID)).toHaveTextContent('Prevalence');
expect(getByTestId(TITLE_ICON_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(TITLE_TEXT_TEST_ID)).not.toBeInTheDocument();
});
it('should render component', () => {
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
loading: false,
it('should render loading', () => {
(usePrevalence as jest.Mock).mockReturnValue({
loading: true,
error: false,
count: 1,
data: [],
});
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 10,
});
(usePrevalence as jest.Mock).mockReturnValue([
<PrevalenceOverviewRow highlightedField={highlightedField} data-test-subj={'test'} />,
]);
const { getByTestId } = render(renderPrevalenceOverview(panelContextValue('eventId', {}, [])));
const { getByTestId } = render(renderPrevalenceOverview());
expect(
getByTestId(EXPANDABLE_PANEL_LOADING_TEST_ID(INSIGHTS_PREVALENCE_TEST_ID))
).toBeInTheDocument();
});
it('should render no-data message', () => {
(usePrevalence as jest.Mock).mockReturnValue({
loading: false,
error: false,
data: [],
});
const { getByTestId } = render(renderPrevalenceOverview());
expect(getByTestId(`${INSIGHTS_PREVALENCE_TEST_ID}Error`)).toBeInTheDocument();
});
it('should render only data with prevalence less than 10%', () => {
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,
},
],
});
const { queryByTestId, getByTestId } = render(renderPrevalenceOverview());
expect(getByTestId(TITLE_LINK_TEST_ID)).toHaveTextContent('Prevalence');
const iconDataTestSubj = 'testIcon';
const valueDataTestSubj = 'testValue';
expect(getByTestId(iconDataTestSubj)).toBeInTheDocument();
expect(getByTestId(valueDataTestSubj)).toBeInTheDocument();
const iconDataTestSubj1 = `${INSIGHTS_PREVALENCE_TEST_ID}${field1}Icon`;
const valueDataTestSubj1 = `${INSIGHTS_PREVALENCE_TEST_ID}${field1}Value`;
expect(getByTestId(iconDataTestSubj1)).toBeInTheDocument();
expect(getByTestId(valueDataTestSubj1)).toBeInTheDocument();
expect(getByTestId(valueDataTestSubj1)).toHaveTextContent('field1, value1 is uncommon');
const iconDataTestSubj2 = `${INSIGHTS_PREVALENCE_TEST_ID}${field2}Icon`;
const valueDataTestSubj2 = `${INSIGHTS_PREVALENCE_TEST_ID}${field2}Value`;
expect(queryByTestId(iconDataTestSubj2)).not.toBeInTheDocument();
expect(queryByTestId(valueDataTestSubj2)).not.toBeInTheDocument();
});
it('should render null if eventId is null', () => {
(usePrevalence as jest.Mock).mockReturnValue([]);
(usePrevalence as jest.Mock).mockReturnValue({
loading: false,
error: false,
data: [],
});
const contextValue = {
...mockContextValue,
eventId: null,
} as unknown as RightPanelContext;
const { container } = render(renderPrevalenceOverview(panelContextValue(null, {}, [])));
const { container } = render(renderPrevalenceOverview(contextValue));
expect(container).toBeEmptyDOMElement();
});
it('should render null if browserFields is null', () => {
(usePrevalence as jest.Mock).mockReturnValue([]);
(usePrevalence as jest.Mock).mockReturnValue({
loading: false,
error: false,
data: [],
});
const contextValue = {
...mockContextValue,
browserFields: null,
};
const { container } = render(renderPrevalenceOverview(panelContextValue('eventId', null, [])));
const { container } = render(renderPrevalenceOverview(contextValue));
expect(container).toBeEmptyDOMElement();
});
it('should render null if dataFormattedForFieldBrowser is null', () => {
(usePrevalence as jest.Mock).mockReturnValue([]);
(usePrevalence as jest.Mock).mockReturnValue({
loading: false,
error: false,
data: [],
});
const contextValue = {
...mockContextValue,
dataFormattedForFieldBrowser: null,
};
const { container } = render(renderPrevalenceOverview(panelContextValue('eventId', {}, null)));
const { container } = render(renderPrevalenceOverview(contextValue));
expect(container).toBeEmptyDOMElement();
});
it('should navigate to left section Insights tab when clicking on button', () => {
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
(usePrevalence as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 1,
data: [
{
field: 'field1',
value: 'value1',
alertCount: 1,
docCount: 1,
hostPrevalence: 0.05,
userPrevalence: 0.1,
},
],
});
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 10,
});
(usePrevalence as jest.Mock).mockReturnValue([
<PrevalenceOverviewRow highlightedField={highlightedField} data-test-subj={'test'} />,
]);
const flyoutContextValue = {
openLeftPanel: jest.fn(),
} as unknown as ExpandableFlyoutContext;
const { getByTestId } = render(
<TestProviders>
<ExpandableFlyoutContext.Provider value={flyoutContextValue}>
<RightPanelContext.Provider value={panelContextValue('eventId', {}, [])}>
<PrevalenceOverview />
</RightPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
</TestProviders>
);
const { getByTestId } = render(renderPrevalenceOverview());
getByTestId(TITLE_LINK_TEST_ID).click();
expect(flyoutContextValue.openLeftPanel).toHaveBeenCalledWith({
@ -166,7 +201,7 @@ describe('<PrevalenceOverview />', () => {
path: { tab: LeftPanelInsightsTab, subTab: PREVALENCE_TAB_ID },
params: {
id: 'eventId',
indexName: 'indexName',
indexName: 'index',
scopeId: 'scopeId',
},
});

View file

@ -6,21 +6,25 @@
*/
import type { FC } from 'react';
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { EuiFlexGroup } from '@elastic/eui';
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { ExpandablePanel } from '../../shared/components/expandable_panel';
import { usePrevalence } from '../hooks/use_prevalence';
import { usePrevalence } from '../../shared/hooks/use_prevalence';
import { INSIGHTS_PREVALENCE_TEST_ID } from './test_ids';
import { useRightPanelContext } from '../context';
import { PREVALENCE_TITLE } from './translations';
import { PREVALENCE_NO_DATA, PREVALENCE_ROW_UNCOMMON, PREVALENCE_TITLE } from './translations';
import { LeftPanelKey, LeftPanelInsightsTab } from '../../left';
import { PREVALENCE_TAB_ID } from '../../left/components/prevalence_details';
import { InsightsSummaryRow } from './insights_summary_row';
const PERCENTAGE_THRESHOLD = 0.1; // we show the prevalence if its value is below 10%
const DEFAULT_FROM = 'now-30d';
const DEFAULT_TO = 'now';
/**
* Prevalence section under Insights section, overview tab.
* The component fetches the necessary data, then pass it down to the InsightsSubSection component for loading and error state,
* and the SummaryPanel component for data rendering.
* The component fetches the necessary data at once. The loading and error states are handled by the ExpandablePanel component.
*/
export const PrevalenceOverview: FC = () => {
const {
@ -48,14 +52,27 @@ export const PrevalenceOverview: FC = () => {
});
}, [eventId, openLeftPanel, indexName, scopeId]);
const prevalenceRows = usePrevalence({
eventId,
browserFields,
const { loading, error, data } = usePrevalence({
dataFormattedForFieldBrowser,
investigationFields,
scopeId,
interval: {
from: DEFAULT_FROM,
to: DEFAULT_TO,
},
});
// only show data if the host prevalence is below 10%
const uncommonData = useMemo(
() =>
data.filter(
(d) =>
isFinite(d.hostPrevalence) &&
d.hostPrevalence > 0 &&
d.hostPrevalence < PERCENTAGE_THRESHOLD
),
[data]
);
if (!eventId || !browserFields || !dataFormattedForFieldBrowser) {
return null;
}
@ -67,10 +84,21 @@ export const PrevalenceOverview: FC = () => {
callback: goToCorrelationsTab,
iconType: 'arrowStart',
}}
content={{ loading, error }}
data-test-subj={INSIGHTS_PREVALENCE_TEST_ID}
>
<EuiFlexGroup direction="column" gutterSize="none">
{prevalenceRows}
{uncommonData.length > 0 ? (
uncommonData.map((d) => (
<InsightsSummaryRow
icon={'warning'}
text={`${d.field}, ${d.value} ${PREVALENCE_ROW_UNCOMMON}`}
data-test-subj={`${INSIGHTS_PREVALENCE_TEST_ID}${d.field}`}
/>
))
) : (
<div data-test-subj={`${INSIGHTS_PREVALENCE_TEST_ID}Error`}>{PREVALENCE_NO_DATA}</div>
)}
</EuiFlexGroup>
</ExpandablePanel>
);

View file

@ -1,108 +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 { render } from '@testing-library/react';
import React from 'react';
import { PrevalenceOverviewRow } from './prevalence_overview_row';
import { useFetchFieldValuePairWithAggregation } from '../../shared/hooks/use_fetch_field_value_pair_with_aggregation';
import { useFetchUniqueByField } from '../../shared/hooks/use_fetch_unique_by_field';
jest.mock('../../shared/hooks/use_fetch_field_value_pair_with_aggregation');
jest.mock('../../shared/hooks/use_fetch_unique_by_field');
const highlightedField = {
name: 'field',
values: ['values'],
};
const dataTestSubj = 'test';
const iconDataTestSubj = 'testIcon';
const valueDataTestSubj = 'testValue';
const colorDataTestSubj = 'testColor';
const loadingDataTestSubj = 'testLoading';
describe('<PrevalenceOverviewRow />', () => {
it('should display row if prevalence is below or equal threshold', () => {
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 1,
});
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 10,
});
const { getByTestId, getAllByText, queryByTestId } = render(
<PrevalenceOverviewRow highlightedField={highlightedField} data-test-subj={dataTestSubj} />
);
const { name, values } = highlightedField;
expect(getByTestId(iconDataTestSubj)).toBeInTheDocument();
expect(getByTestId(valueDataTestSubj)).toBeInTheDocument();
expect(getAllByText(`${name}, ${values} is uncommon`)).toHaveLength(1);
expect(queryByTestId(colorDataTestSubj)).not.toBeInTheDocument();
});
it('should not display row if prevalence is higher than threshold', () => {
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 1,
});
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 2,
});
const { queryAllByAltText } = render(
<PrevalenceOverviewRow highlightedField={highlightedField} data-test-subj={dataTestSubj} />
);
expect(queryAllByAltText('is uncommon')).toHaveLength(0);
});
it('should not display row if error retrieving data', () => {
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
loading: false,
error: true,
count: 0,
});
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: true,
count: 0,
});
const { queryAllByAltText } = render(
<PrevalenceOverviewRow highlightedField={highlightedField} data-test-subj={dataTestSubj} />
);
expect(queryAllByAltText('is uncommon')).toHaveLength(0);
});
it('should display loading', () => {
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
loading: true,
error: false,
count: 1,
});
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 10,
});
const { getByTestId } = render(
<PrevalenceOverviewRow highlightedField={highlightedField} data-test-subj={dataTestSubj} />
);
expect(getByTestId(loadingDataTestSubj)).toBeInTheDocument();
});
});

View file

@ -1,75 +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 type { VFC } from 'react';
import React from 'react';
import { PREVALENCE_ROW_UNCOMMON } from './translations';
import { useFetchFieldValuePairWithAggregation } from '../../shared/hooks/use_fetch_field_value_pair_with_aggregation';
import { useFetchUniqueByField } from '../../shared/hooks/use_fetch_unique_by_field';
import { InsightsSummaryRow } from './insights_summary_row';
const HOST_FIELD = 'host.name';
const PERCENTAGE_THRESHOLD = 0.1; // we show the prevalence if its value is below 10%
export interface PrevalenceOverviewRowProps {
/**
* The highlighted field name and values
* */
highlightedField: { name: string; values: string[] };
/**
* Prefix data-test-subj because this component will be used in multiple places
*/
['data-test-subj']?: string;
}
/**
* Retrieves the unique hosts for the field/value pair as well as the total number of unique hosts,
* calculate the prevalence. If the prevalence is higher than 0.1, the row will render null.
*/
export const PrevalenceOverviewRow: VFC<PrevalenceOverviewRowProps> = ({
highlightedField,
'data-test-subj': dataTestSubj,
}) => {
const {
loading: hostsLoading,
error: hostsError,
count: hostsCount,
} = useFetchFieldValuePairWithAggregation({
highlightedField,
aggregationField: HOST_FIELD,
});
const {
loading: uniqueHostsLoading,
error: uniqueHostsError,
count: uniqueHostsCount,
} = useFetchUniqueByField({ field: HOST_FIELD });
const { name, values } = highlightedField;
// prevalence is number of host(s) where the field/value pair was found divided by the total number of hosts in the environment
const prevalence = hostsCount / uniqueHostsCount;
const loading = hostsLoading || uniqueHostsLoading;
const error = hostsError || uniqueHostsError;
const text = `${name}, ${values} ${PREVALENCE_ROW_UNCOMMON}`;
// we do not want to render the row is the prevalence is Infinite, 0 or above the decided threshold
const shouldNotRender =
isFinite(prevalence) && (prevalence === 0 || prevalence > PERCENTAGE_THRESHOLD);
return (
<InsightsSummaryRow
loading={loading}
error={error || shouldNotRender}
icon={'warning'}
text={text}
data-test-subj={dataTestSubj}
/>
);
};
PrevalenceOverviewRow.displayName = 'PrevalenceOverviewRow';

View file

@ -119,8 +119,6 @@ export const INSIGHTS_CORRELATIONS_RELATED_ALERTS_BY_ANCESTRY_TEST_ID =
export const INSIGHTS_PREVALENCE_TEST_ID =
'securitySolutionDocumentDetailsFlyoutInsightsPrevalence';
export const INSIGHTS_PREVALENCE_ROW_TEST_ID =
'securitySolutionDocumentDetailsFlyoutInsightsPrevalenceRow';
/* Visualizations section */

View file

@ -157,6 +157,11 @@ export const PREVALENCE_TITLE = i18n.translate(
{ defaultMessage: 'Prevalence' }
);
export const PREVALENCE_NO_DATA = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.prevalenceNoData',
{ defaultMessage: 'No field/value pairs are uncommon' }
);
export const THREAT_MATCH_DETECTED = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.overviewTab.threatIntelligence.threatMatch',
{

View file

@ -1,53 +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 type { ReactElement } from 'react';
import { getSummaryRows } from '../../../common/components/event_details/get_alert_summary_rows';
import { usePrevalence } from './use_prevalence';
import type { RenderHookResult } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react-hooks';
import { mockDataFormattedForFieldBrowser } from '../mocks/mock_context';
jest.mock('../../../common/components/event_details/get_alert_summary_rows');
const eventId = 'eventId';
const browserFields = null;
const dataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser;
const scopeId = 'scopeId';
describe('usePrevalence', () => {
let hookResult: RenderHookResult<unknown, ReactElement[]>;
it('should return 1 row to render', () => {
const mockSummaryRow = {
title: 'test',
description: {
data: {
field: 'field',
},
values: ['value'],
},
};
(getSummaryRows as jest.Mock).mockReturnValue([mockSummaryRow]);
hookResult = renderHook(() =>
usePrevalence({ browserFields, dataFormattedForFieldBrowser, eventId, scopeId })
);
expect(hookResult.result.current.length).toEqual(1);
});
it('should return empty true', () => {
(getSummaryRows as jest.Mock).mockReturnValue([]);
hookResult = renderHook(() =>
usePrevalence({ browserFields, dataFormattedForFieldBrowser, eventId, scopeId })
);
expect(hookResult.result.current.length).toEqual(0);
});
});

View file

@ -1,83 +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 type { BrowserFields, TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { ReactElement } from 'react';
import React, { useMemo } from 'react';
import { getSummaryRows } from '../../../common/components/event_details/get_alert_summary_rows';
import { PrevalenceOverviewRow } from '../components/prevalence_overview_row';
import { INSIGHTS_PREVALENCE_ROW_TEST_ID } from '../components/test_ids';
export interface UsePrevalenceParams {
/**
* Id of the document
*/
eventId: string;
/**
* An object containing fields by type
*/
browserFields: BrowserFields | null;
/**
* An array of field objects with category and value
*/
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[] | null;
/**
* Maintain backwards compatibility // TODO remove when possible
*/
scopeId: string;
/**
* User defined fields to highlight (defined on rule)
*/
investigationFields?: string[];
}
/**
* This hook retrieves the highlighted fields from the {@link getSummaryRows} method, then iterates through them
* and generate a {@link PrevalenceOverviewRow} for each.
* We use a callback method passed down to the {@link PrevalenceOverviewRow} component to know when it's rendered as null.
* We need to let the parent know when all the {@link PrevalenceOverviewRow} are null, so it can hide then entire section.
*/
export const usePrevalence = ({
eventId,
browserFields,
dataFormattedForFieldBrowser,
investigationFields,
scopeId,
}: UsePrevalenceParams): ReactElement[] => {
// retrieves the highlighted fields
const summaryRows = useMemo(
() =>
getSummaryRows({
browserFields: browserFields || {},
data: dataFormattedForFieldBrowser || [],
investigationFields: investigationFields || [],
eventId,
scopeId,
isReadOnly: false,
}),
[browserFields, investigationFields, dataFormattedForFieldBrowser, eventId, scopeId]
);
return useMemo(
() =>
summaryRows.map((row) => {
const highlightedField = {
name: row.description.data.field,
values: row.description.values || [],
};
return (
<PrevalenceOverviewRow
highlightedField={highlightedField}
data-test-subj={INSIGHTS_PREVALENCE_ROW_TEST_ID}
key={row.description.data.field}
/>
);
}),
[summaryRows]
);
};

View file

@ -9,7 +9,7 @@ import type { FC } from 'react';
import React, { memo, useMemo } from 'react';
import type { FlyoutPanelProps, PanelPath } from '@kbn/expandable-flyout';
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { EventKind } from '../shared/hooks/use_fetch_field_value_pair_by_event_type';
import { EventKind } from '../shared/constants/event_kinds';
import { getField } from '../shared/utils';
import { useRightPanelContext } from './context';
import { PanelHeader } from './header';

View file

@ -17,7 +17,7 @@ export const mockContextValue: RightPanelContext = {
scopeId: 'scopeId',
getFieldsData: mockGetFieldsData,
dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser,
browserFields: null,
browserFields: {},
dataAsNestedObject: null,
searchHit: undefined,
investigationFields: [],

View file

@ -0,0 +1,17 @@
/*
* 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.
*/
export enum EventKind {
alert = 'alert',
asset = 'asset',
enrichment = 'enrichment',
event = 'event',
metric = 'metric',
state = 'state',
pipeline_error = 'pipeline_error',
signal = 'signal',
}

View file

@ -1,85 +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 '@tanstack/react-query';
import type { RenderHookResult } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react-hooks';
import { useKibana } from '../../../common/lib/kibana';
import type {
UseFetchFieldValuePairByEventTypeParams,
UseFetchFieldValuePairByEventTypeResult,
} from './use_fetch_field_value_pair_by_event_type';
import {
EventKind,
useFetchFieldValuePairByEventType,
} from './use_fetch_field_value_pair_by_event_type';
jest.mock('@tanstack/react-query');
jest.mock('../../../common/lib/kibana');
const highlightedField = {
name: 'field',
values: ['values'],
};
const type = {
eventKind: EventKind.alert,
include: true,
};
describe('useFetchFieldValuePairByEventType', () => {
let hookResult: RenderHookResult<
UseFetchFieldValuePairByEventTypeParams,
UseFetchFieldValuePairByEventTypeResult
>;
(useKibana as jest.Mock).mockReturnValue({
services: {
data: { search: jest.fn() },
},
});
it('should return loading true while data is being fetched', () => {
(useQuery as jest.Mock).mockReturnValue({
isLoading: true,
isError: false,
data: 0,
});
hookResult = renderHook(() => useFetchFieldValuePairByEventType({ highlightedField, type }));
expect(hookResult.result.current.loading).toBeTruthy();
expect(hookResult.result.current.error).toBeFalsy();
expect(hookResult.result.current.count).toBe(0);
});
it('should return error true when data fetching has errored out', () => {
(useQuery as jest.Mock).mockReturnValue({
isLoading: false,
isError: true,
data: 0,
});
hookResult = renderHook(() => useFetchFieldValuePairByEventType({ highlightedField, type }));
expect(hookResult.result.current.loading).toBeFalsy();
expect(hookResult.result.current.error).toBeTruthy();
expect(hookResult.result.current.count).toBe(0);
});
it('should return count on success', () => {
(useQuery as jest.Mock).mockReturnValue({
isLoading: false,
isError: false,
data: 1,
});
hookResult = renderHook(() => useFetchFieldValuePairByEventType({ highlightedField, type }));
expect(hookResult.result.current.loading).toBeFalsy();
expect(hookResult.result.current.error).toBeFalsy();
expect(hookResult.result.current.count).toBe(1);
});
});

View file

@ -1,167 +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 { buildEsQuery } from '@kbn/es-query';
import type { IEsSearchRequest } from '@kbn/data-plugin/public';
import { useQuery } from '@tanstack/react-query';
import { createFetchData } from '../utils/fetch_data';
import { useKibana } from '../../../common/lib/kibana';
import type { RawResponse } from '../utils/fetch_data';
const QUERY_KEY = 'FetchFieldValuePairByEventType';
const DEFAULT_FROM = 'now-30d';
const DEFAULT_TO = 'now';
export enum EventKind {
alert = 'alert',
asset = 'asset',
enrichment = 'enrichment',
event = 'event',
metric = 'metric',
state = 'state',
pipeline_error = 'pipeline_error',
signal = 'signal',
}
export interface EventType {
eventKind: EventKind;
include?: boolean;
exclude?: boolean;
}
export interface UseFetchFieldValuePairByEventTypeParams {
/**
* The highlighted field name and values
* */
highlightedField: { name: string; values: string[] };
/**
* Limit the search to include or exclude a specific value for the event.kind field
* (alert, asset, enrichment, event, metric, state, pipeline_error, signal)
*/
type: EventType;
}
export interface UseFetchFieldValuePairByEventTypeResult {
/**
* Returns true if data is being loaded
*/
loading: boolean;
/**
* Returns true if fetching data has errored out
*/
error: boolean;
/**
* Number of unique hosts found for the field/value pair
*/
count: number;
}
/**
* Hook to retrieve all the unique hosts in the environment that have the field/value pair, using ReactQuery.
*/
export const useFetchFieldValuePairByEventType = ({
highlightedField,
type,
}: UseFetchFieldValuePairByEventTypeParams): UseFetchFieldValuePairByEventTypeResult => {
const {
services: {
data: { search: searchService },
},
} = useKibana();
const { from, to } = { from: DEFAULT_FROM, to: DEFAULT_TO };
const { name, values } = highlightedField;
const req: IEsSearchRequest = buildSearchRequest(name, values, from, to, type);
const { data, isLoading, isError } = useQuery(
[QUERY_KEY, name, values, from, to, type],
() => createFetchData<RawResponse>(searchService, req),
{
select: (res) => res.hits.total,
keepPreviousData: true,
}
);
return {
loading: isLoading,
error: isError,
count: data || 0,
};
};
/**
* Build the search request for the field/values pair, for a date range from/to.
* We set the size to 0 as we only care about the total number of documents.
* Passing signalEventKind as true will return only alerts (event.kind === "signal"), otherwise return all other documents (event.kind !== "signal")
*/
const buildSearchRequest = (
field: string,
values: string[],
from: string,
to: string,
type: EventType
): IEsSearchRequest => {
const query = buildEsQuery(
undefined,
[],
[
{
query: {
bool: {
must: [
{
match: {
[field]: values[0],
},
},
{
range: {
'@timestamp': {
gte: from,
lte: to,
},
},
},
...(type.include
? [
{
match: {
'event.kind': type.eventKind,
},
},
]
: []),
],
...(type.exclude
? {
must_not: [
{
match: {
'event.kind': type.eventKind,
},
},
],
}
: {}),
},
},
meta: {},
},
]
);
return {
params: {
body: {
query,
size: 1000,
},
},
};
};

View file

@ -1,94 +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 '@tanstack/react-query';
import type { RenderHookResult } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react-hooks';
import { useKibana } from '../../../common/lib/kibana';
import type {
UseFetchFieldValuePairWithAggregationParams,
UseFetchFieldValuePairWithAggregationResult,
} from './use_fetch_field_value_pair_with_aggregation';
import { useFetchFieldValuePairWithAggregation } from './use_fetch_field_value_pair_with_aggregation';
jest.mock('@tanstack/react-query');
jest.mock('../../../common/lib/kibana');
const highlightedField = {
name: 'field',
values: ['values'],
};
const aggregationField = 'aggregationField';
describe('useFetchFieldValuePairWithAggregation', () => {
let hookResult: RenderHookResult<
UseFetchFieldValuePairWithAggregationParams,
UseFetchFieldValuePairWithAggregationResult
>;
(useKibana as jest.Mock).mockReturnValue({
services: {
data: { search: jest.fn() },
},
});
it('should return loading true while data is being fetched', () => {
(useQuery as jest.Mock).mockReturnValue({
isLoading: true,
isError: false,
data: 0,
});
hookResult = renderHook(() =>
useFetchFieldValuePairWithAggregation({
highlightedField,
aggregationField,
})
);
expect(hookResult.result.current.loading).toBeTruthy();
expect(hookResult.result.current.error).toBeFalsy();
expect(hookResult.result.current.count).toBe(0);
});
it('should return error true when data fetching has errored out', () => {
(useQuery as jest.Mock).mockReturnValue({
isLoading: false,
isError: true,
data: 0,
});
hookResult = renderHook(() =>
useFetchFieldValuePairWithAggregation({
highlightedField,
aggregationField,
})
);
expect(hookResult.result.current.loading).toBeFalsy();
expect(hookResult.result.current.error).toBeTruthy();
expect(hookResult.result.current.count).toBe(0);
});
it('should return count on success', () => {
(useQuery as jest.Mock).mockReturnValue({
isLoading: false,
isError: false,
data: 1,
});
hookResult = renderHook(() =>
useFetchFieldValuePairWithAggregation({
highlightedField,
aggregationField,
})
);
expect(hookResult.result.current.loading).toBeFalsy();
expect(hookResult.result.current.error).toBeFalsy();
expect(hookResult.result.current.count).toBe(1);
});
});

View file

@ -1,122 +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 { buildEsQuery } from '@kbn/es-query';
import type { IEsSearchRequest } from '@kbn/data-plugin/public';
import { useQuery } from '@tanstack/react-query';
import { buildAggregationSearchRequest } from '../utils/build_requests';
import type { RawAggregatedDataResponse } from '../utils/fetch_data';
import { AGG_KEY, createFetchData } from '../utils/fetch_data';
import { useKibana } from '../../../common/lib/kibana';
const QUERY_KEY = 'useFetchFieldValuePairWithAggregation';
const DEFAULT_FROM = 'now-30d';
const DEFAULT_TO = 'now';
export interface UseFetchFieldValuePairWithAggregationParams {
/**
* The highlighted field name and values
* */
highlightedField: { name: string; values: string[] };
/**
* Field to aggregate value by
*/
aggregationField: string;
}
export interface UseFetchFieldValuePairWithAggregationResult {
/**
* Returns true if data is being loaded
*/
loading: boolean;
/**
* Returns true if fetching data has errored out
*/
error: boolean;
/**
* Number of unique hosts found for the field/value pair
*/
count: number;
}
/**
* Hook to retrieve all the unique documents for the aggregationField in the environment that have the field/value pair, using ReactQuery.
*
* Foe example, passing 'host.name' via the aggregationField props will return the number of unique hosts in the environment that have the field/value pair.
*/
export const useFetchFieldValuePairWithAggregation = ({
highlightedField,
aggregationField,
}: UseFetchFieldValuePairWithAggregationParams): UseFetchFieldValuePairWithAggregationResult => {
const {
services: {
data: { search: searchService },
},
} = useKibana();
const { from, to } = { from: DEFAULT_FROM, to: DEFAULT_TO };
const { name, values } = highlightedField;
const searchRequest = buildSearchRequest(name, values, from, to, aggregationField);
const { data, isLoading, isError } = useQuery(
[QUERY_KEY, name, values, from, to, aggregationField],
() => createFetchData<RawAggregatedDataResponse>(searchService, searchRequest),
{
select: (res) => res.aggregations[AGG_KEY].buckets.length,
keepPreviousData: true,
}
);
return {
loading: isLoading,
error: isError,
count: data || 0,
};
};
/**
* Build the search request for the field/values pair, for a date range from/to.
* The request contains aggregation by aggregationField.
*/
const buildSearchRequest = (
field: string,
values: string[],
from: string,
to: string,
aggregationField: string
): IEsSearchRequest => {
const query = buildEsQuery(
undefined,
[],
[
{
query: {
bool: {
filter: [
{
match: {
[field]: values[0],
},
},
{
range: {
'@timestamp': {
gte: from,
lte: to,
},
},
},
],
},
},
meta: {},
},
]
);
return buildAggregationSearchRequest(aggregationField, AGG_KEY, query);
};

View file

@ -0,0 +1,203 @@
/*
* 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 { buildEsQuery } from '@kbn/es-query';
import type { IEsSearchRequest } from '@kbn/data-plugin/public';
import { useQuery } from '@tanstack/react-query';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { createFetchData } from '../utils/fetch_data';
import { useKibana } from '../../../common/lib/kibana';
const QUERY_KEY = 'useFetchFieldValuePairWithAggregation';
export const FIELD_NAMES_AGG_KEY = 'fieldNames';
export const EVENT_KIND_AGG_KEY = 'eventKind';
export const HOST_NAME_AGG_KEY = 'hostName';
export const USER_NAME_AGG_KEY = 'userName';
export const HOSTS_AGG_KEY = 'hosts';
export const USERS_AGG_KEY = 'users';
export interface AggregationValue {
doc_count: number;
key: string;
}
/**
* Interface for a specific aggregation schema with nested aggregations, used in the prevalence components
*/
export interface RawAggregatedDataResponse {
aggregations: {
[FIELD_NAMES_AGG_KEY]: {
buckets: {
[key: string]: {
eventKind: { buckets: AggregationValue[] };
hostName: { value: number };
userName: { value: number };
};
};
};
[HOSTS_AGG_KEY]: {
value: number;
};
[USERS_AGG_KEY]: {
value: number;
};
};
}
export interface UseFetchPrevalenceParams {
/**
* The highlighted field name and values, already formatted for the query
* */
highlightedFieldsFilters: Record<string, QueryDslQueryContainer>;
/**
* The from and to values for the query
*/
interval: { from: string; to: string };
}
export interface UseFetchPrevalenceResult {
/**
* Returns true if data is being loaded
*/
loading: boolean;
/**
* Returns true if fetching data has errored out
*/
error: boolean;
/**
* Returns the prevalence raw aggregated data
*/
data: RawAggregatedDataResponse | undefined;
}
/**
* Hook to fetch prevalence data for both the PrevalenceDetails and PrevalenceOverview components.
* Here's how we fetch the data:
* - the query filter is just limiting to the from/to datetime range
* - we do 3 top level aggregations:
* - one for each field/value pairs
* - one for all the unique hosts in the environment
* - one for all the unique users in the environment
* For each field/value pair aggregated, we do 3 sub aggregations:
* - one to retrieve the unique hosts which have the field/value pair
* - one to retrieve the unique users which have the field/value pair
* - one to retrieve how many documents are of the different type of event.kind
* All of these values are then used to calculate the alert count, document count, host and user prevalence values.
*/
export const useFetchPrevalence = ({
highlightedFieldsFilters,
interval: { from, to },
}: UseFetchPrevalenceParams): UseFetchPrevalenceResult => {
const {
services: {
data: { search: searchService },
},
} = useKibana();
const searchRequest = buildSearchRequest(highlightedFieldsFilters, from, to);
const { data, isLoading, isError } = useQuery(
[QUERY_KEY, highlightedFieldsFilters, from, to],
() => createFetchData<RawAggregatedDataResponse>(searchService, searchRequest)
);
return {
loading: isLoading,
error: isError,
data,
};
};
/**
* Build the search request for the field/values pair, for a date range from/to.
* The request contains aggregation by aggregationField.
*/
const buildSearchRequest = (
highlightedFieldsFilters: Record<string, QueryDslQueryContainer>,
from: string,
to: string
): IEsSearchRequest => {
const query = buildEsQuery(
undefined,
[],
[
{
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: from,
lte: to,
},
},
},
],
},
},
meta: {},
},
]
);
return buildAggregationSearchRequest(query, highlightedFieldsFilters);
};
const buildAggregationSearchRequest = (
query: QueryDslQueryContainer,
highlightedFieldsFilters: Record<string, QueryDslQueryContainer>
): IEsSearchRequest => ({
params: {
body: {
query,
aggs: {
// with this aggregation, we can in a single call retrieve all the values for each field/value pairs
[FIELD_NAMES_AGG_KEY]: {
filters: {
filters: highlightedFieldsFilters,
},
aggs: {
// this sub aggregation allows us to retrieve all the hosts which have the field/value pair
[HOST_NAME_AGG_KEY]: {
cardinality: {
field: 'host.name',
},
},
// this sub aggregation allows us to retrieve all the users which have the field/value pair
[USER_NAME_AGG_KEY]: {
cardinality: {
field: 'user.name',
},
},
// we use this sub aggregation to differentiate between alerts (event.kind === 'signal') and documents (event.kind !== 'signal')
[EVENT_KIND_AGG_KEY]: {
terms: {
field: 'event.kind',
size: 10, // there should be only 8 different value for the event.kind field
},
},
},
},
// retrieve all the unique hosts in the environment
[HOSTS_AGG_KEY]: {
cardinality: {
field: 'host.name',
},
},
// retrieve all the unique users in the environment
[USERS_AGG_KEY]: {
cardinality: {
field: 'user.name',
},
},
},
size: 0,
},
},
});

View file

@ -1,72 +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 '@tanstack/react-query';
import type { RenderHookResult } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react-hooks';
import { useKibana } from '../../../common/lib/kibana';
import type {
UseFetchUniqueByFieldParams,
UseFetchUniqueByFieldValue,
} from './use_fetch_unique_by_field';
import { useFetchUniqueByField } from './use_fetch_unique_by_field';
jest.mock('@tanstack/react-query');
jest.mock('../../../common/lib/kibana');
const field = 'host.name';
describe('useFetchUniqueByField', () => {
let hookResult: RenderHookResult<UseFetchUniqueByFieldParams, UseFetchUniqueByFieldValue>;
(useKibana as jest.Mock).mockReturnValue({
services: {
data: { search: jest.fn() },
},
});
it('should return loading true while data is being fetched', () => {
(useQuery as jest.Mock).mockReturnValue({
isLoading: true,
isError: false,
data: 0,
});
hookResult = renderHook(() => useFetchUniqueByField({ field }));
expect(hookResult.result.current.loading).toBeTruthy();
expect(hookResult.result.current.error).toBeFalsy();
expect(hookResult.result.current.count).toBe(0);
});
it('should return error true when data fetching has errored out', () => {
(useQuery as jest.Mock).mockReturnValue({
isLoading: false,
isError: true,
data: 0,
});
hookResult = renderHook(() => useFetchUniqueByField({ field }));
expect(hookResult.result.current.loading).toBeFalsy();
expect(hookResult.result.current.error).toBeTruthy();
expect(hookResult.result.current.count).toBe(0);
});
it('should return count on success', () => {
(useQuery as jest.Mock).mockReturnValue({
isLoading: false,
isError: false,
data: 1,
});
hookResult = renderHook(() => useFetchUniqueByField({ field }));
expect(hookResult.result.current.loading).toBeFalsy();
expect(hookResult.result.current.error).toBeFalsy();
expect(hookResult.result.current.count).toBe(1);
});
});

View file

@ -1,67 +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 '@tanstack/react-query';
import type { IEsSearchRequest } from '@kbn/data-plugin/common';
import type { RawAggregatedDataResponse } from '../utils/fetch_data';
import { AGG_KEY, createFetchData } from '../utils/fetch_data';
import { useKibana } from '../../../common/lib/kibana';
import { buildAggregationSearchRequest } from '../utils/build_requests';
const QUERY_KEY = 'useFetchUniqueByField';
export interface UseFetchUniqueByFieldParams {
/**
* Field to aggregate by
*/
field: string;
}
export interface UseFetchUniqueByFieldValue {
/**
* Returns true if data is being loaded
*/
loading: boolean;
/**
* Returns true if fetching data has errored out
*/
error: boolean;
/**
* Number of unique document by field found in the environment
*/
count: number;
}
/**
* Hook to retrieve all unique documents by field in the environment, using ReactQuery.
*
* For example, passing 'host.name' via the field props will return the number of unique hosts in the environment.
*/
export const useFetchUniqueByField = ({
field,
}: UseFetchUniqueByFieldParams): UseFetchUniqueByFieldValue => {
const {
services: {
data: { search: searchService },
},
} = useKibana();
const searchRequest: IEsSearchRequest = buildAggregationSearchRequest(field, AGG_KEY);
const { data, isLoading, isError } = useQuery(
[QUERY_KEY, field],
() => createFetchData<RawAggregatedDataResponse>(searchService, searchRequest),
{
select: (res) => res.aggregations[AGG_KEY].buckets.length,
keepPreviousData: true,
}
);
return {
loading: isLoading,
error: isError,
count: data || 0,
};
};

View file

@ -0,0 +1,24 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { mockDataFormattedForFieldBrowser } from '../mocks/mock_context';
import { useHighlightedFields } from './use_highlighted_fields';
const dataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser;
describe('useHighlightedFields', () => {
it('should return data', () => {
const hookResult = renderHook(() => useHighlightedFields({ dataFormattedForFieldBrowser }));
expect(hookResult.result.current).toEqual({
'kibana.alert.rule.type': {
values: ['query'],
},
});
});
});

View file

@ -26,19 +26,15 @@ export interface UseHighlightedFieldsParams {
}
export interface UseHighlightedFieldsResult {
/**
* Highlighted field name (label or if null, falls back to id)
*/
field: string;
description: {
[fieldName: string]: {
/**
* Highlighted field name (overrideField or if null, falls back to id)
* If the field has a custom override
*/
field: string;
overrideField?: string;
/**
* Highlighted field value
* Values for the field
*/
values: string[] | null | undefined;
values: string[];
};
}
@ -48,8 +44,8 @@ export interface UseHighlightedFieldsResult {
export const useHighlightedFields = ({
dataFormattedForFieldBrowser,
investigationFields,
}: UseHighlightedFieldsParams): UseHighlightedFieldsResult[] => {
if (!dataFormattedForFieldBrowser) return [];
}: UseHighlightedFieldsParams): UseHighlightedFieldsResult => {
if (!dataFormattedForFieldBrowser) return {};
const eventCategories = getEventCategoriesFromData(dataFormattedForFieldBrowser);
@ -78,19 +74,26 @@ export const useHighlightedFields = ({
highlightedFieldsOverride: investigationFields ?? [],
});
return tableFields.reduce<UseHighlightedFieldsResult[]>((acc, field) => {
return tableFields.reduce<UseHighlightedFieldsResult>((acc, field) => {
const item = dataFormattedForFieldBrowser.find(
(data) => data.field === field.id || (field.legacyId && data.field === field.legacyId)
);
if (!item || isEmpty(item.values)) {
if (!item) {
return acc;
}
// If we found the data by its legacy id we swap the ids to display the correct one
// if there aren't any values we can skip this highlighted field
const fieldValues = item.values;
if (!fieldValues || isEmpty(fieldValues)) {
return acc;
}
// if we found the data by its legacy id we swap the ids to display the correct one
if (item.field === field.legacyId) {
field.id = field.legacyId;
}
// if the field is agent.id and the event is not an endpoint event we skip it
if (
field.id === 'agent.id' &&
!isAlertFromEndpointEvent({ data: dataFormattedForFieldBrowser })
@ -98,15 +101,12 @@ export const useHighlightedFields = ({
return acc;
}
return [
return {
...acc,
{
field: field.label ?? field.id,
description: {
field: field.overrideField ?? field.id,
values: item.values,
},
[field.id]: {
...(field.overrideField && { overrideField: field.overrideField }),
values: fieldValues,
},
];
}, []);
};
}, {});
};

View file

@ -0,0 +1,135 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { usePrevalence } from './use_prevalence';
import { mockDataFormattedForFieldBrowser } from '../mocks/mock_context';
import { useHighlightedFields } from './use_highlighted_fields';
import {
FIELD_NAMES_AGG_KEY,
HOSTS_AGG_KEY,
useFetchPrevalence,
USERS_AGG_KEY,
} from './use_fetch_prevalence';
jest.mock('./use_highlighted_fields');
jest.mock('./use_fetch_prevalence');
const interval = {
from: 'now-30d',
to: 'now',
};
const dataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser;
const investigationFields = ['host.name', 'user.name'];
describe('usePrevalence', () => {
it('should return loading true', () => {
(useHighlightedFields as jest.Mock).mockReturnValue({
'host.name': {
values: ['host-1'],
},
});
(useFetchPrevalence as jest.Mock).mockReturnValue({
loading: true,
error: false,
data: undefined,
});
const hookResult = renderHook(() =>
usePrevalence({ interval, dataFormattedForFieldBrowser, investigationFields })
);
expect(hookResult.result.current.loading).toEqual(true);
expect(hookResult.result.current.error).toEqual(false);
expect(hookResult.result.current.data).toEqual([]);
});
it('should return error true', () => {
(useHighlightedFields as jest.Mock).mockReturnValue({
'host.name': {
values: ['host-1'],
},
});
(useFetchPrevalence as jest.Mock).mockReturnValue({
loading: false,
error: true,
data: undefined,
});
const hookResult = renderHook(() =>
usePrevalence({ interval, dataFormattedForFieldBrowser, investigationFields })
);
expect(hookResult.result.current.loading).toEqual(false);
expect(hookResult.result.current.error).toEqual(true);
expect(hookResult.result.current.data).toEqual([]);
});
it('should return data', () => {
(useHighlightedFields as jest.Mock).mockReturnValue({
'host.name': {
values: ['host-1'],
},
});
(useFetchPrevalence as jest.Mock).mockReturnValue({
loading: false,
error: false,
data: {
aggregations: {
[FIELD_NAMES_AGG_KEY]: {
buckets: {
'host.name': {
eventKind: {
buckets: [
{
key: 'signal',
doc_count: 1,
},
{
key: 'event',
doc_count: 1,
},
],
},
hostName: {
value: 10,
},
userName: {
value: 20,
},
},
},
},
[HOSTS_AGG_KEY]: {
value: 100,
},
[USERS_AGG_KEY]: {
value: 200,
},
},
},
});
const hookResult = renderHook(() =>
usePrevalence({ interval, dataFormattedForFieldBrowser, investigationFields })
);
expect(hookResult.result.current.loading).toEqual(false);
expect(hookResult.result.current.error).toEqual(false);
expect(hookResult.result.current.data).toEqual([
{
field: 'host.name',
value: 'host-1',
alertCount: 1,
docCount: 1,
hostPrevalence: 0.1,
userPrevalence: 0.1,
},
]);
});
});

View file

@ -0,0 +1,148 @@
/*
* 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 type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { isArray } from 'lodash/fp';
import { useMemo } from 'react';
import { useHighlightedFields } from './use_highlighted_fields';
import { convertHighlightedFieldsToPrevalenceFilters } from '../utils/highlighted_fields_helpers';
import type { AggregationValue } from './use_fetch_prevalence';
import {
EVENT_KIND_AGG_KEY,
FIELD_NAMES_AGG_KEY,
HOST_NAME_AGG_KEY,
HOSTS_AGG_KEY,
useFetchPrevalence,
USER_NAME_AGG_KEY,
USERS_AGG_KEY,
} from './use_fetch_prevalence';
import { EventKind } from '../constants/event_kinds';
export interface PrevalenceData {
field: string;
value: string;
alertCount: number;
docCount: number;
hostPrevalence: number;
userPrevalence: number;
}
export interface UsePrevalenceParams {
/**
* The from and to values for the query
*/
interval: { from: string; to: string };
/**
* An array of field objects with category and value
*/
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[] | null;
/**
* User defined fields to highlight (defined on the rule)
*/
investigationFields?: string[];
}
export interface UsePrevalenceResult {
/**
* Returns true if data is being loaded
*/
loading: boolean;
/**
* Returns true if fetching data has errored out
*/
error: boolean;
/**
* Returns the prevalence data formatted for the EuiInMemoryTable component
*/
data: PrevalenceData[];
}
/**
* Hook to fetch the prevalence data, then prepares the data to be consumed by the EuiInMemoryTable component
* in the PrevalenceDetails component
*/
export const usePrevalence = ({
interval,
dataFormattedForFieldBrowser,
investigationFields,
}: UsePrevalenceParams): UsePrevalenceResult => {
const highlightedFields = useHighlightedFields({
dataFormattedForFieldBrowser,
investigationFields,
});
const highlightedFieldsFilters = useMemo(
() => convertHighlightedFieldsToPrevalenceFilters(highlightedFields),
[highlightedFields]
);
const { data, loading, error } = useFetchPrevalence({ highlightedFieldsFilters, interval });
const items: PrevalenceData[] = [];
if (data && data.aggregations) {
// total number of unique hosts in the environment
const uniqueHostsInEnvironment = data.aggregations[HOSTS_AGG_KEY].value;
// total number of unique users in the environment
const uniqueUsersInEnvironment = data.aggregations[USERS_AGG_KEY].value;
const fieldNames = Object.keys(data.aggregations[FIELD_NAMES_AGG_KEY].buckets);
fieldNames.forEach((fieldName: string) => {
const fieldValue = highlightedFields[fieldName].values;
// retrieves the number of signals for the current field/value pair
const alertCount =
data.aggregations[FIELD_NAMES_AGG_KEY].buckets[fieldName][EVENT_KIND_AGG_KEY].buckets.find(
(aggregationValue: AggregationValue) => aggregationValue.key === EventKind.signal
)?.doc_count || 0;
// calculate the number of documents (non-signal) for the current field/value pair
let docCount = 0;
data.aggregations[FIELD_NAMES_AGG_KEY].buckets[fieldName][EVENT_KIND_AGG_KEY].buckets.reduce(
(acc, curr) => {
if (curr.key !== EventKind.signal) {
docCount += curr.doc_count;
}
return acc;
},
docCount
);
// number of unique hosts in which the current field/value pair is present
const uniqueHostsForCurrentFieldValuePair =
data.aggregations[FIELD_NAMES_AGG_KEY].buckets[fieldName][HOST_NAME_AGG_KEY].value;
// number of unique users in which the current field/value pair is present
const uniqueUsersForCurrentFieldValuePair =
data.aggregations[FIELD_NAMES_AGG_KEY].buckets[fieldName][USER_NAME_AGG_KEY].value;
// calculate host prevalence
const hostPrevalence = uniqueHostsInEnvironment
? uniqueHostsForCurrentFieldValuePair / uniqueHostsInEnvironment
: 0;
// calculate user prevalence
const userPrevalence = uniqueUsersInEnvironment
? uniqueUsersForCurrentFieldValuePair / uniqueUsersInEnvironment
: 0;
items.push({
field: fieldName,
value: isArray(fieldValue) ? fieldValue[0] : fieldValue,
alertCount,
docCount,
hostPrevalence,
userPrevalence,
});
});
}
return {
loading,
error,
data: items,
};
};

View file

@ -88,4 +88,18 @@ export const mockDataFormattedForFieldBrowser = [
originalValue: ['process-entity_id'],
isObjectArray: false,
},
{
category: 'event',
field: 'event.category',
values: ['registry'],
originalValue: ['registry'],
isObjectArray: false,
},
{
category: 'kibana',
field: 'kibana.alert.rule.type',
values: ['query'],
originalValue: ['query'],
isObjectArray: false,
},
];

View file

@ -8,28 +8,6 @@
import type { IEsSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common';
import type { ISearchStart } from '@kbn/data-plugin/public';
export const AGG_KEY = 'aggregation';
/**
* Interface for aggregation responses
*/
export interface RawAggregatedDataResponse {
aggregations: {
[AGG_KEY]: {
buckets: unknown[];
};
};
}
/**
* Interface for non-aggregated responses
*/
export interface RawResponse {
hits: {
total: number;
};
}
/**
* Reusable method that returns a promise wrapping the search functionality of Kibana search service
*/

View file

@ -0,0 +1,65 @@
/*
* 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 {
convertHighlightedFieldsToPrevalenceFilters,
convertHighlightedFieldsToTableRow,
} from './highlighted_fields_helpers';
describe('convertHighlightedFieldsToTableRow', () => {
it('should convert highlighted fields to a table row', () => {
const highlightedFields = {
'host.name': {
values: ['host-1'],
},
};
expect(convertHighlightedFieldsToTableRow(highlightedFields)).toEqual([
{
field: 'host.name',
description: {
field: 'host.name',
values: ['host-1'],
},
},
]);
});
it('should convert take override name over default name', () => {
const highlightedFields = {
'host.name': {
overrideField: 'host.name-override',
values: ['host-1'],
},
};
expect(convertHighlightedFieldsToTableRow(highlightedFields)).toEqual([
{
field: 'host.name-override',
description: {
field: 'host.name-override',
values: ['host-1'],
},
},
]);
});
});
describe('convertHighlightedFieldsToPrevalenceFilters', () => {
it('should convert highlighted fields to prevalence filters', () => {
const highlightedFields = {
'host.name': {
values: ['host-1'],
},
'user.name': {
values: ['user-1'],
},
};
expect(convertHighlightedFieldsToPrevalenceFilters(highlightedFields)).toEqual({
'host.name': { match: { 'host.name': 'host-1' } },
'user.name': { match: { 'user.name': 'user-1' } },
});
});
});

View file

@ -0,0 +1,51 @@
/*
* 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 type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import type { UseHighlightedFieldsResult } from '../hooks/use_highlighted_fields';
import type { HighlightedFieldsTableRow } from '../../right/components/highlighted_fields';
/**
* Converts the highlighted fields to a format that can be consumed by the HighlightedFields component
* @param highlightedFields
*/
export const convertHighlightedFieldsToTableRow = (
highlightedFields: UseHighlightedFieldsResult
): HighlightedFieldsTableRow[] => {
const fieldNames = Object.keys(highlightedFields);
return fieldNames.map((fieldName) => {
const values = highlightedFields[fieldName].values;
const overrideFieldName = highlightedFields[fieldName].overrideField;
const field = overrideFieldName ? overrideFieldName : fieldName;
return {
field,
description: {
field,
values,
},
};
});
};
/**
* Converts the highlighted fields to a format that can be consumed by the prevalence query
* @param highlightedFields
*/
export const convertHighlightedFieldsToPrevalenceFilters = (
highlightedFields: UseHighlightedFieldsResult
): Record<string, QueryDslQueryContainer> => {
const fieldNames = Object.keys(highlightedFields);
return fieldNames.reduce((acc, curr) => {
const values = highlightedFields[curr].values;
return {
...acc,
[curr]: { match: { [curr]: Array.isArray(values) ? values[0] : values } },
};
}, []) as unknown as Record<string, QueryDslQueryContainer>;
};

View file

@ -33352,10 +33352,6 @@
"xpack.securitySolution.flyout.prevalenceErrorMessage": "prévalence",
"xpack.securitySolution.flyout.prevalenceTableAlertCountColumnTitle": "Nombre d'alertes",
"xpack.securitySolution.flyout.prevalenceTableDocCountColumnTitle": "Compte du document",
"xpack.securitySolution.flyout.prevalenceTableHostPrevalenceColumnTitle": "Prévalence de lhôte",
"xpack.securitySolution.flyout.prevalenceTableNameColumnTitle": "Nom",
"xpack.securitySolution.flyout.prevalenceTableTypeColumnTitle": "Type",
"xpack.securitySolution.flyout.prevalenceTableUserPrevalenceColumnTitle": "Prévalence de lutilisateur",
"xpack.securitySolution.flyout.response.empty": "Il ny a pas dactions de réponse définies pour cet évènement.",
"xpack.securitySolution.flyout.response.title": "Réponses",
"xpack.securitySolution.flyout.sessionViewErrorMessage": "vue de session",

View file

@ -33351,10 +33351,6 @@
"xpack.securitySolution.flyout.prevalenceErrorMessage": "発生率",
"xpack.securitySolution.flyout.prevalenceTableAlertCountColumnTitle": "アラート件数",
"xpack.securitySolution.flyout.prevalenceTableDocCountColumnTitle": "ドキュメントカウント",
"xpack.securitySolution.flyout.prevalenceTableHostPrevalenceColumnTitle": "ホスト発生率",
"xpack.securitySolution.flyout.prevalenceTableNameColumnTitle": "名前",
"xpack.securitySolution.flyout.prevalenceTableTypeColumnTitle": "型",
"xpack.securitySolution.flyout.prevalenceTableUserPrevalenceColumnTitle": "ユーザー発生率",
"xpack.securitySolution.flyout.response.empty": "このイベントに対する対応アクションは定義されていません。",
"xpack.securitySolution.flyout.response.title": "対応",
"xpack.securitySolution.flyout.sessionViewErrorMessage": "セッションビュー",

View file

@ -33347,10 +33347,6 @@
"xpack.securitySolution.flyout.prevalenceErrorMessage": "普及率",
"xpack.securitySolution.flyout.prevalenceTableAlertCountColumnTitle": "告警计数",
"xpack.securitySolution.flyout.prevalenceTableDocCountColumnTitle": "文档计数",
"xpack.securitySolution.flyout.prevalenceTableHostPrevalenceColumnTitle": "主机普及率",
"xpack.securitySolution.flyout.prevalenceTableNameColumnTitle": "名称",
"xpack.securitySolution.flyout.prevalenceTableTypeColumnTitle": "类型",
"xpack.securitySolution.flyout.prevalenceTableUserPrevalenceColumnTitle": "用户普及率",
"xpack.securitySolution.flyout.response.empty": "没有为此事件定义响应操作。",
"xpack.securitySolution.flyout.response.title": "响应",
"xpack.securitySolution.flyout.sessionViewErrorMessage": "会话视图",

View file

@ -23,6 +23,7 @@ import {
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_HOST_PREVALENCE_CELL,
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_USER_PREVALENCE_CELL,
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE,
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_DATE_PICKER,
} from '../../../../screens/expandable_flyout/alert_details_left_panel_prevalence_tab';
import { cleanKibana } from '../../../../tasks/common';
import { login, visit } from '../../../../tasks/login';
@ -53,6 +54,8 @@ describe('Alert details expandable flyout left panel prevalence', () => {
.should('be.visible')
.and('have.text', 'Prevalence');
cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_DATE_PICKER).should('be.visible');
cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE).should('be.visible');
cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_TYPE_CELL)
.should('contain.text', 'host.name')

View file

@ -9,10 +9,11 @@ import {
PREVALENCE_DETAILS_TABLE_ALERT_COUNT_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_DOC_COUNT_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_NAME_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_VALUE_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_TEST_ID,
PREVALENCE_DETAILS_TABLE_TYPE_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_FIELD_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID,
PREVALENCE_DETAILS_DATE_PICKER_TEST_ID,
} from '@kbn/security-solution-plugin/public/flyout/left/components/test_ids';
import { INSIGHTS_TAB_PREVALENCE_BUTTON_TEST_ID } from '@kbn/security-solution-plugin/public/flyout/left/tabs/test_ids';
import { getDataTestSubjectSelector } from '../../helpers/common';
@ -20,13 +21,15 @@ import { getDataTestSubjectSelector } from '../../helpers/common';
export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_BUTTON = getDataTestSubjectSelector(
INSIGHTS_TAB_PREVALENCE_BUTTON_TEST_ID
);
export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_DATE_PICKER =
getDataTestSubjectSelector(PREVALENCE_DETAILS_DATE_PICKER_TEST_ID);
export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE = getDataTestSubjectSelector(
PREVALENCE_DETAILS_TABLE_TEST_ID
);
export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_TYPE_CELL =
getDataTestSubjectSelector(PREVALENCE_DETAILS_TABLE_TYPE_CELL_TEST_ID);
getDataTestSubjectSelector(PREVALENCE_DETAILS_TABLE_FIELD_CELL_TEST_ID);
export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_NAME_CELL =
getDataTestSubjectSelector(PREVALENCE_DETAILS_TABLE_NAME_CELL_TEST_ID);
getDataTestSubjectSelector(PREVALENCE_DETAILS_TABLE_VALUE_CELL_TEST_ID);
export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_ALERT_COUNT_CELL =
getDataTestSubjectSelector(PREVALENCE_DETAILS_TABLE_ALERT_COUNT_CELL_TEST_ID);
export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_DOC_COUNT_CELL =

View file

@ -6,7 +6,10 @@
*/
import { JSON_TAB_CONTENT_TEST_ID } from '@kbn/security-solution-plugin/public/flyout/right/tabs/test_ids';
import { RIGHT_SECTION } from '@kbn/expandable-flyout/src/components/test_ids';
import { getDataTestSubjectSelector } from '../../helpers/common';
export const DOCUMENT_DETAILS_FLYOUT_RIGHT_PANEL_CONTENT =
getDataTestSubjectSelector(RIGHT_SECTION);
export const DOCUMENT_DETAILS_FLYOUT_JSON_TAB_CONTENT =
getDataTestSubjectSelector(JSON_TAB_CONTENT_TEST_ID);

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import { getClassSelector } from '../../helpers/common';
import { DOCUMENT_DETAILS_FLYOUT_RIGHT_PANEL_CONTENT } from '../../screens/expandable_flyout/alert_details_right_panel_json_tab';
/**
* Scroll to x-y positions within the right section of the document details expandable flyout
* // TODO revisit this as it seems very fragile: the first element found is the timeline flyout, which isn't visible but still exist in the DOM
*/
export const scrollWithinDocumentDetailsExpandableFlyoutRightSection = (x: number, y: number) =>
cy.get(getClassSelector('euiFlyout')).last().scrollTo(x, y);
cy.get(DOCUMENT_DETAILS_FLYOUT_RIGHT_PANEL_CONTENT).last().scrollTo(x, y);

View file

@ -43,5 +43,6 @@
"@kbn/fleet-plugin",
"@kbn/cases-components",
"@kbn/security-solution-plugin",
"@kbn/expandable-flyout",
]
}