[Security Solution] add prevalence expanded section to expandable flyout (#158606)

This commit is contained in:
Philippe Oberti 2023-06-13 17:17:11 -05:00 committed by GitHub
parent eaf2d33c59
commit 39c68b52b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1486 additions and 457 deletions

View file

@ -5,24 +5,33 @@
* 2.0.
*/
import { createRule } from '../../../../tasks/api_calls/rules';
import { getNewRule } from '../../../../objects/rule';
import { DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_BUTTON } from '../../../../screens/expandable_flyout/alert_details_left_panel_prevalence_tab';
import {
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB,
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_BUTTON_GROUP,
} from '../../../../screens/expandable_flyout/alert_details_left_panel';
import { openPrevalenceTab } from '../../../../tasks/expandable_flyout/alert_details_left_panel_prevalence_tab';
import { openInsightsTab } from '../../../../tasks/expandable_flyout/alert_details_left_panel';
import { expandDocumentDetailsExpandableFlyoutLeftSection } from '../../../../tasks/expandable_flyout/alert_details_right_panel';
import { expandFirstAlertExpandableFlyout } from '../../../../tasks/expandable_flyout/common';
import {
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB,
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_BUTTON_GROUP,
} from '../../../../screens/expandable_flyout/alert_details_left_panel';
import {
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_ALERT_COUNT_CELL,
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_BUTTON,
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_NAME_CELL,
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_TYPE_CELL,
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_DOC_COUNT_CELL,
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,
} from '../../../../screens/expandable_flyout/alert_details_left_panel_prevalence_tab';
import { cleanKibana } from '../../../../tasks/common';
import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule';
import { login, visit } from '../../../../tasks/login';
import { createRule } from '../../../../tasks/api_calls/rules';
import { getNewRule } from '../../../../objects/rule';
import { ALERTS_URL } from '../../../../urls/navigation';
import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule';
describe(
'Expandable flyout left panel prevalence',
'Alert details expandable flyout left panel prevalence',
{ env: { ftrConfig: { enableExperimental: ['securityFlyoutEnabled'] } } },
() => {
beforeEach(() => {
@ -37,7 +46,7 @@ describe(
openPrevalenceTab();
});
it('should show prevalence table', () => {
it('should display prevalence tab', () => {
cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB)
.should('be.visible')
.and('have.text', 'Insights');
@ -48,7 +57,29 @@ describe(
.should('be.visible')
.and('have.text', 'Prevalence');
// TODO actual test
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')
.and('contain.text', 'user.name');
cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_NAME_CELL)
.should('contain.text', 'siem-kibana')
.and('contain.text', 'test');
cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_ALERT_COUNT_CELL).should(
'contain.text',
2
);
cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_DOC_COUNT_CELL).should(
'contain.text',
2
);
cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_HOST_PREVALENCE_CELL).should(
'contain.text',
100
);
cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_USER_PREVALENCE_CELL).should(
'contain.text',
100
);
});
}
);

View file

@ -5,9 +5,33 @@
* 2.0.
*/
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_TEST_ID,
PREVALENCE_DETAILS_TABLE_TYPE_CELL_TEST_ID,
PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID,
} from '../../../public/flyout/left/components/test_ids';
import { getDataTestSubjectSelector } from '../../helpers/common';
import { INSIGHTS_TAB_PREVALENCE_BUTTON_TEST_ID } from '../../../public/flyout/left/tabs/test_ids';
export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_BUTTON = getDataTestSubjectSelector(
INSIGHTS_TAB_PREVALENCE_BUTTON_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);
export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_NAME_CELL =
getDataTestSubjectSelector(PREVALENCE_DETAILS_TABLE_NAME_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 =
getDataTestSubjectSelector(PREVALENCE_DETAILS_TABLE_DOC_COUNT_CELL_TEST_ID);
export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_HOST_PREVALENCE_CELL =
getDataTestSubjectSelector(PREVALENCE_DETAILS_TABLE_HOST_PREVALENCE_CELL_TEST_ID);
export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_PREVALENCE_TABLE_USER_PREVALENCE_CELL =
getDataTestSubjectSelector(PREVALENCE_DETAILS_TABLE_USER_PREVALENCE_CELL_TEST_ID);

View file

@ -1,76 +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 React from 'react';
import type { Story } from '@storybook/react';
import { Provider as ReduxStoreProvider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { MemoryRouter } from 'react-router-dom';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import { sourcererReducer } from '../../../common/store/sourcerer';
import { inputsReducer } from '../../../common/store/inputs';
import type { LeftPanelContext } from '../context';
import { LeftFlyoutContext } from '../context';
import { AnalyzeGraph } from './analyze_graph';
export default {
component: AnalyzeGraph,
title: 'Flyout/AnalyzeGraph',
};
// TODO to get this working, we need to spent some time getting all the foundation items for storybook
// (ReduxStoreProvider, CellActionsProvider...) similarly to how it was done for the TestProvidersComponent
// see ticket https://github.com/elastic/security-team/issues/6223
// export const Default: Story<void> = () => {
// const contextValue = {
// eventId: 'some_id',
// } as unknown as LeftPanelContext;
//
// return (
// <LeftFlyoutContext.Provider value={contextValue}>
// <AnalyzeGraph />
// </LeftFlyoutContext.Provider>
// );
// };
export const Error: Story<void> = () => {
const store = configureStore({
reducer: {
inputs: inputsReducer,
sourcerer: sourcererReducer,
},
});
const services = {
data: {},
notifications: {
toasts: {
addError: () => {},
addSuccess: () => {},
addWarning: () => {},
remove: () => {},
},
},
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext({ ...services });
const contextValue = {
eventId: null,
} as unknown as LeftPanelContext;
return (
<MemoryRouter>
<ReduxStoreProvider store={store}>
<KibanaReactContext.Provider>
<LeftFlyoutContext.Provider value={contextValue}>
<AnalyzeGraph />
</LeftFlyoutContext.Provider>
</KibanaReactContext.Provider>
</ReduxStoreProvider>
</MemoryRouter>
);
};

View file

@ -8,8 +8,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { LeftPanelContext } from '../context';
import { LeftFlyoutContext } from '../context';
import { LeftPanelContext } from '../context';
import { TestProviders } from '../../../common/mock';
import { AnalyzeGraph } from './analyze_graph';
import { ANALYZE_GRAPH_ERROR_TEST_ID, ANALYZER_GRAPH_TEST_ID } from './test_ids';
@ -39,9 +38,9 @@ describe('<AnalyzeGraph />', () => {
const wrapper = render(
<TestProviders>
<LeftFlyoutContext.Provider value={contextValue}>
<LeftPanelContext.Provider value={contextValue}>
<AnalyzeGraph />
</LeftFlyoutContext.Provider>
</LeftPanelContext.Provider>
</TestProviders>
);
expect(wrapper.getByTestId(ANALYZER_GRAPH_TEST_ID)).toBeInTheDocument();
@ -54,9 +53,9 @@ describe('<AnalyzeGraph />', () => {
const wrapper = render(
<TestProviders>
<LeftFlyoutContext.Provider value={contextValue}>
<LeftPanelContext.Provider value={contextValue}>
<AnalyzeGraph />
</LeftFlyoutContext.Provider>
</LeftPanelContext.Provider>
</TestProviders>
);
expect(wrapper.getByTestId(ANALYZE_GRAPH_ERROR_TEST_ID)).toBeInTheDocument();

View file

@ -8,7 +8,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { LeftFlyoutContext } from '../context';
import { LeftPanelContext } from '../context';
import { TestProviders } from '../../../common/mock';
import { EntitiesDetails } from './entities_details';
import { ENTITIES_DETAILS_TEST_ID, HOST_DETAILS_TEST_ID, USER_DETAILS_TEST_ID } from './test_ids';
@ -35,9 +35,9 @@ describe('<EntitiesDetails />', () => {
it('renders entities details correctly', () => {
const { getByTestId } = render(
<TestProviders>
<LeftFlyoutContext.Provider value={mockContextValue}>
<LeftPanelContext.Provider value={mockContextValue}>
<EntitiesDetails />
</LeftFlyoutContext.Provider>
</LeftPanelContext.Provider>
</TestProviders>
);
expect(getByTestId(ENTITIES_DETAILS_TEST_ID)).toBeInTheDocument();
@ -48,7 +48,7 @@ describe('<EntitiesDetails />', () => {
it('does not render user and host details if user name and host name are not available', () => {
const { queryByTestId } = render(
<TestProviders>
<LeftFlyoutContext.Provider
<LeftPanelContext.Provider
value={{
...mockContextValue,
getFieldsData: (fieldName) =>
@ -56,7 +56,7 @@ describe('<EntitiesDetails />', () => {
}}
>
<EntitiesDetails />
</LeftFlyoutContext.Provider>
</LeftPanelContext.Provider>
</TestProviders>
);
expect(queryByTestId(USER_DETAILS_TEST_ID)).not.toBeInTheDocument();
@ -66,7 +66,7 @@ describe('<EntitiesDetails />', () => {
it('does not render user and host details if @timestamp is not available', () => {
const { queryByTestId } = render(
<TestProviders>
<LeftFlyoutContext.Provider
<LeftPanelContext.Provider
value={{
...mockContextValue,
getFieldsData: (fieldName) => {
@ -82,7 +82,7 @@ describe('<EntitiesDetails />', () => {
}}
>
<EntitiesDetails />
</LeftFlyoutContext.Provider>
</LeftPanelContext.Provider>
</TestProviders>
);
expect(queryByTestId(USER_DETAILS_TEST_ID)).not.toBeInTheDocument();

View file

@ -0,0 +1,82 @@
/*
* 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 { getSummaryRows } from '../../../common/components/event_details/get_alert_summary_rows';
import { LeftPanelContext } from '../context';
import { PrevalenceDetails } from './prevalence_details';
import {
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';
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');
const panelContextValue = {
eventId: 'event id',
indexName: 'indexName',
browserFields: {},
dataFormattedForFieldBrowser: [],
} 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',
},
values: ['value'],
},
};
(getSummaryRows as jest.Mock).mockReturnValue([mockSummaryRow]);
const { getByTestId } = render(
<LeftPanelContext.Provider value={panelContextValue}>
<PrevalenceDetails />
</LeftPanelContext.Provider>
);
expect(getByTestId(PREVALENCE_DETAILS_TABLE_TEST_ID)).toBeInTheDocument();
});
it('should render the error message if no highlighted fields', () => {
jest.mocked(getSummaryRows).mockReturnValue([]);
const { getByTestId } = render(
<LeftPanelContext.Provider value={panelContextValue}>
<PrevalenceDetails />
</LeftPanelContext.Provider>
);
expect(getByTestId(PREVALENCE_DETAILS_TABLE_ERROR_TEST_ID)).toBeInTheDocument();
});
});

View file

@ -5,17 +5,166 @@
* 2.0.
*/
import React from 'react';
import { EuiText } from '@elastic/eui';
import { PREVALENCE_DETAILS_TEST_ID } from './test_ids';
import React, { useMemo } from 'react';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { EuiBasicTable, EuiEmptyPrompt } from '@elastic/eui';
import { ERROR_MESSAGE, ERROR_TITLE } from '../../shared/translations';
import {
PREVALENCE_ERROR_MESSAGE,
PREVALENCE_TABLE_ALERT_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,
} from './translations';
import {
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_USER_PREVALENCE_CELL_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[] };
scopeId: string;
}
export const PREVALENCE_TAB_ID = 'prevalence-details';
const columns: Array<EuiBasicTableColumn<unknown>> = [
{
field: 'type',
name: PREVALENCE_TABLE_TYPE_COLUMN_TITLE,
'data-test-subj': PREVALENCE_DETAILS_TABLE_TYPE_CELL_TEST_ID,
},
{
field: 'name',
name: PREVALENCE_TABLE_NAME_COLUMN_TITLE,
'data-test-subj': PREVALENCE_DETAILS_TABLE_NAME_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}
scopeId={data.scopeId}
type={{
eventKind: EventKind.signal,
include: true,
}}
/>
),
},
{
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}
scopeId={data.scopeId}
type={{
eventKind: EventKind.signal,
exclude: true,
}}
/>
),
},
{
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}
scopeId={data.scopeId}
aggregationField={'host.name'}
/>
),
},
{
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}
scopeId={data.scopeId}
aggregationField={'user.name'}
/>
),
},
];
/**
* Prevalence displayed in the document details expandable flyout left section under the Insights tab
* Prevalence table displayed in the document details expandable flyout left section under the Insights tab
*/
export const PrevalenceDetails: React.FC = () => {
return <EuiText data-test-subj={PREVALENCE_DETAILS_TEST_ID}>{'Prevalence'}</EuiText>;
const { browserFields, dataFormattedForFieldBrowser, eventId, scopeId } = useLeftPanelContext();
const data = useMemo(() => {
const summaryRows = getSummaryRows({
browserFields: browserFields || {},
data: dataFormattedForFieldBrowser || [],
eventId,
scopeId,
isReadOnly: false,
});
const getCellRenderFields = (summaryRow: AlertSummaryRow): PrevalenceDetailsTableCell => ({
highlightedField: {
name: summaryRow.description.data.field,
values: summaryRow.description.values || [],
},
scopeId,
});
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, dataFormattedForFieldBrowser, eventId, scopeId]);
if (!eventId || !dataFormattedForFieldBrowser || !browserFields || !data || data.length === 0) {
return (
<EuiEmptyPrompt
iconType="error"
color="danger"
title={<h2>{ERROR_TITLE(PREVALENCE_ERROR_MESSAGE)}</h2>}
body={<p>{ERROR_MESSAGE(PREVALENCE_ERROR_MESSAGE)}</p>}
data-test-subj={PREVALENCE_DETAILS_TABLE_ERROR_TEST_ID}
/>
);
}
return (
<EuiBasicTable
tableCaption=""
items={data}
columns={columns}
data-test-subj={PREVALENCE_DETAILS_TABLE_TEST_ID}
/>
);
};
PrevalenceDetails.displayName = 'PrevalenceDetails';

View file

@ -0,0 +1,107 @@
/*
* 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 scopeId = 'scopeId';
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}
scopeId={scopeId}
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}
scopeId={scopeId}
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}
scopeId={scopeId}
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}
scopeId={scopeId}
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

@ -0,0 +1,70 @@
/*
* 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';
import { TimelineId } from '../../../../common/types';
export interface PrevalenceDetailsCountCellProps {
/**
* The highlighted field name and values
* */
highlightedField: { name: string; values: string[] };
/**
* The scope id
*/
scopeId: 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,
scopeId,
type,
}) => {
const { loading, error, count } = useFetchFieldValuePairByEventType({
highlightedField,
isActiveTimelines: scopeId === TimelineId.active,
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

@ -0,0 +1,168 @@
/*
* 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 scopeId = 'scopeId';
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}
scopeId={scopeId}
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}
scopeId={scopeId}
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}
scopeId={scopeId}
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}
scopeId={scopeId}
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}
scopeId={scopeId}
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}
scopeId={scopeId}
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

@ -0,0 +1,85 @@
/*
* 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 { TimelineId } from '../../../../common/types';
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 scope id
*/
scopeId: 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,
scopeId,
aggregationField,
}) => {
const {
loading: aggregationLoading,
error: aggregationError,
count: aggregationCount,
} = useFetchFieldValuePairWithAggregation({
highlightedField,
isActiveTimelines: scopeId === TimelineId.active,
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

@ -8,8 +8,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { LeftPanelContext } from '../context';
import { LeftFlyoutContext } from '../context';
import { LeftPanelContext } from '../context';
import { rawEventData, TestProviders } from '../../../common/mock';
import { RESPONSE_DETAILS_TEST_ID, RESPONSE_EMPTY_TEST_ID } from './test_ids';
import { ResponseDetails } from './response_details';
@ -56,12 +55,12 @@ const defaultContextValue = {
dataAsNestedObject: {
_id: 'test',
},
data: rawEventData,
searchHit: rawEventData,
} as unknown as LeftPanelContext;
const contextWithResponseActions = {
...defaultContextValue,
data: {
searchHit: {
...rawEventData,
fields: {
...rawEventData.fields,
@ -80,9 +79,9 @@ const contextWithResponseActions = {
const renderSUT = (contextValue: LeftPanelContext) =>
render(
<TestProviders>
<LeftFlyoutContext.Provider value={contextValue}>
<LeftPanelContext.Provider value={contextValue}>
<ResponseDetails />
</LeftFlyoutContext.Provider>
</LeftPanelContext.Provider>
</TestProviders>
);

View file

@ -36,23 +36,23 @@ const InlineBlock = styled.div`
* Automated response actions results, displayed in the document details expandable flyout left section under the Insights tab, Response tab
*/
export const ResponseDetails: React.FC = () => {
const { data, dataAsNestedObject } = useLeftPanelContext();
const { searchHit, dataAsNestedObject } = useLeftPanelContext();
const endpointResponseActionsEnabled = useIsExperimentalFeatureEnabled(
'endpointResponseActionsEnabled'
);
const expandedEventFieldsObject = data
? (expandDottedObject((data as RawEventData).fields) as ExpandedEventFieldsObject)
const expandedEventFieldsObject = searchHit
? (expandDottedObject((searchHit as RawEventData).fields) as ExpandedEventFieldsObject)
: undefined;
const responseActions =
expandedEventFieldsObject?.kibana?.alert?.rule?.parameters?.[0].response_actions;
const responseActionsView = useResponseActionsView({
rawEventData: data,
rawEventData: searchHit,
ecsData: dataAsNestedObject,
});
const osqueryView = useOsqueryTab({
rawEventData: data,
rawEventData: searchHit,
ecsData: dataAsNestedObject,
});

View file

@ -1,44 +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 React from 'react';
import type { Story } from '@storybook/react';
import { SessionView } from './session_view';
import type { LeftPanelContext } from '../context';
import { LeftFlyoutContext } from '../context';
export default {
component: SessionView,
title: 'Flyout/SessionView',
};
// TODO to get this working, we need to spent some time getting all the foundation items for storybook
// (ReduxStoreProvider, CellActionsProvider...) similarly to how it was done for the TestProvidersComponent
// see ticket https://github.com/elastic/security-team/issues/6223
// export const Default: Story<void> = () => {
// const contextValue = {
// getFieldsData: () => {},
// } as unknown as LeftPanelContext;
//
// return (
// <LeftFlyoutContext.Provider value={contextValue}>
// <SessionView />
// </LeftFlyoutContext.Provider>
// );
// };
export const Error: Story<void> = () => {
const contextValue = {
getFieldsData: () => {},
} as unknown as LeftPanelContext;
return (
<LeftFlyoutContext.Provider value={contextValue}>
<SessionView />
</LeftFlyoutContext.Provider>
);
};

View file

@ -8,8 +8,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { LeftPanelContext } from '../context';
import { LeftFlyoutContext } from '../context';
import { LeftPanelContext } from '../context';
import { TestProviders } from '../../../common/mock';
import { SESSION_VIEW_ERROR_TEST_ID, SESSION_VIEW_TEST_ID } from './test_ids';
import {
@ -56,9 +55,9 @@ describe('<SessionView />', () => {
const wrapper = render(
<TestProviders>
<LeftFlyoutContext.Provider value={contextValue}>
<LeftPanelContext.Provider value={contextValue}>
<SessionView />
</LeftFlyoutContext.Provider>
</LeftPanelContext.Provider>
</TestProviders>
);
expect(wrapper.getByTestId(SESSION_VIEW_TEST_ID)).toBeInTheDocument();
@ -72,9 +71,9 @@ describe('<SessionView />', () => {
const wrapper = render(
<TestProviders>
<LeftFlyoutContext.Provider value={contextValue}>
<LeftPanelContext.Provider value={contextValue}>
<SessionView />
</LeftFlyoutContext.Provider>
</LeftPanelContext.Provider>
</TestProviders>
);
expect(wrapper.getByTestId(SESSION_VIEW_TEST_ID)).toBeInTheDocument();
@ -87,9 +86,9 @@ describe('<SessionView />', () => {
const wrapper = render(
<TestProviders>
<LeftFlyoutContext.Provider value={contextValue}>
<LeftPanelContext.Provider value={contextValue}>
<SessionView />
</LeftFlyoutContext.Provider>
</LeftPanelContext.Provider>
</TestProviders>
);
expect(wrapper.getByTestId(SESSION_VIEW_ERROR_TEST_ID)).toBeInTheDocument();

View file

@ -15,6 +15,35 @@ export const SESSION_VIEW_ERROR_TEST_ID = `${PREFIX}SessionViewError` as const;
/* Insights tab */
/* Prevalence */
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_TABLE_ALERT_COUNT_CELL_TEST_ID =
`${PREFIX}PrevalenceDetailsTableAlertCountCell` as const;
export const PREVALENCE_DETAILS_TABLE_DOC_COUNT_CELL_TEST_ID =
`${PREFIX}PrevalenceDetailsTableDocCountCell` as const;
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;
/* Entities */
export const ENTITIES_DETAILS_TEST_ID = `${PREFIX}EntitiesDetails` as const;
export const USER_DETAILS_TEST_ID = `${PREFIX}UsersDetails` as const;
@ -26,6 +55,7 @@ export const HOST_DETAILS_INFO_TEST_ID = 'host-overview';
export const HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID =
`${PREFIX}HostsDetailsRelatedUsersTable` as const;
export const THREAT_INTELLIGENCE_DETAILS_TEST_ID = `${PREFIX}ThreatIntelligenceDetails` as const;
export const PREVALENCE_DETAILS_TEST_ID = `${PREFIX}PrevalenceDetails` as const;
export const CORRELATIONS_DETAILS_TEST_ID = `${PREFIX}CorrelationsDetails` as const;

View file

@ -8,15 +8,14 @@
import React from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { LeftPanelContext } from '../context';
import { LeftFlyoutContext } from '../context';
import { LeftPanelContext } from '../context';
import { TestProviders } from '../../../common/mock';
import {
THREAT_INTELLIGENCE_DETAILS_ENRICHMENTS_TEST_ID,
THREAT_INTELLIGENCE_DETAILS_SPINNER_TEST_ID,
} from './test_ids';
import { ThreatIntelligenceDetails } from './threat_intelligence_details';
import { useThreatIntelligenceDetails } from './hooks/use_threat_intelligence_details';
import { useThreatIntelligenceDetails } from '../hooks/use_threat_intelligence_details';
jest.mock('../../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../../common/lib/kibana');
@ -32,7 +31,7 @@ jest.mock('../../../common/lib/kibana', () => {
};
});
jest.mock('./hooks/use_threat_intelligence_details');
jest.mock('../hooks/use_threat_intelligence_details');
const defaultContextValue = {
getFieldsData: () => 'id',
@ -42,9 +41,9 @@ const defaultContextValue = {
const renderSUT = (contextValue: LeftPanelContext) =>
render(
<TestProviders>
<LeftFlyoutContext.Provider value={contextValue}>
<LeftPanelContext.Provider value={contextValue}>
<ThreatIntelligenceDetails />
</LeftFlyoutContext.Provider>
</LeftPanelContext.Provider>
</TestProviders>
);

View file

@ -10,7 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer } from '@elasti
import isEmpty from 'lodash/isEmpty';
import { EnrichmentRangePicker } from '../../../common/components/event_details/cti_details/enrichment_range_picker';
import { ThreatDetailsView } from '../../../common/components/event_details/cti_details/threat_details_view';
import { useThreatIntelligenceDetails } from './hooks/use_threat_intelligence_details';
import { useThreatIntelligenceDetails } from '../hooks/use_threat_intelligence_details';
import { THREAT_INTELLIGENCE_DETAILS_SPINNER_TEST_ID } from './test_ids';
export const THREAT_INTELLIGENCE_TAB_ID = 'threat-intelligence-details';

View file

@ -85,6 +85,55 @@ export const RELATED_USERS_TOOL_TIP = i18n.translate(
}
);
export const PREVALENCE_ERROR_MESSAGE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceErrorMessage',
{
defaultMessage: 'prevalence',
}
);
export const PREVALENCE_TABLE_TYPE_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableTypeColumnTitle',
{
defaultMessage: 'Type',
}
);
export const PREVALENCE_TABLE_NAME_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableNameColumnTitle',
{
defaultMessage: 'Name',
}
);
export const PREVALENCE_TABLE_ALERT_COUNT_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableAlertCountColumnTitle',
{
defaultMessage: 'Alert count',
}
);
export const PREVALENCE_TABLE_DOC_COUNT_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableDocCountColumnTitle',
{
defaultMessage: 'Doc count',
}
);
export const PREVALENCE_TABLE_HOST_PREVALENCE_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableHostPrevalenceColumnTitle',
{
defaultMessage: 'Host prevalence',
}
);
export const PREVALENCE_TABLE_USER_PREVALENCE_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.flyout.prevalenceTableUserPrevalenceColumnTitle',
{
defaultMessage: 'User prevalence',
}
);
export const RESPONSE_TITLE = i18n.translate('xpack.securitySolution.flyout.response.title', {
defaultMessage: 'Responses',
});

View file

@ -5,13 +5,14 @@
* 2.0.
*/
import React, { createContext, useContext, useMemo } from 'react';
import type { BrowserFields, TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { css } from '@emotion/react';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import React, { createContext, useContext, useMemo } from 'react';
import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import type { Ecs } from '@kbn/cases-plugin/common';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import type { SearchHit } from '../../../common/search_strategy';
import type { LeftPanelProps } from '.';
import type { GetFieldsData } from '../../common/hooks/use_get_fields_data';
import { useGetFieldsData } from '../../common/hooks/use_get_fields_data';
import { useTimelineEventsDetails } from '../../timelines/containers/details';
import { getAlertIndexAlias } from '../../timelines/components/side_panel/event_details/helpers';
@ -31,20 +32,32 @@ export interface LeftPanelContext {
*/
indexName: string;
/**
* Retrieves searchHit values for the provided field
* Maintain backwards compatibility // TODO remove when possible
*/
getFieldsData: (field: string) => unknown | unknown[];
scopeId: string;
/**
* An object containing fields by type
*/
browserFields: BrowserFields | null;
/**
* An object with top level fields from the ECS object
*/
dataAsNestedObject: Ecs | null;
/**
* An array of field objects with category and value
*/
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[] | null;
data: SearchHit | undefined;
dataAsNestedObject: Ecs | null;
/**
* The actual raw document object
*/
searchHit: SearchHit | undefined;
/**
* Retrieves searchHit values for the provided field
*/
getFieldsData: GetFieldsData;
}
export const LeftFlyoutContext = createContext<LeftPanelContext | undefined>(undefined);
export const LeftPanelContext = createContext<LeftPanelContext | undefined>(undefined);
export type LeftPanelProviderProps = {
/**
@ -53,7 +66,7 @@ export type LeftPanelProviderProps = {
children: React.ReactNode;
} & Partial<LeftPanelProps['params']>;
export const LeftPanelProvider = ({ id, indexName, children }: LeftPanelProviderProps) => {
export const LeftPanelProvider = ({ id, indexName, scopeId, children }: LeftPanelProviderProps) => {
const currentSpaceId = useSpaceId();
const eventIndex = indexName ? getAlertIndexAlias(indexName, currentSpaceId) ?? indexName : '';
const [{ pageName }] = useRouteSpy();
@ -73,17 +86,28 @@ export const LeftPanelProvider = ({ id, indexName, children }: LeftPanelProvider
const contextValue = useMemo(
() =>
id && indexName
id && indexName && scopeId
? {
eventId: id,
indexName,
getFieldsData,
data: searchHit,
dataFormattedForFieldBrowser,
scopeId,
browserFields: sourcererDataView.browserFields,
dataAsNestedObject,
dataFormattedForFieldBrowser,
searchHit,
getFieldsData,
}
: undefined,
[id, indexName, getFieldsData, searchHit, dataFormattedForFieldBrowser, dataAsNestedObject]
[
id,
indexName,
scopeId,
sourcererDataView.browserFields,
dataFormattedForFieldBrowser,
getFieldsData,
dataAsNestedObject,
searchHit,
]
);
if (loading) {
@ -99,11 +123,11 @@ export const LeftPanelProvider = ({ id, indexName, children }: LeftPanelProvider
);
}
return <LeftFlyoutContext.Provider value={contextValue}>{children}</LeftFlyoutContext.Provider>;
return <LeftPanelContext.Provider value={contextValue}>{children}</LeftPanelContext.Provider>;
};
export const useLeftPanelContext = () => {
const contextValue = useContext(LeftFlyoutContext);
const contextValue = useContext(LeftPanelContext);
if (!contextValue) {
throw new Error('LeftPanelContext can only be used within LeftPanelContext provider');

View file

@ -8,24 +8,24 @@
import { useThreatIntelligenceDetails } from './use_threat_intelligence_details';
import { renderHook } from '@testing-library/react-hooks';
import { useTimelineEventsDetails } from '../../../../timelines/containers/details';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
import { useRouteSpy } from '../../../../common/utils/route/use_route_spy';
import { useLeftPanelContext } from '../../context';
import { useInvestigationTimeEnrichment } from '../../../../common/containers/cti/event_enrichment';
import { SecurityPageName } from '../../../../../common/constants';
import type { RouteSpyState } from '../../../../common/utils/route/types';
import { useTimelineEventsDetails } from '../../../timelines/containers/details';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { useRouteSpy } from '../../../common/utils/route/use_route_spy';
import { useLeftPanelContext } from '../context';
import { useInvestigationTimeEnrichment } from '../../../common/containers/cti/event_enrichment';
import { SecurityPageName } from '../../../../common/constants';
import type { RouteSpyState } from '../../../common/utils/route/types';
import {
type GetBasicDataFromDetailsData,
useBasicDataFromDetailsData,
} from '../../../../timelines/components/side_panel/event_details/helpers';
} from '../../../timelines/components/side_panel/event_details/helpers';
jest.mock('../../../../timelines/containers/details');
jest.mock('../../../../common/containers/sourcerer');
jest.mock('../../../../common/utils/route/use_route_spy');
jest.mock('../../context');
jest.mock('../../../../common/containers/cti/event_enrichment');
jest.mock('../../../../timelines/components/side_panel/event_details/helpers');
jest.mock('../../../timelines/containers/details');
jest.mock('../../../common/containers/sourcerer');
jest.mock('../../../common/utils/route/use_route_spy');
jest.mock('../context');
jest.mock('../../../common/containers/cti/event_enrichment');
jest.mock('../../../timelines/components/side_panel/event_details/helpers');
describe('useThreatIntelligenceDetails', () => {
beforeEach(() => {
@ -66,9 +66,11 @@ describe('useThreatIntelligenceDetails', () => {
jest.mocked(useLeftPanelContext).mockReturnValue({
indexName: 'test-index',
eventId: 'test-event-id',
getFieldsData: () => {},
getFieldsData: () => null,
dataFormattedForFieldBrowser: null,
data: {
scopeId: 'test-scope-id',
browserFields: null,
searchHit: {
_id: 'testId',
_index: 'testIndex',
},

View file

@ -6,22 +6,22 @@
*/
import { useMemo } from 'react';
import type { CtiEnrichment, EventFields } from '../../../../../common/search_strategy';
import { useBasicDataFromDetailsData } from '../../../../timelines/components/side_panel/event_details/helpers';
import type { CtiEnrichment, EventFields } from '../../../../common/search_strategy';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import {
filterDuplicateEnrichments,
getEnrichmentFields,
parseExistingEnrichments,
timelineDataToEnrichment,
} from '../../../../common/components/event_details/cti_details/helpers';
import { SecurityPageName } from '../../../../../common/constants';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
} from '../../../common/components/event_details/cti_details/helpers';
import { SecurityPageName } from '../../../../common/constants';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { useInvestigationTimeEnrichment } from '../../../../common/containers/cti/event_enrichment';
import { useTimelineEventsDetails } from '../../../../timelines/containers/details';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
import { useRouteSpy } from '../../../../common/utils/route/use_route_spy';
import { useLeftPanelContext } from '../../context';
import { useInvestigationTimeEnrichment } from '../../../common/containers/cti/event_enrichment';
import { useTimelineEventsDetails } from '../../../timelines/containers/details';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { useRouteSpy } from '../../../common/utils/route/use_route_spy';
import { useLeftPanelContext } from '../context';
export interface ThreatIntelligenceDetailsValue {
enrichments: CtiEnrichment[];

View file

@ -29,12 +29,13 @@ export interface LeftPanelProps extends FlyoutPanel {
params?: {
id: string;
indexName: string;
scopeId: string;
};
}
export const LeftPanel: FC<Partial<LeftPanelProps>> = memo(({ path }) => {
const { openLeftPanel } = useExpandableFlyoutContext();
const { eventId, indexName } = useLeftPanelContext();
const { eventId, indexName, scopeId } = useLeftPanelContext();
const selectedTabId = useMemo(() => {
const defaultTab = tabs[0].id;
@ -49,6 +50,7 @@ export const LeftPanel: FC<Partial<LeftPanelProps>> = memo(({ path }) => {
params: {
id: eventId,
indexName,
scopeId,
},
});
};

View file

@ -36,9 +36,11 @@ export const mockGetFieldsData = (field: string): string[] => {
export const mockContextValue: LeftPanelContext = {
eventId: 'eventId',
indexName: 'index',
getFieldsData: mockGetFieldsData,
scopeId: 'scopeId',
browserFields: null,
dataFormattedForFieldBrowser: null,
data: {
getFieldsData: mockGetFieldsData,
searchHit: {
_id: 'testId',
_index: 'testIndex',
},

View file

@ -143,6 +143,7 @@ describe('<ThreatIntelligenceOverview />', () => {
params: {
id: panelContextValue.eventId,
indexName: panelContextValue.indexName,
scopeId: panelContextValue.scopeId,
},
});
});

View file

@ -33,9 +33,10 @@ export const CorrelationsOverview: React.FC = () => {
params: {
id: eventId,
indexName,
scopeId,
},
});
}, [eventId, openLeftPanel, indexName]);
}, [eventId, openLeftPanel, indexName, scopeId]);
const { loading, error, data } = useCorrelations({
eventId,

View file

@ -22,6 +22,7 @@ describe('<ExpandDetailButton />', () => {
const panelContextValue = {
eventId: 'eventId',
indexName: 'indexName',
scopeId: 'scopeId',
} as unknown as RightPanelContext;
const { getByTestId } = render(
@ -40,6 +41,7 @@ describe('<ExpandDetailButton />', () => {
params: {
id: panelContextValue.eventId,
indexName: panelContextValue.indexName,
scopeId: panelContextValue.scopeId,
},
});
});

View file

@ -7,7 +7,7 @@
import { EuiButtonEmpty } from '@elastic/eui';
import type { FC } from 'react';
import React, { memo } from 'react';
import React, { memo, useCallback } from 'react';
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { COLLAPSE_DETAILS_BUTTON_TEST_ID, EXPAND_DETAILS_BUTTON_TEST_ID } from './test_ids';
import { LeftPanelKey } from '../../left';
@ -21,19 +21,20 @@ export const ExpandDetailButton: FC = memo(() => {
const { closeLeftPanel, openLeftPanel, panels } = useExpandableFlyoutContext();
const isExpanded: boolean = panels.left != null;
const { eventId, indexName } = useRightPanelContext();
const { eventId, indexName, scopeId } = useRightPanelContext();
const expandDetails = () => {
const expandDetails = useCallback(() => {
openLeftPanel({
id: LeftPanelKey,
params: {
id: eventId,
indexName,
scopeId,
},
});
};
}, [eventId, openLeftPanel, indexName, scopeId]);
const collapseDetails = () => closeLeftPanel();
const collapseDetails = useCallback(() => closeLeftPanel(), [closeLeftPanel]);
return isExpanded ? (
<EuiButtonEmpty

View file

@ -15,15 +15,17 @@ import React from 'react';
import { PrevalenceOverview } from './prevalence_overview';
import { usePrevalence } from '../hooks/use_prevalence';
import { PrevalenceOverviewRow } from './prevalence_overview_row';
import { useFetchUniqueHostsWithFieldPair } from '../hooks/use_fetch_unique_hosts_with_field_value_pair';
import { useFetchUniqueHosts } from '../hooks/use_fetch_unique_hosts';
import { useFetchFieldValuePairWithAggregation } from '../../shared/hooks/use_fetch_field_value_pair_with_aggregation';
import { useFetchUniqueByField } from '../../shared/hooks/use_fetch_unique_by_field';
jest.mock('../hooks/use_fetch_unique_hosts_with_field_value_pair');
jest.mock('../hooks/use_fetch_unique_hosts');
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');
const field = 'field';
const values = ['value'];
const highlightedField = {
name: 'field',
values: ['values'],
};
const scopeId = 'scopeId';
const callbackIfNull = jest.fn();
@ -44,12 +46,12 @@ const renderPrevalenceOverview = (contextValue: RightPanelContext) => (
describe('<PrevalenceOverview />', () => {
it('should render PrevalenceOverviewRows', () => {
(useFetchUniqueHostsWithFieldPair as jest.Mock).mockReturnValue({
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 1,
});
(useFetchUniqueHosts as jest.Mock).mockReturnValue({
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 10,
@ -58,8 +60,7 @@ describe('<PrevalenceOverview />', () => {
empty: false,
prevalenceRows: [
<PrevalenceOverviewRow
field={field}
values={values}
highlightedField={highlightedField}
scopeId={scopeId}
callbackIfNull={callbackIfNull}
data-test-subj={'test'}
@ -90,12 +91,12 @@ describe('<PrevalenceOverview />', () => {
});
it('should navigate to left section Insights tab when clicking on button', () => {
(useFetchUniqueHostsWithFieldPair as jest.Mock).mockReturnValue({
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 1,
});
(useFetchUniqueHosts as jest.Mock).mockReturnValue({
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 10,
@ -104,8 +105,7 @@ describe('<PrevalenceOverview />', () => {
empty: false,
prevalenceRows: [
<PrevalenceOverviewRow
field={field}
values={values}
highlightedField={highlightedField}
scopeId={scopeId}
callbackIfNull={callbackIfNull}
data-test-subj={'test'}

View file

@ -8,14 +8,16 @@
import { render } from '@testing-library/react';
import React from 'react';
import { PrevalenceOverviewRow } from './prevalence_overview_row';
import { useFetchUniqueHostsWithFieldPair } from '../hooks/use_fetch_unique_hosts_with_field_value_pair';
import { useFetchUniqueHosts } from '../hooks/use_fetch_unique_hosts';
import { useFetchFieldValuePairWithAggregation } from '../../shared/hooks/use_fetch_field_value_pair_with_aggregation';
import { useFetchUniqueByField } from '../../shared/hooks/use_fetch_unique_by_field';
jest.mock('../hooks/use_fetch_unique_hosts_with_field_value_pair');
jest.mock('../hooks/use_fetch_unique_hosts');
jest.mock('../../shared/hooks/use_fetch_field_value_pair_with_aggregation');
jest.mock('../../shared/hooks/use_fetch_unique_by_field');
const field = 'field';
const values = ['values'];
const highlightedField = {
name: 'field',
values: ['values'],
};
const scopeId = 'scopeId';
const dataTestSubj = 'test';
const iconDataTestSubj = 'testIcon';
@ -25,12 +27,12 @@ const loadingDataTestSubj = 'testLoading';
describe('<PrevalenceOverviewRow />', () => {
it('should display row if prevalence is below or equal threshold', () => {
(useFetchUniqueHostsWithFieldPair as jest.Mock).mockReturnValue({
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 1,
});
(useFetchUniqueHosts as jest.Mock).mockReturnValue({
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 10,
@ -38,27 +40,28 @@ describe('<PrevalenceOverviewRow />', () => {
const { getByTestId, getAllByText, queryByTestId } = render(
<PrevalenceOverviewRow
field={field}
values={values}
highlightedField={highlightedField}
scopeId={scopeId}
callbackIfNull={() => {}}
data-test-subj={dataTestSubj}
/>
);
const { name, values } = highlightedField;
expect(getByTestId(iconDataTestSubj)).toBeInTheDocument();
expect(getByTestId(valueDataTestSubj)).toBeInTheDocument();
expect(getAllByText(`${field}, ${values} is uncommon`)).toHaveLength(1);
expect(getAllByText(`${name}, ${values} is uncommon`)).toHaveLength(1);
expect(queryByTestId(colorDataTestSubj)).not.toBeInTheDocument();
});
it('should not display row if prevalence is higher than threshold', () => {
(useFetchUniqueHostsWithFieldPair as jest.Mock).mockReturnValue({
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 1,
});
(useFetchUniqueHosts as jest.Mock).mockReturnValue({
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 2,
@ -67,8 +70,7 @@ describe('<PrevalenceOverviewRow />', () => {
const { queryAllByAltText } = render(
<PrevalenceOverviewRow
field={field}
values={values}
highlightedField={highlightedField}
scopeId={scopeId}
callbackIfNull={callbackIfNull}
data-test-subj={dataTestSubj}
@ -80,12 +82,12 @@ describe('<PrevalenceOverviewRow />', () => {
});
it('should not display row if error retrieving data', () => {
(useFetchUniqueHostsWithFieldPair as jest.Mock).mockReturnValue({
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
loading: false,
error: true,
count: 0,
});
(useFetchUniqueHosts as jest.Mock).mockReturnValue({
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: true,
count: 0,
@ -94,8 +96,7 @@ describe('<PrevalenceOverviewRow />', () => {
const { queryAllByAltText } = render(
<PrevalenceOverviewRow
field={field}
values={values}
highlightedField={highlightedField}
scopeId={scopeId}
callbackIfNull={callbackIfNull}
data-test-subj={dataTestSubj}
@ -107,12 +108,12 @@ describe('<PrevalenceOverviewRow />', () => {
});
it('should display loading', () => {
(useFetchUniqueHostsWithFieldPair as jest.Mock).mockReturnValue({
(useFetchFieldValuePairWithAggregation as jest.Mock).mockReturnValue({
loading: true,
error: false,
count: 1,
});
(useFetchUniqueHosts as jest.Mock).mockReturnValue({
(useFetchUniqueByField as jest.Mock).mockReturnValue({
loading: false,
error: false,
count: 10,
@ -120,8 +121,7 @@ describe('<PrevalenceOverviewRow />', () => {
const { getByTestId } = render(
<PrevalenceOverviewRow
field={field}
values={values}
highlightedField={highlightedField}
scopeId={scopeId}
callbackIfNull={() => {}}
data-test-subj={dataTestSubj}

View file

@ -8,22 +8,19 @@
import type { VFC } from 'react';
import React from 'react';
import { PREVALENCE_ROW_UNCOMMON } from './translations';
import { useFetchUniqueHostsWithFieldPair } from '../hooks/use_fetch_unique_hosts_with_field_value_pair';
import { useFetchUniqueHosts } from '../hooks/use_fetch_unique_hosts';
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';
import { TimelineId } from '../../../../common/types';
const HOST_FIELD = 'host.name';
const PERCENTAGE_THRESHOLD = 0.1; // we show the prevalence if its value is below 10%
export interface PrevalenceOverviewRowProps {
/**
* Highlighted field
*/
field: string;
/**
* Highlighted field value
*/
values: string[];
* The highlighted field name and values
* */
highlightedField: { name: string; values: string[] };
/**
* Maintain backwards compatibility // TODO remove when possible
*/
@ -44,8 +41,7 @@ export interface PrevalenceOverviewRowProps {
* the row will render null.
*/
export const PrevalenceOverviewRow: VFC<PrevalenceOverviewRowProps> = ({
field,
values,
highlightedField,
scopeId,
callbackIfNull,
'data-test-subj': dataTestSubj,
@ -56,23 +52,25 @@ export const PrevalenceOverviewRow: VFC<PrevalenceOverviewRowProps> = ({
loading: hostsLoading,
error: hostsError,
count: hostsCount,
} = useFetchUniqueHostsWithFieldPair({
field,
values,
} = useFetchFieldValuePairWithAggregation({
highlightedField,
isActiveTimelines,
aggregationField: HOST_FIELD,
});
const {
loading: uniqueHostsLoading,
error: uniqueHostsError,
count: uniqueHostsCount,
} = useFetchUniqueHosts();
} = 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 = `${field}, ${values} ${PREVALENCE_ROW_UNCOMMON}`;
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 =

View file

@ -31,7 +31,7 @@ import { LeftPanelKey, LeftPanelInsightsTabPath } from '../../left';
* and the SummaryPanel component for data rendering.
*/
export const ThreatIntelligenceOverview: FC = () => {
const { eventId, indexName, dataFormattedForFieldBrowser } = useRightPanelContext();
const { eventId, indexName, scopeId, dataFormattedForFieldBrowser } = useRightPanelContext();
const { openLeftPanel } = useExpandableFlyoutContext();
const goToThreatIntelligenceTab = useCallback(() => {
@ -41,9 +41,10 @@ export const ThreatIntelligenceOverview: FC = () => {
params: {
id: eventId,
indexName,
scopeId,
},
});
}, [eventId, openLeftPanel, indexName]);
}, [eventId, openLeftPanel, indexName, scopeId]);
const {
loading: threatIntelLoading,

View file

@ -8,9 +8,9 @@
import type { BrowserFields, TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { css } from '@emotion/react';
import React, { createContext, useContext, useMemo } from 'react';
import type { SearchHit } from '@kbn/es-types';
import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import type { SearchHit } from '../../../common/search_strategy';
import { useTimelineEventsDetails } from '../../timelines/containers/details';
import { getAlertIndexAlias } from '../../timelines/components/side_panel/event_details/helpers';
import { useSpaceId } from '../../common/hooks/use_space_id';
@ -50,9 +50,9 @@ export interface RightPanelContext {
/**
* The actual raw document object
*/
searchHit: SearchHit<object> | undefined;
searchHit: SearchHit | undefined;
/**
*
* Promise to trigger a data refresh
*/
refetchFlyoutData: () => Promise<void>;
/**
@ -101,9 +101,9 @@ export const RightPanelProvider = ({
indexName,
scopeId,
browserFields: sourcererDataView.browserFields,
dataAsNestedObject: dataAsNestedObject as unknown as Ecs,
dataAsNestedObject,
dataFormattedForFieldBrowser,
searchHit: searchHit as SearchHit<object>,
searchHit,
refetchFlyoutData,
getFieldsData,
}

View file

@ -1,81 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import type { IEsSearchRequest } from '@kbn/data-plugin/common';
import { createFetchAggregatedData } from '../utils/fetch_aggregated_data';
import { useKibana } from '../../../common/lib/kibana';
const AGG_KEY = 'uniqueHosts';
const QUERY_KEY = 'useFetchUniqueHosts';
interface RawAggregatedDataResponse {
aggregations: {
[AGG_KEY]: {
buckets: unknown[];
};
};
}
const searchRequest: IEsSearchRequest = {
params: {
body: {
aggs: {
[AGG_KEY]: {
terms: {
field: 'host.name',
size: 1000,
},
},
},
size: 0,
},
},
};
export interface UseUniqueValuesValue {
/**
* Returns true if data is being loaded
*/
loading: boolean;
/**
* Returns true if fetching data has errored out
*/
error: boolean;
/**
* Number of unique hosts found in the environment
*/
count: number;
}
/**
* Hook to retrieve all unique hosts in the environment, using ReactQuery.
* The query uses an aggregation by unique hosts.
*/
export const useFetchUniqueHosts = (): UseUniqueValuesValue => {
const {
services: {
data: { search: searchService },
},
} = useKibana();
const { data, isLoading, isError } = useQuery(
[QUERY_KEY],
() =>
createFetchAggregatedData<RawAggregatedDataResponse>(searchService, searchRequest, AGG_KEY),
{
select: (res) => res.aggregations[AGG_KEY].buckets.length,
keepPreviousData: true,
}
);
return {
loading: isLoading,
error: isError,
count: data || 0,
};
};

View file

@ -70,16 +70,22 @@ export const usePrevalence = ({
const prevalenceRows = useMemo(
() =>
summaryRows.map((row) => (
<PrevalenceOverviewRow
field={row.description.data.field}
values={row.description.values || []}
scopeId={scopeId}
callbackIfNull={() => setCount((prevCount) => prevCount + 1)}
data-test-subj={INSIGHTS_PREVALENCE_TEST_ID}
key={row.description.data.field}
/>
)),
summaryRows.map((row) => {
const highlightedField = {
name: row.description.data.field,
values: row.description.values || [],
};
return (
<PrevalenceOverviewRow
highlightedField={highlightedField}
scopeId={scopeId}
callbackIfNull={() => setCount((prevCount) => prevCount + 1)}
data-test-subj={INSIGHTS_PREVALENCE_TEST_ID}
key={row.description.data.field}
/>
);
}),
[summaryRows, scopeId]
);

View file

@ -9,38 +9,46 @@ 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 {
UseFetchUniqueHostWithFieldPairParams,
UseFetchUniqueHostWithFieldPairResult,
} from './use_fetch_unique_hosts_with_field_value_pair';
import { useFetchUniqueHostsWithFieldPair } from './use_fetch_unique_hosts_with_field_value_pair';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { useGlobalTime } from '../../../common/containers/use_global_time';
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');
jest.mock('../../../common/hooks/use_selector');
jest.mock('../../../common/containers/use_global_time');
const field = 'field';
const values = ['values'];
const highlightedField = {
name: 'field',
values: ['values'],
};
const isActiveTimelines = true;
const type = {
eventKind: EventKind.alert,
include: true,
};
describe('useFetchUniqueHostsWithFieldPair', () => {
describe('useFetchFieldValuePairByEventType', () => {
let hookResult: RenderHookResult<
UseFetchUniqueHostWithFieldPairParams,
UseFetchUniqueHostWithFieldPairResult
UseFetchFieldValuePairByEventTypeParams,
UseFetchFieldValuePairByEventTypeResult
>;
(useKibana as jest.Mock).mockReturnValue({
services: {
data: { search: jest.fn() },
},
});
(useDeepEqualSelector as jest.Mock).mockReturnValue({ to: '', from: '' });
jest.mocked(useDeepEqualSelector).mockReturnValue({ to: '', from: '' });
(useGlobalTime as jest.Mock).mockReturnValue({ to: '', from: '' });
it('should return loading true while data is being fetched', async () => {
it('should return loading true while data is being fetched', () => {
(useQuery as jest.Mock).mockReturnValue({
isLoading: true,
isError: false,
@ -48,7 +56,7 @@ describe('useFetchUniqueHostsWithFieldPair', () => {
});
hookResult = renderHook(() =>
useFetchUniqueHostsWithFieldPair({ field, values, isActiveTimelines })
useFetchFieldValuePairByEventType({ highlightedField, isActiveTimelines, type })
);
expect(hookResult.result.current.loading).toBeTruthy();
@ -56,7 +64,7 @@ describe('useFetchUniqueHostsWithFieldPair', () => {
expect(hookResult.result.current.count).toBe(0);
});
it('should return error true when data fetching has errored out', async () => {
it('should return error true when data fetching has errored out', () => {
(useQuery as jest.Mock).mockReturnValue({
isLoading: false,
isError: true,
@ -64,7 +72,7 @@ describe('useFetchUniqueHostsWithFieldPair', () => {
});
hookResult = renderHook(() =>
useFetchUniqueHostsWithFieldPair({ field, values, isActiveTimelines })
useFetchFieldValuePairByEventType({ highlightedField, isActiveTimelines, type })
);
expect(hookResult.result.current.loading).toBeFalsy();
@ -72,7 +80,7 @@ describe('useFetchUniqueHostsWithFieldPair', () => {
expect(hookResult.result.current.count).toBe(0);
});
it('should return count on success', async () => {
it('should return count on success', () => {
(useQuery as jest.Mock).mockReturnValue({
isLoading: false,
isError: false,
@ -80,7 +88,7 @@ describe('useFetchUniqueHostsWithFieldPair', () => {
});
hookResult = renderHook(() =>
useFetchUniqueHostsWithFieldPair({ field, values, isActiveTimelines })
useFetchFieldValuePairByEventType({ highlightedField, isActiveTimelines, type })
);
expect(hookResult.result.current.loading).toBeFalsy();

View file

@ -0,0 +1,177 @@
/*
* 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 { inputsSelectors } from '../../../common/store';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { useGlobalTime } from '../../../common/containers/use_global_time';
import type { RawResponse } from '../utils/fetch_data';
const QUERY_KEY = 'FetchFieldValuePairByEventType';
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[] };
/**
* True is the current timeline is active ('timeline-1')
*/
isActiveTimelines: boolean;
/**
* 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,
isActiveTimelines,
type,
}: UseFetchFieldValuePairByEventTypeParams): UseFetchFieldValuePairByEventTypeResult => {
const {
services: {
data: { search: searchService },
},
} = useKibana();
const timelineTime = useDeepEqualSelector((state) =>
inputsSelectors.timelineTimeRangeSelector(state)
);
const globalTime = useGlobalTime();
const { to, from } = isActiveTimelines ? timelineTime : globalTime;
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

@ -0,0 +1,104 @@
/*
* 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';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { useGlobalTime } from '../../../common/containers/use_global_time';
jest.mock('@tanstack/react-query');
jest.mock('../../../common/lib/kibana');
jest.mock('../../../common/hooks/use_selector');
jest.mock('../../../common/containers/use_global_time');
const highlightedField = {
name: 'field',
values: ['values'],
};
const isActiveTimelines = true;
const aggregationField = 'aggregationField';
describe('useFetchFieldValuePairWithAggregation', () => {
let hookResult: RenderHookResult<
UseFetchFieldValuePairWithAggregationParams,
UseFetchFieldValuePairWithAggregationResult
>;
(useKibana as jest.Mock).mockReturnValue({
services: {
data: { search: jest.fn() },
},
});
jest.mocked(useDeepEqualSelector).mockReturnValue({ to: '', from: '' });
(useGlobalTime as jest.Mock).mockReturnValue({ to: '', from: '' });
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,
isActiveTimelines,
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,
isActiveTimelines,
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,
isActiveTimelines,
aggregationField,
})
);
expect(hookResult.result.current.loading).toBeFalsy();
expect(hookResult.result.current.error).toBeFalsy();
expect(hookResult.result.current.count).toBe(1);
});
});

View file

@ -6,41 +6,34 @@
*/
import { buildEsQuery } from '@kbn/es-query';
import type { IEsSearchRequest } from '@kbn/data-plugin/public';
import { useQuery } from '@tanstack/react-query';
import type { IEsSearchRequest } from '@kbn/data-plugin/common';
import { createFetchAggregatedData } from '../utils/fetch_aggregated_data';
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';
import { inputsSelectors } from '../../../common/store';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { useGlobalTime } from '../../../common/containers/use_global_time';
const AGG_KEY = 'hostsWithSameFieldValuePair';
const QUERY_KEY = 'useFetchUniqueHostsWithFieldPair';
const QUERY_KEY = 'useFetchFieldValuePairWithAggregation';
interface RawAggregatedDataResponse {
aggregations: {
[AGG_KEY]: {
buckets: unknown[];
};
};
}
export interface UseFetchUniqueHostWithFieldPairParams {
export interface UseFetchFieldValuePairWithAggregationParams {
/**
* Highlighted field
*/
field: string;
/**
* Highlighted field value
*/
values: string[];
* The highlighted field name and values
* */
highlightedField: { name: string; values: string[] };
/**
*
*/
isActiveTimelines: boolean;
/**
* Field to aggregate value by
*/
aggregationField: string;
}
export interface UseFetchUniqueHostWithFieldPairResult {
export interface UseFetchFieldValuePairWithAggregationResult {
/**
* Returns true if data is being loaded
*/
@ -56,14 +49,15 @@ export interface UseFetchUniqueHostWithFieldPairResult {
}
/**
* Hook to retrieve all the unique hosts in the environment that have the field/value pair, using ReactQuery.
* The query uses an aggregation by unique hosts.
* 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 useFetchUniqueHostsWithFieldPair = ({
field,
values,
export const useFetchFieldValuePairWithAggregation = ({
highlightedField,
isActiveTimelines,
}: UseFetchUniqueHostWithFieldPairParams): UseFetchUniqueHostWithFieldPairResult => {
aggregationField,
}: UseFetchFieldValuePairWithAggregationParams): UseFetchFieldValuePairWithAggregationResult => {
const {
services: {
data: { search: searchService },
@ -76,12 +70,13 @@ export const useFetchUniqueHostsWithFieldPair = ({
const globalTime = useGlobalTime();
const { to, from } = isActiveTimelines ? timelineTime : globalTime;
const searchRequest = buildSearchRequest(field, values, from, to);
const { name, values } = highlightedField;
const searchRequest = buildSearchRequest(name, values, from, to, aggregationField);
const { data, isLoading, isError } = useQuery(
[QUERY_KEY, field, values],
() =>
createFetchAggregatedData<RawAggregatedDataResponse>(searchService, searchRequest, AGG_KEY),
[QUERY_KEY, name, values, from, to, aggregationField],
() => createFetchData<RawAggregatedDataResponse>(searchService, searchRequest),
{
select: (res) => res.aggregations[AGG_KEY].buckets.length,
keepPreviousData: true,
@ -97,13 +92,14 @@ export const useFetchUniqueHostsWithFieldPair = ({
/**
* Build the search request for the field/values pair, for a date range from/to.
* The request contains aggregation by host.name field.
* The request contains aggregation by aggregationField.
*/
const buildSearchRequest = (
field: string,
values: string[],
from: string,
to: string
to: string,
aggregationField: string
): IEsSearchRequest => {
const query = buildEsQuery(
undefined,
@ -133,21 +129,5 @@ const buildSearchRequest = (
},
]
);
return {
params: {
body: {
query,
aggs: {
[AGG_KEY]: {
terms: {
field: 'host.name',
size: 1000,
},
},
},
size: 0,
},
},
};
return buildAggregationSearchRequest(aggregationField, AGG_KEY, query);
};

View file

@ -8,16 +8,20 @@
import { useQuery } from '@tanstack/react-query';
import type { RenderHookResult } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react-hooks';
import type { UseUniqueValuesValue } from './use_fetch_unique_hosts';
import { useFetchUniqueHosts } from './use_fetch_unique_hosts';
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');
describe('useFetchUniqueHosts', () => {
let hookResult: RenderHookResult<unknown, UseUniqueValuesValue>;
const field = 'host.name';
describe('useFetchUniqueByField', () => {
let hookResult: RenderHookResult<UseFetchUniqueByFieldParams, UseFetchUniqueByFieldValue>;
(useKibana as jest.Mock).mockReturnValue({
services: {
data: { search: jest.fn() },
@ -31,7 +35,7 @@ describe('useFetchUniqueHosts', () => {
data: 0,
});
hookResult = renderHook(() => useFetchUniqueHosts());
hookResult = renderHook(() => useFetchUniqueByField({ field }));
expect(hookResult.result.current.loading).toBeTruthy();
expect(hookResult.result.current.error).toBeFalsy();
@ -45,7 +49,7 @@ describe('useFetchUniqueHosts', () => {
data: 0,
});
hookResult = renderHook(() => useFetchUniqueHosts());
hookResult = renderHook(() => useFetchUniqueByField({ field }));
expect(hookResult.result.current.loading).toBeFalsy();
expect(hookResult.result.current.error).toBeTruthy();
@ -59,7 +63,7 @@ describe('useFetchUniqueHosts', () => {
data: 1,
});
hookResult = renderHook(() => useFetchUniqueHosts());
hookResult = renderHook(() => useFetchUniqueByField({ field }));
expect(hookResult.result.current.loading).toBeFalsy();
expect(hookResult.result.current.error).toBeFalsy();

View file

@ -0,0 +1,67 @@
/*
* 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,38 @@
/*
* 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 { IEsSearchRequest } from '@kbn/data-plugin/common';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
/**
* Builds a search request for an aggregation.
* We're setting the query size to 0 as we only care about the aggregation result here.
*
* @param field aggregation field
* @param key aggregation key
* @param query optional query
*/
export const buildAggregationSearchRequest = (
field: string,
key: string,
query?: QueryDslQueryContainer
): IEsSearchRequest => ({
params: {
body: {
query,
aggs: {
[key]: {
terms: {
field,
size: 1000, // setting a high size to get as close as possible to all unique values
},
},
},
size: 0,
},
},
});

View file

@ -8,13 +8,34 @@
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
*/
export const createFetchAggregatedData = async <TResponse, T = {}>(
export const createFetchData = async <TResponse, T = {}>(
searchService: ISearchStart,
req: IEsSearchRequest,
aggregationKey: string
req: IEsSearchRequest
): Promise<TResponse> => {
return new Promise((resolve, reject) => {
searchService.search<IEsSearchRequest, IKibanaSearchResponse<TResponse>>(req).subscribe({