[Security Solution] Alerts Grouping MVP (#149145)

Current PR introducing the new grouping functionality to the alerts
tables: on Alerts and Rule Details pages.
The existing grouping design is a technical preview functionality and is
a subject of the change.
MVP description:
1. Grouping is available only for alerts tables on the Alerts and Rules
Details page as selectable dropdown options list in the right top level
menu of the alerts table:
<img width="1565" alt="Screenshot 2023-01-28 at 2 00 33 PM"
src="https://user-images.githubusercontent.com/55110838/215293513-a46e5989-0e49-4b4c-b191-e00d6ef14eff.png">
2. Default selected option "None" means that the group alerts by is
turned off and none of the field is selected. In 8.7 feature has a
**technical preview** badge on the right of the select option.
<img width="373" alt="Screenshot 2023-01-28 at 2 21 24 PM"
src="https://user-images.githubusercontent.com/55110838/215293745-ae232e12-eb92-4429-a667-7b76a2be8c61.png">
3. The default fields options list is different for Alerts and Rule
Details pages and relevant to the page context:
<img width="1555" alt="Screenshot 2023-01-28 at 2 30 02 PM"
src="https://user-images.githubusercontent.com/55110838/215294128-a0e2a875-088b-446e-ba96-28bcb1d114d0.png">
<img width="1498" alt="Screenshot 2023-01-28 at 2 31 22 PM"
src="https://user-images.githubusercontent.com/55110838/215294132-0ca11882-73e9-446c-9e75-112569b9bdc7.png">

4. Group by custom field is a separate option which allows to group the
alerts data by any other index field.
<img width="980" alt="Screenshot 2023-01-28 at 2 34 28 PM"
src="https://user-images.githubusercontent.com/55110838/215294168-f787093c-72e9-483d-8881-70320b1f4343.png">

5. Custom field provides a limited to the field value only default
rendering for the panel and default set of stats metrics: Rules count
and Alerts count.
<img width="1209" alt="Screenshot 2023-01-28 at 2 35 47 PM"
src="https://user-images.githubusercontent.com/55110838/215294237-17c6105c-d9a3-4ced-be2b-c17ffd181e14.png">
For rule name for example the is also additionally rendered metrics,
rule name, rule description and rule tags:
<img width="1899" alt="Screenshot 2023-01-28 at 2 40 02 PM"
src="https://user-images.githubusercontent.com/55110838/215294351-8935ee93-c416-4357-80cd-ce28c0127993.png">

6. Each group panel provides the list of bulk actions options which
could be applied to the whole group by clicking on the **Take actions**
button. For now the list is limited to the three available actions:
<img width="1557" alt="Screenshot 2023-01-28 at 2 32 24 PM"
src="https://user-images.githubusercontent.com/55110838/215294393-513dc001-be83-4f76-ac09-3a36b2b89e00.png">

7. Existing technical preview functionality is limited to display only
one expanded group at a time.
8. For a big number of groups there is a paging functionality with the
ability to define the items per page:
<img width="735" alt="Screenshot 2023-01-28 at 2 32 40 PM"
src="https://user-images.githubusercontent.com/55110838/215294444-98dfef11-b6b5-413b-b82f-0dcea90f0e65.png">
9. Grouping setting is stored in the local storage for each page
separately and after the hard refresh should be picked up and rendered
on the page.

---------

Co-authored-by: Steph Milovic <stephanie.milovic@elastic.co>
Co-authored-by: Garrett Spong <spong@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yuliia Naumenko 2023-02-06 21:01:31 -08:00 committed by GitHub
parent c71725ee46
commit 705ba7b5c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 3544 additions and 63 deletions

View file

@ -70,7 +70,7 @@ export interface Hits<T, U> {
}
export interface GenericBuckets {
key: string;
key: string | string[];
key_as_string?: string; // contains, for example, formatted dates
doc_count: number;
}

View file

@ -7,6 +7,7 @@
import { useCallback, useEffect, useState } from 'react';
import { firstNonNullValue } from '../../../../common/endpoint/models/ecs_safety_helpers';
import type { ESBoolQuery } from '../../../../common/typed_json';
import type { Status } from '../../../../common/detection_engine/schemas/common';
import type { GenericBuckets } from '../../../../common/search_strategy';
@ -202,7 +203,7 @@ const parseAlertCountByRuleItems = (
return buckets.map<AlertCountByRuleByStatusItem>((bucket) => {
const uuid = bucket.ruleUuid.hits?.hits[0]?._source['kibana.alert.rule.uuid'] || '';
return {
ruleName: bucket.key,
ruleName: firstNonNullValue(bucket.key) ?? '-',
count: bucket.doc_count,
uuid,
};

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { firstNonNullValue } from '../../../../../../common/endpoint/models/ecs_safety_helpers';
import type { RawBucket, FlattenedBucket } from '../../types';
export const flattenBucket = ({
@ -18,6 +19,6 @@ export const flattenBucket = ({
doc_count: bucket.doc_count,
key: bucket.key_as_string ?? bucket.key, // prefer key_as_string when available, because it contains a formatted date
maxRiskSubAggregation: bucket.maxRiskSubAggregation,
stackByField1Key: x.key_as_string ?? x.key,
stackByField1Key: x.key_as_string ?? firstNonNullValue(x.key),
stackByField1DocCount: x.doc_count,
})) ?? [];

View file

@ -13,6 +13,7 @@ import type {
WordCloudElementEvent,
XYChartElementEvent,
} from '@elastic/charts';
import { firstNonNullValue } from '../../../../../common/endpoint/models/ecs_safety_helpers';
import type { RawBucket } from '../types';
@ -28,7 +29,10 @@ export const getMaxRiskSubAggregations = (
buckets: RawBucket[]
): Record<string, number | undefined> =>
buckets.reduce<Record<string, number | undefined>>(
(acc, x) => ({ ...acc, [x.key]: x.maxRiskSubAggregation?.value ?? undefined }),
(acc, x) => ({
...acc,
[firstNonNullValue(x.key) ?? '']: x.maxRiskSubAggregation?.value ?? undefined,
}),
{}
);

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { firstNonNullValue } from '../../../../../../common/endpoint/models/ecs_safety_helpers';
import type { LegendItem } from '../../../charts/draggable_legend_item';
import { getLegendMap, getLegendItemFromFlattenedBucket } from '.';
import type { FlattenedBucket, RawBucket } from '../../types';
@ -38,8 +39,8 @@ export const getFlattenedLegendItems = ({
>(
(acc, flattenedBucket) => ({
...acc,
[flattenedBucket.key]: [
...(acc[flattenedBucket.key] ?? []),
[firstNonNullValue(flattenedBucket.key) ?? '']: [
...(acc[firstNonNullValue(flattenedBucket.key) ?? ''] ?? []),
getLegendItemFromFlattenedBucket({
colorPalette,
flattenedBucket,
@ -54,7 +55,7 @@ export const getFlattenedLegendItems = ({
// reduce all the legend items to a single array in the same order as the raw buckets:
return buckets.reduce<LegendItem[]>(
(acc, bucket) => [...acc, ...combinedLegendItems[bucket.key]],
(acc, bucket) => [...acc, ...combinedLegendItems[firstNonNullValue(bucket.key) ?? '']],
[]
);
};

View file

@ -7,6 +7,7 @@
import { v4 as uuidv4 } from 'uuid';
import { firstNonNullValue } from '../../../../../../common/endpoint/models/ecs_safety_helpers';
import type { LegendItem } from '../../../charts/draggable_legend_item';
import { getFillColor } from '../chart_palette';
import { escapeDataProviderId } from '../../../drag_and_drop/helpers';
@ -28,7 +29,7 @@ export const getLegendItemFromRawBucket = ({
}): LegendItem => ({
color: showColor
? getFillColor({
riskScore: maxRiskSubAggregations[bucket.key] ?? 0,
riskScore: maxRiskSubAggregations[firstNonNullValue(bucket.key) ?? ''] ?? 0,
colorPalette,
})
: undefined,
@ -38,11 +39,11 @@ export const getLegendItemFromRawBucket = ({
),
render: () =>
getLabel({
baseLabel: bucket.key_as_string ?? bucket.key, // prefer key_as_string when available, because it contains a formatted date
baseLabel: bucket.key_as_string ?? firstNonNullValue(bucket.key) ?? '', // prefer key_as_string when available, because it contains a formatted date
riskScore: bucket.maxRiskSubAggregation?.value,
}),
field: stackByField0,
value: bucket.key_as_string ?? bucket.key,
value: bucket.key_as_string ?? firstNonNullValue(bucket.key) ?? 0,
});
export const getLegendItemFromFlattenedBucket = ({
@ -59,7 +60,7 @@ export const getLegendItemFromFlattenedBucket = ({
stackByField1: string | undefined;
}): LegendItem => ({
color: getFillColor({
riskScore: maxRiskSubAggregations[key] ?? 0,
riskScore: maxRiskSubAggregations[firstNonNullValue(key) ?? ''] ?? 0,
colorPalette,
}),
count: stackByField1DocCount,
@ -106,7 +107,7 @@ export const getLegendMap = ({
buckets.reduce<Record<string, LegendItem[]>>(
(acc, bucket) => ({
...acc,
[bucket.key]: [
[firstNonNullValue(bucket.key) ?? '']: [
getLegendItemFromRawBucket({
bucket,
colorPalette,

View file

@ -18,7 +18,7 @@ import { eventsDefaultModel } from './default_model';
import { EntityType } from '@kbn/timelines-plugin/common';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer';
import { useTimelineEvents } from '../../../timelines/containers';
import { useTimelineEvents } from './use_timelines_events';
import { getDefaultControlColumn } from '../../../timelines/components/timeline/body/control_columns';
import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers';
import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions';
@ -46,9 +46,7 @@ const originalKibanaLib = jest.requireActual('../../lib/kibana');
const mockUseGetUserCasesPermissions = useGetUserCasesPermissions as jest.Mock;
mockUseGetUserCasesPermissions.mockImplementation(originalKibanaLib.useGetUserCasesPermissions);
jest.mock('../../../timelines/containers', () => ({
useTimelineEvents: jest.fn(),
}));
jest.mock('./use_timelines_events');
jest.mock('../../utils/normalize_time_range');
@ -57,12 +55,6 @@ jest.mock('../../../timelines/components/fields_browser', () => ({
useFieldBrowserOptions: (props: UseFieldBrowserOptionsProps) => mockUseFieldBrowserOptions(props),
}));
jest.mock('./helpers', () => ({
getDefaultViewSelection: () => 'gridView',
resolverIsShowing: () => false,
getCombinedFilterQuery: () => undefined,
}));
const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock;
jest.mock('use-resize-observer/polyfilled');
mockUseResizeObserver.mockImplementation(() => ({}));
@ -87,7 +79,12 @@ const testProps = {
hasCrudPermissions: true,
};
describe('StatefulEventsViewer', () => {
(useTimelineEvents as jest.Mock).mockReturnValue([false, mockEventViewerResponse]);
beforeAll(() => {
(useTimelineEvents as jest.Mock).mockReturnValue([false, mockEventViewerResponse]);
});
beforeEach(() => {
jest.clearAllMocks();
});
test('it renders the events viewer', () => {
const wrapper = mount(
@ -127,4 +124,25 @@ describe('StatefulEventsViewer', () => {
unmount();
expect(mockCloseEditor).toHaveBeenCalled();
});
test('renders the RightTopMenu additional menu options when given additionalRightMenuOptions props', () => {
const { getByTestId } = render(
<TestProviders>
<StatefulEventsViewer
{...testProps}
additionalRightMenuOptions={[<p data-test-subj="right-option" />]}
/>
</TestProviders>
);
expect(getByTestId('right-option')).toBeInTheDocument();
});
test('does not render the RightTopMenu additional menu options when additionalRightMenuOptions props are not given', () => {
const { queryByTestId } = render(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
expect(queryByTestId('right-option')).not.toBeInTheDocument();
});
});

View file

@ -96,6 +96,7 @@ export interface EventsViewerProps {
unit?: (n: number) => string;
indexNames?: string[];
bulkActions: boolean | BulkActionsProp;
additionalRightMenuOptions?: React.ReactNode[];
}
/**
@ -124,6 +125,7 @@ const StatefulEventsViewerComponent: React.FC<EventsViewerProps & PropsFromRedux
bulkActions,
setSelected,
clearSelected,
additionalRightMenuOptions,
}) => {
const dispatch = useDispatch();
const theme: EuiTheme = useContext(ThemeContext);
@ -554,6 +556,7 @@ const StatefulEventsViewerComponent: React.FC<EventsViewerProps & PropsFromRedux
onViewChange={(selectedView) => setTableView(selectedView)}
additionalFilters={additionalFilters}
hasRightOffset={tableView === 'gridView' && nonDeletedEvents.length > 0}
additionalMenuOptions={additionalRightMenuOptions}
/>
{!hasAlerts && !loading && !graphOverlay && <EmptyTable height="short" />}

View file

@ -26,6 +26,7 @@ interface Props {
onViewChange: (viewSelection: ViewSelection) => void;
additionalFilters?: React.ReactNode;
hasRightOffset?: boolean;
additionalMenuOptions?: React.ReactNode[];
}
export const RightTopMenu = ({
@ -36,6 +37,7 @@ export const RightTopMenu = ({
onViewChange,
additionalFilters,
hasRightOffset,
additionalMenuOptions = [],
}: Props) => {
const alignItems = tableView === 'gridView' ? 'baseline' : 'center';
const justTitle = useMemo(() => <TitleText data-test-subj="title">{title}</TitleText>, [title]);
@ -43,6 +45,19 @@ export const RightTopMenu = ({
const tGridEventRenderedViewEnabled = useIsExperimentalFeatureEnabled(
'tGridEventRenderedViewEnabled'
);
const menuOptions = useMemo(
() =>
additionalMenuOptions.length
? additionalMenuOptions.map((additionalMenuOption, i) => (
<UpdatedFlexItem grow={false} $show={!loading} key={i}>
{additionalMenuOption}
</UpdatedFlexItem>
))
: null,
[additionalMenuOptions, loading]
);
return (
<UpdatedFlexGroup
alignItems={alignItems}
@ -63,6 +78,7 @@ export const RightTopMenu = ({
<SummaryViewSelector viewSelected={tableView} onViewChange={onViewChange} />
</UpdatedFlexItem>
)}
{menuOptions}
</UpdatedFlexGroup>
);
};

View file

@ -12,6 +12,7 @@ import type {
OptionsListEmbeddableInput,
ControlGroupContainer,
} from '@kbn/controls-plugin/public';
import { i18n } from '@kbn/i18n';
import { LazyControlGroupRenderer } from '@kbn/controls-plugin/public';
import type { PropsWithChildren } from 'react';
import React, { createContext, useCallback, useEffect, useState, useRef, useMemo } from 'react';
@ -344,6 +345,9 @@ const FilterGroupComponent = (props: PropsWithChildren<FilterGroupProps>) => {
id="filter-group__context-menu"
button={
<EuiButtonIcon
aria-label={i18n.translate('xpack.securitySolution.filterGroup.groupMenuTitle', {
defaultMessage: 'Filter group menu',
})}
display="empty"
size="s"
iconType="boxesHorizontal"

View file

@ -0,0 +1,88 @@
/*
* 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 { fireEvent, render } from '@testing-library/react';
import { GroupStats } from './group_stats';
import { TestProviders } from '../../../mock';
const onTakeActionsOpen = jest.fn();
const testProps = {
badgeMetricStats: [
{ title: "IP's:", value: 1 },
{ title: 'Rules:', value: 2 },
{ title: 'Alerts:', value: 2, width: 50, color: '#a83632' },
],
bucket: {
key: '9nk5mo2fby',
doc_count: 2,
hostsCountAggregation: { value: 1 },
ruleTags: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] },
alertsCount: { value: 2 },
rulesCountAggregation: { value: 2 },
severitiesSubAggregation: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [{ key: 'low', doc_count: 2 }],
},
countSeveritySubAggregation: { value: 1 },
usersCountAggregation: { value: 1 },
},
onTakeActionsOpen,
customMetricStats: [
{
title: 'Severity',
customStatRenderer: <p data-test-subj="customMetricStat" />,
},
],
takeActionItems: [
<p data-test-subj="takeActionItem-1" key={1} />,
<p data-test-subj="takeActionItem-2" key={2} />,
],
};
describe('Group stats', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders each stat item', () => {
const { getByTestId } = render(
<TestProviders>
<GroupStats {...testProps} />
</TestProviders>
);
expect(getByTestId('group-stats')).toBeInTheDocument();
testProps.badgeMetricStats.forEach(({ title: stat }) => {
expect(getByTestId(`metric-${stat}`)).toBeInTheDocument();
});
testProps.customMetricStats.forEach(({ title: stat }) => {
expect(getByTestId(`customMetric-${stat}`)).toBeInTheDocument();
});
});
it('when onTakeActionsOpen is defined, call onTakeActionsOpen on popover click', () => {
const { getByTestId, queryByTestId } = render(
<TestProviders>
<GroupStats {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('take-action-button'));
expect(onTakeActionsOpen).toHaveBeenCalled();
['takeActionItem-1', 'takeActionItem-2'].forEach((actionItem) => {
expect(queryByTestId(actionItem)).not.toBeInTheDocument();
});
});
it('when onTakeActionsOpen is undefined, render take actions dropdown on popover click', () => {
const { getByTestId } = render(
<TestProviders>
<GroupStats {...testProps} onTakeActionsOpen={undefined} />
</TestProviders>
);
fireEvent.click(getByTestId('take-action-button'));
['takeActionItem-1', 'takeActionItem-2'].forEach((actionItem) => {
expect(getByTestId(actionItem)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,120 @@
/*
* 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 {
EuiBadge,
EuiButtonEmpty,
EuiContextMenuPanel,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiToolTip,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import type { BadgeMetric, CustomMetric } from '.';
import { StatsContainer } from '../styles';
import { TAKE_ACTION } from '../translations';
import type { RawBucket } from '../types';
interface GroupStatsProps {
badgeMetricStats?: BadgeMetric[];
bucket: RawBucket;
customMetricStats?: CustomMetric[];
onTakeActionsOpen?: () => void;
takeActionItems: JSX.Element[];
}
const GroupStatsComponent = ({
badgeMetricStats,
bucket,
customMetricStats,
onTakeActionsOpen,
takeActionItems,
}: GroupStatsProps) => {
const [isPopoverOpen, setPopover] = useState(false);
const onButtonClick = useCallback(
() => (!isPopoverOpen && onTakeActionsOpen ? onTakeActionsOpen() : setPopover(!isPopoverOpen)),
[isPopoverOpen, onTakeActionsOpen]
);
const badgesComponents = useMemo(
() =>
badgeMetricStats?.map((metric) => (
<EuiFlexItem grow={false} key={metric.title}>
<StatsContainer data-test-subj={`metric-${metric.title}`}>
<>
{metric.title}
<EuiToolTip position="top" content={metric.value}>
<EuiBadge
style={{ marginLeft: 10, width: metric.width ?? 35 }}
color={metric.color ?? 'hollow'}
>
{metric.value > 99 ? '99+' : metric.value.toString()}
</EuiBadge>
</EuiToolTip>
</>
</StatsContainer>
</EuiFlexItem>
)),
[badgeMetricStats]
);
const customComponents = useMemo(
() =>
customMetricStats?.map((customMetric) => (
<EuiFlexItem grow={false} key={customMetric.title}>
<StatsContainer data-test-subj={`customMetric-${customMetric.title}`}>
{customMetric.title}
{customMetric.customStatRenderer}
</StatsContainer>
</EuiFlexItem>
)),
[customMetricStats]
);
const popoverComponent = useMemo(
() => (
<EuiFlexItem grow={false}>
<EuiPopover
anchorPosition="downLeft"
button={
<EuiButtonEmpty
data-test-subj="take-action-button"
onClick={onButtonClick}
iconType="arrowDown"
iconSide="right"
>
{TAKE_ACTION}
</EuiButtonEmpty>
}
closePopover={() => setPopover(false)}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenuPanel items={takeActionItems} />
</EuiPopover>
</EuiFlexItem>
),
[isPopoverOpen, onButtonClick, takeActionItems]
);
return (
<EuiFlexGroup
data-test-subj="group-stats"
key={`stats-${bucket.key[0]}`}
gutterSize="none"
alignItems="center"
>
{customComponents}
{badgesComponents}
{popoverComponent}
</EuiFlexGroup>
);
};
export const GroupStats = React.memo(GroupStatsComponent);

View file

@ -0,0 +1,31 @@
/*
* 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 const createGroupFilter = (selectedGroup: string, query?: string) =>
query && selectedGroup
? [
{
meta: {
alias: null,
disabled: false,
key: selectedGroup,
negate: false,
params: {
query,
},
type: 'phrase',
},
query: {
match_phrase: {
[selectedGroup]: {
query,
},
},
},
},
]
: [];

View file

@ -0,0 +1,93 @@
/*
* 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 { fireEvent, render } from '@testing-library/react';
import { GroupPanel } from '.';
import { createGroupFilter } from './helpers';
import React from 'react';
const onToggleGroup = jest.fn();
const renderChildComponent = jest.fn();
const ruleName = 'Rule name';
const ruleDesc = 'Rule description';
const testProps = {
groupBucket: {
key: [ruleName, ruleDesc],
key_as_string: `${ruleName}|${ruleDesc}`,
doc_count: 98,
hostsCountAggregation: {
value: 5,
},
ruleTags: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
alertsCount: {
value: 98,
},
rulesCountAggregation: {
value: 1,
},
severitiesSubAggregation: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'low',
doc_count: 98,
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 98,
},
},
renderChildComponent,
selectedGroup: 'kibana.alert.rule.name',
};
describe('grouping accordion panel', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('creates the query for the selectedGroup attribute', () => {
const { getByTestId } = render(<GroupPanel {...testProps} />);
expect(getByTestId('grouping-accordion')).toBeInTheDocument();
expect(renderChildComponent).toHaveBeenCalledWith(
createGroupFilter(testProps.selectedGroup, ruleName)
);
});
it('does not create query without a valid groupFieldValue', () => {
const { queryByTestId } = render(
<GroupPanel
{...testProps}
groupBucket={{
...testProps.groupBucket,
// @ts-expect-error
key: null,
}}
/>
);
expect(queryByTestId('grouping-accordion')).not.toBeInTheDocument();
expect(renderChildComponent).not.toHaveBeenCalled();
});
it('When onToggleGroup not defined, does nothing on toggle', () => {
const { container } = render(<GroupPanel {...testProps} />);
fireEvent.click(container.querySelector('[data-test-subj="grouping-accordion"] button')!);
expect(onToggleGroup).not.toHaveBeenCalled();
});
it('When onToggleGroup is defined, calls function with proper args on toggle', () => {
const { container } = render(<GroupPanel {...testProps} onToggleGroup={onToggleGroup} />);
fireEvent.click(container.querySelector('[data-test-subj="grouping-accordion"] button')!);
expect(onToggleGroup).toHaveBeenCalledWith(true, testProps.groupBucket);
});
});

View file

@ -0,0 +1,101 @@
/*
* 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 { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import React, { useCallback, useMemo } from 'react';
import { firstNonNullValue } from '../../../../../common/endpoint/models/ecs_safety_helpers';
import type { RawBucket } from '../types';
import { createGroupFilter } from './helpers';
export interface BadgeMetric {
title: string;
value: number;
color?: string;
width?: number;
}
export interface CustomMetric {
title: string;
customStatRenderer: JSX.Element;
}
interface GroupPanelProps {
customAccordionButtonClassName?: string;
customAccordionClassName?: string;
extraAction?: React.ReactNode;
forceState?: 'open' | 'closed';
groupBucket: RawBucket;
groupPanelRenderer?: JSX.Element;
level?: number;
onToggleGroup?: (isOpen: boolean, groupBucket: RawBucket) => void;
renderChildComponent: (groupFilter: Filter[]) => React.ReactNode;
selectedGroup: string;
}
const DefaultGroupPanelRenderer = ({ title }: { title: string }) => (
<div>
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem>
<EuiTitle size="xs" className="euiAccordionForm__title">
<h4 className="eui-textTruncate">{title}</h4>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
const GroupPanelComponent = ({
customAccordionButtonClassName = 'groupingAccordionForm__button',
customAccordionClassName = 'groupingAccordionForm',
extraAction,
forceState,
groupBucket,
groupPanelRenderer,
level = 0,
onToggleGroup,
renderChildComponent,
selectedGroup,
}: GroupPanelProps) => {
const groupFieldValue = useMemo(() => firstNonNullValue(groupBucket.key), [groupBucket.key]);
const groupFilters = useMemo(
() => createGroupFilter(selectedGroup, groupFieldValue),
[groupFieldValue, selectedGroup]
);
const onToggle = useCallback(
(isOpen) => {
if (onToggleGroup) {
onToggleGroup(isOpen, groupBucket);
}
},
[groupBucket, onToggleGroup]
);
return !groupFieldValue ? null : (
<EuiAccordion
buttonClassName={customAccordionButtonClassName}
buttonContent={
<div className="groupingPanelRenderer">
{groupPanelRenderer ?? <DefaultGroupPanelRenderer title={groupFieldValue} />}
</div>
}
className={customAccordionClassName}
data-test-subj="grouping-accordion"
extraAction={extraAction}
forceState={forceState}
id={`group${level}-${groupFieldValue}`}
onToggle={onToggle}
paddingSize="m"
>
{renderChildComponent(groupFilters)}
</EuiAccordion>
);
};
export const GroupPanel = React.memo(GroupPanelComponent);

View file

@ -0,0 +1,180 @@
/*
* 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 { fireEvent, render, within } from '@testing-library/react';
import React from 'react';
import { GroupingContainer } from '..';
import { TestProviders } from '../../../mock';
import { createGroupFilter } from '../accordion_panel/helpers';
const renderChildComponent = jest.fn();
const takeActionItems = jest.fn();
const rule1Name = 'Rule 1 name';
const rule1Desc = 'Rule 1 description';
const rule2Name = 'Rule 2 name';
const rule2Desc = 'Rule 2 description';
const testProps = {
data: {
groupsNumber: {
value: 2,
},
stackByMultipleFields0: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: [rule1Name, rule1Desc],
key_as_string: `${rule1Name}|${rule1Desc}`,
doc_count: 1,
hostsCountAggregation: {
value: 1,
},
ruleTags: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
alertsCount: {
value: 1,
},
severitiesSubAggregation: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'low',
doc_count: 1,
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 1,
},
},
{
key: [rule2Name, rule2Desc],
key_as_string: `${rule2Name}|${rule2Desc}`,
doc_count: 1,
hostsCountAggregation: {
value: 1,
},
ruleTags: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
alertsCount: {
value: 1,
},
severitiesSubAggregation: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'low',
doc_count: 1,
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 1,
},
},
],
},
alertsCount: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'siem',
doc_count: 2,
},
],
},
},
pagination: {
pageIndex: 0,
pageSize: 25,
onChangeItemsPerPage: jest.fn(),
onChangePage: jest.fn(),
},
renderChildComponent,
selectedGroup: 'kibana.alert.rule.name',
takeActionItems,
};
describe('grouping container', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Renders group counts when groupsNumber > 0', () => {
const { getByTestId, getAllByTestId, queryByTestId } = render(
<TestProviders>
<GroupingContainer {...testProps} />
</TestProviders>
);
expect(getByTestId('alert-count').textContent).toBe('2 alerts');
expect(getByTestId('groups-count').textContent).toBe('2 groups');
expect(getAllByTestId('grouping-accordion').length).toBe(2);
expect(queryByTestId('empty-results-panel')).not.toBeInTheDocument();
});
it('Does not render group counts when groupsNumber = 0', () => {
const data = {
groupsNumber: {
value: 0,
},
stackByMultipleFields0: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
alertsCount: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
};
const { getByTestId, queryByTestId } = render(
<TestProviders>
<GroupingContainer {...testProps} data={data} />
</TestProviders>
);
expect(queryByTestId('alert-count')).not.toBeInTheDocument();
expect(queryByTestId('groups-count')).not.toBeInTheDocument();
expect(queryByTestId('grouping-accordion')).not.toBeInTheDocument();
expect(getByTestId('empty-results-panel')).toBeInTheDocument();
});
it('Opens one group at a time when each group is clicked', () => {
const { getAllByTestId } = render(
<TestProviders>
<GroupingContainer {...testProps} />
</TestProviders>
);
const group1 = within(getAllByTestId('grouping-accordion')[0]).getAllByRole('button')[0];
const group2 = within(getAllByTestId('grouping-accordion')[1]).getAllByRole('button')[0];
fireEvent.click(group1);
expect(renderChildComponent).toHaveBeenNthCalledWith(
1,
createGroupFilter(testProps.selectedGroup, rule1Name)
);
fireEvent.click(group2);
expect(renderChildComponent).toHaveBeenNthCalledWith(
2,
createGroupFilter(testProps.selectedGroup, rule2Name)
);
});
});

View file

@ -0,0 +1,189 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTablePagination } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import React, { useMemo, useState } from 'react';
import { firstNonNullValue } from '../../../../../common/endpoint/models/ecs_safety_helpers';
import { createGroupFilter } from '../accordion_panel/helpers';
import { tableDefaults } from '../../../store/data_table/defaults';
import { defaultUnit } from '../../toolbar/unit';
import type { BadgeMetric, CustomMetric } from '../accordion_panel';
import { GroupPanel } from '../accordion_panel';
import { GroupStats } from '../accordion_panel/group_stats';
import { EmptyGroupingComponent } from '../empty_resuls_panel';
import { GroupingStyledContainer, GroupsUnitCount } from '../styles';
import { GROUPS_UNIT } from '../translations';
import type { GroupingTableAggregation, RawBucket } from '../types';
interface GroupingContainerProps {
badgeMetricStats?: (fieldBucket: RawBucket) => BadgeMetric[];
customMetricStats?: (fieldBucket: RawBucket) => CustomMetric[];
data: GroupingTableAggregation &
Record<
string,
{
value?: number | null;
buckets?: Array<{
doc_count?: number | null;
}>;
}
>;
groupPanelRenderer?: (fieldBucket: RawBucket) => JSX.Element | undefined;
groupsSelector?: JSX.Element;
inspectButton?: JSX.Element;
pagination: {
pageIndex: number;
pageSize: number;
onChangeItemsPerPage: (itemsPerPageNumber: number) => void;
onChangePage: (pageNumber: number) => void;
};
renderChildComponent: (groupFilter: Filter[]) => React.ReactNode;
selectedGroup: string;
takeActionItems: (groupFilters: Filter[]) => JSX.Element[];
unit?: (n: number) => string;
}
const GroupingContainerComponent = ({
badgeMetricStats,
customMetricStats,
data,
groupPanelRenderer,
groupsSelector,
inspectButton,
pagination,
renderChildComponent,
selectedGroup,
takeActionItems,
unit = defaultUnit,
}: GroupingContainerProps) => {
const [trigger, setTrigger] = useState<
Record<string, { state: 'open' | 'closed' | undefined; selectedBucket: RawBucket }>
>({});
const groupsNumber = data?.groupsNumber?.value ?? 0;
const unitCountText = useMemo(() => {
const count =
data?.alertsCount?.buckets && data?.alertsCount?.buckets.length > 0
? data?.alertsCount?.buckets[0].doc_count ?? 0
: 0;
return `${count.toLocaleString()} ${unit && unit(count)}`;
}, [data?.alertsCount?.buckets, unit]);
const unitGroupsCountText = useMemo(
() => `${groupsNumber.toLocaleString()} ${GROUPS_UNIT(groupsNumber)}`,
[groupsNumber]
);
const groupPanels = useMemo(
() =>
data.stackByMultipleFields0?.buckets?.map((groupBucket) => {
const group = firstNonNullValue(groupBucket.key);
const groupKey = `group0-${group}`;
return (
<span key={groupKey}>
<GroupPanel
extraAction={
<GroupStats
bucket={groupBucket}
takeActionItems={takeActionItems(createGroupFilter(selectedGroup, group))}
badgeMetricStats={badgeMetricStats && badgeMetricStats(groupBucket)}
customMetricStats={customMetricStats && customMetricStats(groupBucket)}
/>
}
forceState={(trigger[groupKey] && trigger[groupKey].state) ?? 'closed'}
groupBucket={groupBucket}
groupPanelRenderer={groupPanelRenderer && groupPanelRenderer(groupBucket)}
onToggleGroup={(isOpen) => {
setTrigger({
// ...trigger, -> this change will keep only one group at a time expanded and one table displayed
[groupKey]: {
state: isOpen ? 'open' : 'closed',
selectedBucket: groupBucket,
},
});
}}
renderChildComponent={
trigger[groupKey] && trigger[groupKey].state === 'open'
? renderChildComponent
: () => null
}
selectedGroup={selectedGroup}
/>
<EuiSpacer size="s" />
</span>
);
}),
[
badgeMetricStats,
customMetricStats,
data.stackByMultipleFields0?.buckets,
groupPanelRenderer,
renderChildComponent,
selectedGroup,
takeActionItems,
trigger,
]
);
const pageCount = useMemo(
() => (groupsNumber && pagination.pageSize ? Math.ceil(groupsNumber / pagination.pageSize) : 1),
[groupsNumber, pagination.pageSize]
);
return (
<>
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
style={{ paddingBottom: 20, paddingTop: 20 }}
>
<EuiFlexItem grow={false}>
{groupsNumber > 0 ? (
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<GroupsUnitCount data-test-subj="alert-count">{unitCountText}</GroupsUnitCount>
</EuiFlexItem>
<EuiFlexItem>
<GroupsUnitCount data-test-subj="groups-count" style={{ borderRight: 'none' }}>
{unitGroupsCountText}
</GroupsUnitCount>
</EuiFlexItem>
</EuiFlexGroup>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs">
{inspectButton && <EuiFlexItem>{inspectButton}</EuiFlexItem>}
<EuiFlexItem>{groupsSelector}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<GroupingStyledContainer className="eui-xScroll">
{groupsNumber > 0 ? (
<>
{groupPanels}
<EuiSpacer size="m" />
<EuiTablePagination
activePage={pagination.pageIndex}
data-test-subj="grouping-table-pagination"
itemsPerPage={pagination.pageSize}
itemsPerPageOptions={tableDefaults.itemsPerPageOptions}
onChangeItemsPerPage={pagination.onChangeItemsPerPage}
onChangePage={pagination.onChangePage}
pageCount={pageCount}
showPerPageOptions
/>
</>
) : (
<EmptyGroupingComponent />
)}
</GroupingStyledContainer>
</>
);
};
export const GroupingContainer = React.memo(GroupingContainerComponent);

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 { EuiFlexGroup, EuiFlexItem, EuiImage, EuiPanel, EuiText, EuiTitle } from '@elastic/eui';
import React from 'react';
import type { CoreStart } from '@kbn/core/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
const panelStyle = {
maxWidth: 500,
};
const heights = {
tall: 490,
short: 250,
};
export const EmptyGroupingComponent: React.FC<{ height?: keyof typeof heights }> = ({
height = 'tall',
}) => {
const { http } = useKibana<CoreStart>().services;
return (
<EuiPanel color="subdued" data-test-subj="empty-results-panel">
<EuiFlexGroup style={{ height: heights[height] }} alignItems="center" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiPanel hasBorder={true} style={panelStyle}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="s">
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.securitySolution.grouping.empty.title"
defaultMessage="No grouping results match your selected Group alerts field"
/>
</h3>
</EuiTitle>
<p>
<FormattedMessage
id="xpack.securitySolution.grouping.empty.description"
defaultMessage="Try searching over a longer period of time or modifying your Group alerts field"
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiImage
size="200"
alt=""
url={http.basePath.prepend(
'/plugins/timelines/assets/illustration_product_no_results_magnifying_glass.svg'
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

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 type { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiButton, EuiComboBox, EuiForm, EuiFormRow } from '@elastic/eui';
import type { FieldSpec } from '@kbn/data-views-plugin/common';
import { i18n } from '@kbn/i18n';
import React from 'react';
export interface GroupByOptions {
text: string;
field: string;
}
interface Props {
onSubmit: (field: string) => void;
fields: FieldSpec[];
currentOptions: GroupByOptions[];
}
interface SelectedOption {
label: string;
}
const initialState = {
selectedOptions: [] as SelectedOption[],
};
type State = Readonly<typeof initialState>;
export class CustomFieldPanel extends React.PureComponent<Props, State> {
public static displayName = 'CustomFieldPanel';
public readonly state: State = initialState;
public render() {
const { fields, currentOptions } = this.props;
const options = fields
.filter(
(f) =>
f.aggregatable &&
f.type === 'string' &&
!(currentOptions && currentOptions.some((o) => o.field === f.name))
)
.map<EuiComboBoxOptionOption>((f) => ({ label: f.name }));
const isSubmitDisabled = !this.state.selectedOptions.length;
return (
<div data-test-subj="custom-field-panel" style={{ padding: 16 }}>
<EuiForm>
<EuiFormRow
label={i18n.translate('xpack.securitySolution.groupsSelector.customGroupByFieldLabel', {
defaultMessage: 'Field',
})}
helpText={i18n.translate(
'xpack.securitySolution.groupsSelector.customGroupByHelpText',
{
defaultMessage: 'This is the field used for the terms aggregation',
}
)}
display="rowCompressed"
fullWidth
>
<EuiComboBox
data-test-subj="groupByCustomField"
placeholder={i18n.translate(
'xpack.securitySolution.groupsSelector.customGroupByDropdownPlacehoder',
{
defaultMessage: 'Select one',
}
)}
singleSelection={{ asPlainText: true }}
selectedOptions={this.state.selectedOptions}
options={options as EuiComboBoxOptionOption[]}
onChange={this.handleFieldSelection}
fullWidth
isClearable={false}
/>
</EuiFormRow>
<EuiButton
data-test-subj="groupByCustomFieldAddButton"
disabled={isSubmitDisabled}
type="submit"
size="s"
fill
onClick={this.handleSubmit}
>
{i18n.translate('xpack.securitySolution.selector.grouping.label.add', {
defaultMessage: 'Add',
})}
</EuiButton>
</EuiForm>
</div>
);
}
private handleSubmit = () => {
this.props.onSubmit(this.state.selectedOptions[0].label);
};
private handleFieldSelection = (selectedOptions: SelectedOption[]) => {
this.setState({ selectedOptions });
};
}

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 { fireEvent, render } from '@testing-library/react';
import { TestProviders } from '../../../mock';
import { GroupsSelector } from '..';
import React from 'react';
const onGroupChange = jest.fn();
const testProps = {
fields: [
{
name: 'kibana.alert.rule.name',
searchable: true,
type: 'string',
aggregatable: true,
esTypes: ['keyword'],
},
{
name: 'host.name',
searchable: true,
type: 'string',
aggregatable: true,
esTypes: ['keyword'],
},
{
name: 'user.name',
searchable: true,
type: 'string',
aggregatable: true,
esTypes: ['keyword'],
},
{
name: 'source.ip',
searchable: true,
type: 'ip',
aggregatable: true,
esTypes: ['ip'],
},
],
groupSelected: 'kibana.alert.rule.name',
onGroupChange,
options: [
{
label: 'Rule name',
key: 'kibana.alert.rule.name',
},
{
label: 'User name',
key: 'user.name',
},
{
label: 'Host name',
key: 'host.name',
},
{
label: 'Source IP',
key: 'source.ip',
},
],
title: 'Group alerts by',
};
describe('group selector', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Sets the selected group from the groupSelected prop', () => {
const { getByTestId } = render(
<TestProviders>
<GroupsSelector {...testProps} />
</TestProviders>
);
expect(getByTestId('group-selector-dropdown').textContent).toBe('Group alerts by: Rule name');
});
it('Presents correct option when group selector dropdown is clicked', () => {
const { getByTestId } = render(
<TestProviders>
<GroupsSelector {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('group-selector-dropdown'));
[
...testProps.options,
{ key: 'none', label: 'None' },
{ key: 'custom', label: 'Custom field' },
].forEach((o) => {
expect(getByTestId(`panel-${o.key}`).textContent).toBe(o.label);
});
});
it('Presents fields dropdown when custom field option is selected', () => {
const { getByTestId } = render(
<TestProviders>
<GroupsSelector {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('group-selector-dropdown'));
fireEvent.click(getByTestId('panel-none'));
expect(onGroupChange).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,139 @@
/*
* 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 {
EuiContextMenuPanelDescriptor,
EuiContextMenuPanelItemDescriptor,
} from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiBetaBadge, EuiPopover } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo, useState } from 'react';
import type { FieldSpec } from '@kbn/data-views-plugin/common';
import { CustomFieldPanel } from './custom_field_panel';
import { GROUP_BY, TECHNICAL_PREVIEW } from '../translations';
import { StyledContextMenu, StyledEuiButtonEmpty } from '../styles';
const none = i18n.translate('xpack.securitySolution.groupsSelector.noneGroupByOptionName', {
defaultMessage: 'None',
});
interface GroupSelectorProps {
fields: FieldSpec[];
groupSelected: string;
onGroupChange: (groupSelection: string) => void;
options: Array<{ key: string; label: string }>;
title?: string;
}
const GroupsSelectorComponent = ({
fields,
groupSelected = 'none',
onGroupChange,
options,
title = '',
}: GroupSelectorProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const panels: EuiContextMenuPanelDescriptor[] = useMemo(
() => [
{
id: 'firstPanel',
items: [
{
'data-test-subj': 'panel-none',
name: none,
icon: groupSelected === 'none' ? 'check' : 'empty',
onClick: () => onGroupChange('none'),
},
...options.map<EuiContextMenuPanelItemDescriptor>((o) => ({
'data-test-subj': `panel-${o.key}`,
name: o.label,
onClick: () => onGroupChange(o.key),
icon: groupSelected === o.key ? 'check' : 'empty',
})),
{
'data-test-subj': `panel-custom`,
name: i18n.translate('xpack.securitySolution.groupsSelector.customGroupByOptionName', {
defaultMessage: 'Custom field',
}),
icon: 'empty',
panel: 'customPanel',
},
],
},
{
id: 'customPanel',
title: i18n.translate('xpack.securitySolution.groupsSelector.customGroupByPanelTitle', {
defaultMessage: 'Group By Custom Field',
}),
width: 685,
content: (
<CustomFieldPanel
currentOptions={options.map((o) => ({ text: o.label, field: o.key }))}
onSubmit={(field: string) => {
onGroupChange(field);
}}
fields={fields}
/>
),
},
],
[fields, groupSelected, onGroupChange, options]
);
const selectedOption = useMemo(
() => options.filter((groupOption) => groupOption.key === groupSelected),
[groupSelected, options]
);
const onButtonClick = useCallback(() => setIsPopoverOpen((currentVal) => !currentVal), []);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const button = useMemo(
() => (
<StyledEuiButtonEmpty
data-test-subj="group-selector-dropdown"
flush="both"
iconSide="right"
iconSize="s"
iconType="arrowDown"
onClick={onButtonClick}
title={
groupSelected !== 'none' && selectedOption.length > 0 ? selectedOption[0].label : none
}
size="xs"
>
{`${title ?? GROUP_BY}: ${
groupSelected !== 'none' && selectedOption.length > 0 ? selectedOption[0].label : none
}`}
</StyledEuiButtonEmpty>
),
[groupSelected, onButtonClick, selectedOption, title]
);
return (
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem>
<EuiPopover
button={button}
closePopover={closePopover}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<StyledContextMenu
data-test-subj="groupByContextMenu"
initialPanelId="firstPanel"
panels={panels}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem>
<EuiBetaBadge label={TECHNICAL_PREVIEW} size="s" style={{ marginTop: 2 }} />
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const GroupsSelector = React.memo(GroupsSelectorComponent);

View file

@ -0,0 +1,15 @@
/*
* 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 { NONE_GROUP_KEY } from './types';
export * from './container';
export * from './query';
export * from './groups_selector';
export * from './types';
export const isNoneGroup = (groupKey: string) => groupKey === NONE_GROUP_KEY;

View file

@ -0,0 +1,174 @@
/*
* 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 { isEmpty } from 'lodash/fp';
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
/** The maximum number of items to render */
export const DEFAULT_STACK_BY_FIELD0_SIZE = 10;
export const DEFAULT_STACK_BY_FIELD1_SIZE = 10;
interface OptionalSubAggregation {
stackByMultipleFields1: {
multi_terms: {
terms: Array<{
field: string;
}>;
};
};
}
export interface CardinalitySubAggregation {
[category: string]: {
cardinality: {
field: string;
};
};
}
export interface TermsSubAggregation {
[category: string]: {
terms: {
field: string;
exclude?: string[];
};
};
}
export const getOptionalSubAggregation = ({
stackByMultipleFields1,
stackByMultipleFields1Size,
stackByMultipleFields1From = 0,
stackByMultipleFields1Sort,
additionalStatsAggregationsFields1,
}: {
stackByMultipleFields1: string[] | undefined;
stackByMultipleFields1Size: number;
stackByMultipleFields1From?: number;
stackByMultipleFields1Sort?: Array<{ [category: string]: { order: 'asc' | 'desc' } }>;
additionalStatsAggregationsFields1: Array<CardinalitySubAggregation | TermsSubAggregation>;
}): OptionalSubAggregation | {} =>
stackByMultipleFields1 != null && !isEmpty(stackByMultipleFields1)
? {
stackByMultipleFields1: {
multi_terms: {
terms: stackByMultipleFields1.map((stackByMultipleField1) => ({
field: stackByMultipleField1,
})),
},
aggs: {
bucket_truncate: {
bucket_sort: {
sort: stackByMultipleFields1Sort,
from: stackByMultipleFields1From,
size: stackByMultipleFields1Size,
},
},
...additionalStatsAggregationsFields1.reduce(
(aggObj, subAgg) => Object.assign(aggObj, subAgg),
{}
),
},
},
}
: {};
export const getGroupingQuery = ({
additionalFilters = [],
additionalAggregationsRoot,
additionalStatsAggregationsFields0,
additionalStatsAggregationsFields1,
from,
runtimeMappings,
stackByMultipleFields0,
stackByMultipleFields0Size = DEFAULT_STACK_BY_FIELD0_SIZE,
stackByMultipleFields0From,
stackByMultipleFields0Sort,
stackByMultipleFields1,
stackByMultipleFields1Size = DEFAULT_STACK_BY_FIELD1_SIZE,
stackByMultipleFields1From,
stackByMultipleFields1Sort,
to,
}: {
additionalFilters: Array<{
bool: { filter: unknown[]; should: unknown[]; must_not: unknown[]; must: unknown[] };
}>;
from: string;
runtimeMappings?: MappingRuntimeFields;
additionalAggregationsRoot?: Array<CardinalitySubAggregation | TermsSubAggregation>;
stackByMultipleFields0: string[];
stackByMultipleFields0Size?: number;
stackByMultipleFields0From?: number;
stackByMultipleFields0Sort?: Array<{ [category: string]: { order: 'asc' | 'desc' } }>;
additionalStatsAggregationsFields0: Array<CardinalitySubAggregation | TermsSubAggregation>;
stackByMultipleFields1: string[] | undefined;
stackByMultipleFields1Size?: number;
stackByMultipleFields1From?: number;
stackByMultipleFields1Sort?: Array<{ [category: string]: { order: 'asc' | 'desc' } }>;
additionalStatsAggregationsFields1: Array<CardinalitySubAggregation | TermsSubAggregation>;
to: string;
}) => ({
size: 0,
aggs: {
stackByMultipleFields0: {
...(stackByMultipleFields0.length > 1
? {
multi_terms: {
terms: stackByMultipleFields0.map((stackByMultipleField0) => ({
field: stackByMultipleField0,
})),
},
}
: {
terms: {
field: stackByMultipleFields0[0],
size: 10000,
},
}),
aggs: {
...getOptionalSubAggregation({
stackByMultipleFields1,
stackByMultipleFields1Size,
stackByMultipleFields1From,
stackByMultipleFields1Sort,
additionalStatsAggregationsFields1,
}),
bucket_truncate: {
bucket_sort: {
sort: stackByMultipleFields0Sort,
from: stackByMultipleFields0From,
size: stackByMultipleFields0Size,
},
},
...additionalStatsAggregationsFields0.reduce(
(aggObj, subAgg) => Object.assign(aggObj, subAgg),
{}
),
},
},
...(additionalAggregationsRoot
? additionalAggregationsRoot.reduce((aggObj, subAgg) => Object.assign(aggObj, subAgg), {})
: {}),
},
query: {
bool: {
filter: [
...additionalFilters,
{
range: {
'@timestamp': {
gte: from,
lte: to,
},
},
},
],
},
},
runtime_mappings: runtimeMappings,
_source: false,
});

View file

@ -0,0 +1,94 @@
/*
* 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 { EuiButtonEmpty, EuiContextMenu } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import styled from 'styled-components';
export const GroupsUnitCount = styled.span`
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold};
border-right: ${({ theme }) => theme.eui.euiBorderThin};
margin-right: 16px;
padding-right: 16px;
`;
export const StatsContainer = styled.span`
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold};
border-right: ${({ theme }) => theme.eui.euiBorderThin};
margin-right: 16px;
padding-right: 16px;
.smallDot {
width: 3px !important;
display: inline-block;
}
.euiBadge__text {
text-align: center;
width: 100%;
}
`;
export const GroupingStyledContainer = styled.div`
.euiAccordion__childWrapper .euiAccordion__padding--m {
margin-left: 8px;
margin-right: 8px;
border-left: ${({ theme }) => theme.eui.euiBorderThin};
border-right: ${({ theme }) => theme.eui.euiBorderThin};
border-bottom: ${({ theme }) => theme.eui.euiBorderThin};
border-radius: 0 0 6px 6px;
}
.euiAccordion__triggerWrapper {
border-bottom: ${({ theme }) => theme.eui.euiBorderThin};
border-left: ${({ theme }) => theme.eui.euiBorderThin};
border-right: ${({ theme }) => theme.eui.euiBorderThin};
border-radius: 6px;
min-height: 78px;
padding-left: 16px;
padding-right: 16px;
}
.groupingAccordionForm {
border-top: ${({ theme }) => theme.eui.euiBorderThin};
border-bottom: none;
border-radius: 6px;
min-width: 1090px;
}
.groupingAccordionForm__button {
text-decoration: none !important;
}
.groupingPanelRenderer {
display: table;
table-layout: fixed;
width: 100%;
padding-right: 32px;
}
`;
export const StyledContextMenu = euiStyled(EuiContextMenu)`
width: 250px;
& .euiContextMenuItem__text {
overflow: hidden;
text-overflow: ellipsis;
}
.euiContextMenuItem {
border-bottom: ${({ theme }) => theme.eui.euiBorderThin};
}
.euiContextMenuItem:last-child {
border: none;
}
`;
export const StyledEuiButtonEmpty = euiStyled(EuiButtonEmpty)`
font-weight: 'normal';
.euiButtonEmpty__text {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`;

View file

@ -0,0 +1,32 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const GROUPS_UNIT = (totalCount: number) =>
i18n.translate('xpack.securitySolution.grouping.total.unit', {
values: { totalCount },
defaultMessage: `{totalCount, plural, =1 {group} other {groups}}`,
});
export const TAKE_ACTION = i18n.translate(
'xpack.securitySolution.grouping.additionalActions.takeAction',
{
defaultMessage: 'Take actions',
}
);
export const TECHNICAL_PREVIEW = i18n.translate(
'xpack.securitySolution.grouping.technicalPreviewLabel',
{
defaultMessage: 'Technical Preview',
}
);
export const GROUP_BY = i18n.translate('xpack.securitySolution.selector.grouping.label', {
defaultMessage: 'Group by field',
});

View file

@ -0,0 +1,66 @@
/*
* 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 { GenericBuckets } from '../../../../common/search_strategy/common';
export const DEFAULT_GROUPING_QUERY_ID = 'defaultGroupingQuery';
export const NONE_GROUP_KEY = 'none';
export type RawBucket = GenericBuckets & {
alertsCount?: {
value?: number | null; // Elasticsearch returns `null` when a sub-aggregation cannot be computed
};
severitiesSubAggregation?: {
buckets?: GenericBuckets[];
};
countSeveritySubAggregation?: {
value?: number | null; // Elasticsearch returns `null` when a sub-aggregation cannot be computed
};
usersCountAggregation?: {
value?: number | null; // Elasticsearch returns `null` when a sub-aggregation cannot be computed
};
hostsCountAggregation?: {
value?: number | null; // Elasticsearch returns `null` when a sub-aggregation cannot be computed
};
rulesCountAggregation?: {
value?: number | null; // Elasticsearch returns `null` when a sub-aggregation cannot be computed
};
ruleTags?: {
doc_count_error_upper_bound?: number;
sum_other_doc_count?: number;
buckets?: GenericBuckets[];
};
stackByMultipleFields1?: {
buckets?: GenericBuckets[];
doc_count_error_upper_bound?: number;
sum_other_doc_count?: number;
};
};
/** Defines the shape of the aggregation returned by Elasticsearch */
export interface GroupingTableAggregation {
stackByMultipleFields0?: {
buckets?: RawBucket[];
};
groupsCount0?: {
value?: number | null;
};
}
export type GroupingFieldTotalAggregation = Record<
string,
{ value?: number | null; buckets?: Array<{ doc_count?: number | null }> }
>;
export type FlattenedBucket = Pick<
RawBucket,
'doc_count' | 'key' | 'key_as_string' | 'alertsCount'
> & {
stackByMultipleFields1Key?: string;
stackByMultipleFields1DocCount?: number;
};

View file

@ -94,7 +94,6 @@ export const LandingCards = memo(() => {
<EuiFlexItem>
<iframe
allowFullScreen
allowTransparency
className="vidyard_iframe"
frameBorder="0"
height="100%"

View file

@ -853,6 +853,8 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
}
onRuleChange={refreshRule}
to={to}
signalIndexName={signalIndexName}
runtimeMappings={runtimeMappings}
/>
)}
</>

View file

@ -85,6 +85,8 @@ export const getAlertsHistogramQuery = (
},
},
runtime_mappings: runtimeMappings,
_source: false,
size: 0,
};
};

View file

@ -0,0 +1,56 @@
/*
* 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 { shallow } from 'enzyme';
import { getSelectedGroupButtonContent } from '.';
describe('getSelectedGroupButtonContent', () => {
it('renders correctly when the field renderer exists', () => {
const wrapperRuleName = shallow(
getSelectedGroupButtonContent('kibana.alert.rule.name', {
key: ['Rule name test', 'Some description'],
doc_count: 10,
})!
);
expect(wrapperRuleName.find('[data-test-subj="rule-name-group-renderer"]')).toBeTruthy();
const wrapperHostName = shallow(
getSelectedGroupButtonContent('host.name', {
key: 'Host',
doc_count: 2,
})!
);
expect(wrapperHostName.find('[data-test-subj="host-name-group-renderer"]')).toBeTruthy();
const wrapperUserName = shallow(
getSelectedGroupButtonContent('user.name', {
key: 'User test',
doc_count: 1,
})!
);
expect(wrapperUserName.find('[data-test-subj="host-name-group-renderer"]')).toBeTruthy();
const wrapperSourceIp = shallow(
getSelectedGroupButtonContent('source.ip', {
key: 'sourceIp',
doc_count: 23,
})!
);
expect(wrapperSourceIp.find('[data-test-subj="source-ip-group-renderer"]')).toBeTruthy();
});
it('returns undefined when the renderer does not exist', () => {
const wrapper = getSelectedGroupButtonContent('process.name', {
key: 'process',
doc_count: 10,
});
expect(wrapper).toBeUndefined();
});
});

View file

@ -0,0 +1,150 @@
/*
* 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 {
EuiAvatar,
EuiBadge,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiText,
EuiTextColor,
EuiTitle,
} from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import { isArray } from 'lodash/fp';
import React from 'react';
import { firstNonNullValue } from '../../../../../common/endpoint/models/ecs_safety_helpers';
import type { GenericBuckets } from '../../../../../common/search_strategy';
import type { RawBucket } from '../../../../common/components/grouping';
import { PopoverItems } from '../../../../common/components/popover_items';
import { COLUMN_TAGS } from '../../../pages/detection_engine/rules/translations';
export const getSelectedGroupButtonContent = (selectedGroup: string, bucket: RawBucket) => {
switch (selectedGroup) {
case 'kibana.alert.rule.name':
return isArray(bucket.key) ? (
<RuleNameGroupContent
ruleName={bucket.key[0]}
ruleDescription={bucket.key[1]}
tags={bucket.ruleTags?.buckets}
/>
) : undefined;
case 'host.name':
return <HostNameGroupContent hostName={bucket.key} />;
case 'user.name':
return <UserNameGroupContent userName={bucket.key} />;
case 'source.ip':
return <SourceIpGroupContent sourceIp={bucket.key} />;
}
};
const RuleNameGroupContent = React.memo<{
ruleName: string;
ruleDescription: string;
tags?: GenericBuckets[] | undefined;
}>(({ ruleName, ruleDescription, tags }) => {
const renderItem = (tag: string, i: number) => (
<EuiBadge color="hollow" key={`${tag}-${i}`} data-test-subj="tag">
{tag}
</EuiBadge>
);
return (
<>
<EuiFlexGroup data-test-subj="rule-name-group-renderer" gutterSize="m" alignItems="center">
<EuiFlexItem grow={false} style={{ display: 'table', tableLayout: 'fixed', width: '100%' }}>
<EuiTitle size="xs">
<h5 className="eui-textTruncate">{ruleName.trim()}</h5>
</EuiTitle>
</EuiFlexItem>
{tags && tags.length > 0 ? (
<EuiFlexItem onClick={(e) => e.stopPropagation()} grow={false}>
<PopoverItems
items={tags.map((tag) => tag.key.toString())}
popoverTitle={COLUMN_TAGS}
popoverButtonTitle={tags.length.toString()}
popoverButtonIcon="tag"
dataTestPrefix="tags"
renderItem={renderItem}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
<EuiText size="s">
<p className="eui-textTruncate">
<EuiTextColor color="subdued">{ruleDescription}</EuiTextColor>
</p>
</EuiText>
</>
);
});
RuleNameGroupContent.displayName = 'RuleNameGroup';
const HostNameGroupContent = React.memo<{ hostName: string | string[] }>(({ hostName }) => (
<EuiFlexGroup
data-test-subj="host-name-group-renderer"
gutterSize="s"
alignItems="center"
justifyContent="center"
>
<EuiFlexItem
grow={false}
style={{
backgroundColor: euiThemeVars.euiColorVis1_behindText,
borderRadius: '50%',
}}
>
<EuiIcon type="database" size="l" style={{ padding: 4 }} />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs">
<h5>{hostName}</h5>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
));
HostNameGroupContent.displayName = 'HostNameGroupContent';
const UserNameGroupContent = React.memo<{ userName: string | string[] }>(({ userName }) => {
const userNameValue = firstNonNullValue(userName) ?? '-';
return (
<EuiFlexGroup data-test-subj="user-name-group-renderer" gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiAvatar name={userNameValue} color={euiThemeVars.euiColorVis0} />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs">
<h5>{userName}</h5>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
);
});
UserNameGroupContent.displayName = 'UserNameGroupContent';
const SourceIpGroupContent = React.memo<{ sourceIp: string | string[] }>(({ sourceIp }) => (
<EuiFlexGroup data-test-subj="source-ip-group-renderer" gutterSize="s" alignItems="center">
<EuiFlexItem
grow={false}
style={{
backgroundColor: euiThemeVars.euiColorVis3_behindText,
borderRadius: '50%',
}}
>
<EuiIcon style={{ padding: 4 }} type="ip" size="l" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs">
<h5>{sourceIp}</h5>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
));
SourceIpGroupContent.displayName = 'SourceIpGroupContent';

View file

@ -0,0 +1,64 @@
/*
* 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 { getSelectedGroupBadgeMetrics } from '.';
describe('getSelectedGroupBadgeMetrics', () => {
it('returns array of badges which roccespondes to the field name', () => {
const badgesRuleName = getSelectedGroupBadgeMetrics('kibana.alert.rule.name', {
key: ['Rule name test', 'Some description'],
usersCountAggregation: {
value: 10,
},
doc_count: 10,
});
expect(
badgesRuleName.find((badge) => badge.title === 'Users:' && badge.value === 10)
).toBeTruthy();
expect(
badgesRuleName.find((badge) => badge.title === 'Alerts:' && badge.value === 10)
).toBeTruthy();
const badgesHostName = getSelectedGroupBadgeMetrics('host.name', {
key: 'Host',
rulesCountAggregation: {
value: 3,
},
doc_count: 2,
});
expect(
badgesHostName.find((badge) => badge.title === 'Rules:' && badge.value === 3)
).toBeTruthy();
const badgesUserName = getSelectedGroupBadgeMetrics('user.name', {
key: 'User test',
hostsCountAggregation: {
value: 1,
},
doc_count: 1,
});
expect(
badgesUserName.find((badge) => badge.title === `IP's:` && badge.value === 1)
).toBeTruthy();
});
it('returns default badges if the field specific does not exist', () => {
const badges = getSelectedGroupBadgeMetrics('process.name', {
key: 'process',
rulesCountAggregation: {
value: 3,
},
doc_count: 10,
});
expect(badges.length).toBe(2);
expect(badges.find((badge) => badge.title === 'Rules:' && badge.value === 3)).toBeTruthy();
expect(badges.find((badge) => badge.title === 'Alerts:' && badge.value === 10)).toBeTruthy();
});
});

View file

@ -0,0 +1,180 @@
/*
* 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 { EuiIcon } from '@elastic/eui';
import React from 'react';
import type {
BadgeMetric,
CustomMetric,
} from '../../../../common/components/grouping/accordion_panel';
import type { RawBucket } from '../../../../common/components/grouping';
import * as i18n from '../translations';
const getSingleGroupSeverity = (severity?: string) => {
switch (severity) {
case 'low':
return (
<>
<EuiIcon type="dot" color="#54b399" />
{i18n.STATS_GROUP_SEVERITY_LOW}
</>
);
case 'medium':
return (
<>
<EuiIcon type="dot" color="#d6bf57" />
{i18n.STATS_GROUP_SEVERITY_MEDIUM}
</>
);
case 'high':
return (
<>
<EuiIcon type="dot" color="#da8b45" />
{i18n.STATS_GROUP_SEVERITY_HIGH}
</>
);
case 'critical':
return (
<>
<EuiIcon type="dot" color="#e7664c" />
{i18n.STATS_GROUP_SEVERITY_CRITICAL}
</>
);
}
return null;
};
const multiSeverity = (
<>
<span className="smallDot">
<EuiIcon type="dot" color="#54b399" />
</span>
<span className="smallDot">
<EuiIcon type="dot" color="#d6bf57" />
</span>
<span className="smallDot">
<EuiIcon type="dot" color="#da8b45" />
</span>
<span>
<EuiIcon type="dot" color="#e7664c" />
</span>
{i18n.STATS_GROUP_SEVERITY_MULTI}
</>
);
export const getSelectedGroupBadgeMetrics = (
selectedGroup: string,
bucket: RawBucket
): BadgeMetric[] => {
const defaultBadges = [
{
title: i18n.STATS_GROUP_ALERTS,
value: bucket.doc_count,
width: 50,
color: '#a83632',
},
];
switch (selectedGroup) {
case 'kibana.alert.rule.name':
return [
{
title: i18n.STATS_GROUP_USERS,
value: bucket.usersCountAggregation?.value ?? 0,
},
{
title: i18n.STATS_GROUP_HOSTS,
value: bucket.hostsCountAggregation?.value ?? 0,
},
...defaultBadges,
];
case 'host.name':
return [
{
title: i18n.STATS_GROUP_USERS,
value: bucket.usersCountAggregation?.value ?? 0,
},
{
title: i18n.STATS_GROUP_RULES,
value: bucket.rulesCountAggregation?.value ?? 0,
},
...defaultBadges,
];
case 'user.name':
return [
{
title: i18n.STATS_GROUP_IPS,
value: bucket.hostsCountAggregation?.value ?? 0,
},
{
title: i18n.STATS_GROUP_RULES,
value: bucket.rulesCountAggregation?.value ?? 0,
},
...defaultBadges,
];
case 'source.ip':
return [
{
title: i18n.STATS_GROUP_IPS,
value: bucket.hostsCountAggregation?.value ?? 0,
},
{
title: i18n.STATS_GROUP_RULES,
value: bucket.rulesCountAggregation?.value ?? 0,
},
...defaultBadges,
];
}
return [
{
title: i18n.STATS_GROUP_RULES,
value: bucket.rulesCountAggregation?.value ?? 0,
},
...defaultBadges,
];
};
export const getSelectedGroupCustomMetrics = (
selectedGroup: string,
bucket: RawBucket
): CustomMetric[] => {
const singleSeverityComponent =
bucket.severitiesSubAggregation?.buckets && bucket.severitiesSubAggregation?.buckets?.length
? getSingleGroupSeverity(bucket.severitiesSubAggregation?.buckets[0].key.toString())
: null;
const severityComponent =
bucket.countSeveritySubAggregation?.value && bucket.countSeveritySubAggregation?.value > 1
? multiSeverity
: singleSeverityComponent;
if (!severityComponent) {
return [];
}
switch (selectedGroup) {
case 'kibana.alert.rule.name':
return [
{
title: i18n.STATS_GROUP_SEVERITY,
customStatRenderer: severityComponent,
},
];
case 'host.name':
return [
{
title: i18n.STATS_GROUP_SEVERITY,
customStatRenderer: severityComponent,
},
];
case 'user.name':
return [
{
title: i18n.STATS_GROUP_SEVERITY,
customStatRenderer: severityComponent,
},
];
}
return [];
};

View file

@ -0,0 +1,61 @@
/*
* 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 { TestProviders } from '@kbn/timelines-plugin/public/mock';
import { act, renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { useGroupTakeActionsItems } from '.';
jest.mock('../../../../common/store', () => ({
inputsSelectors: {
globalQuery: jest.fn(),
},
inputsModel: {},
}));
jest.mock('../../../../common/hooks/use_selector', () => ({
useDeepEqualSelector: () => jest.fn(),
}));
describe('useGroupTakeActionsItems', () => {
const wrapperContainer: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
<TestProviders>{children}</TestProviders>
);
it('returns array take actions items available for alerts table if showAlertStatusActions is true', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useGroupTakeActionsItems({
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
wrapper: wrapperContainer,
}
);
await waitForNextUpdate();
expect(result.current().length).toEqual(3);
});
});
it('returns empty array of take actions items available for alerts table if showAlertStatusActions is false', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useGroupTakeActionsItems({
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: false,
}),
{
wrapper: wrapperContainer,
}
);
await waitForNextUpdate();
expect(result.current().length).toEqual(0);
});
});
});

View file

@ -0,0 +1,188 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useCallback } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import type { inputsModel } from '../../../../common/store';
import { inputsSelectors } from '../../../../common/store';
import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import type { AlertWorkflowStatus } from '../../../../common/types';
import { APM_USER_INTERACTIONS } from '../../../../common/lib/apm/constants';
import { useUpdateAlertsStatus } from '../../../../common/components/toolbar/bulk_actions/use_update_alerts';
import {
BULK_ACTION_ACKNOWLEDGED_SELECTED,
BULK_ACTION_CLOSE_SELECTED,
BULK_ACTION_OPEN_SELECTED,
} from '../../../../common/components/toolbar/bulk_actions/translations';
import {
UPDATE_ALERT_STATUS_FAILED,
UPDATE_ALERT_STATUS_FAILED_DETAILED,
} from '../../../../common/translations';
import { FILTER_ACKNOWLEDGED, FILTER_CLOSED, FILTER_OPEN } from '../../../../../common/types';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import * as i18n from '../translations';
export interface TakeActionsProps {
currentStatus?: AlertWorkflowStatus;
indexName: string;
showAlertStatusActions?: boolean;
}
export const useGroupTakeActionsItems = ({
currentStatus,
indexName,
showAlertStatusActions = true,
}: TakeActionsProps) => {
const { updateAlertStatus } = useUpdateAlertsStatus();
const { addSuccess, addError, addWarning } = useAppToasts();
const { startTransaction } = useStartTransaction();
const getGlobalQuerySelector = inputsSelectors.globalQuery();
const globalQueries = useDeepEqualSelector(getGlobalQuerySelector);
const refetchQuery = useCallback(() => {
globalQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)());
}, [globalQueries]);
const onUpdateSuccess = useCallback(
(updated: number, conflicts: number, newStatus: AlertWorkflowStatus) => {
refetchQuery();
},
[refetchQuery]
);
const onUpdateFailure = useCallback(
(newStatus: AlertWorkflowStatus, error: Error) => {
refetchQuery();
},
[refetchQuery]
);
const onAlertStatusUpdateSuccess = useCallback(
(updated: number, conflicts: number, newStatus: AlertWorkflowStatus) => {
if (conflicts > 0) {
// Partial failure
addWarning({
title: UPDATE_ALERT_STATUS_FAILED(conflicts),
text: UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts),
});
} else {
let title: string;
switch (newStatus) {
case 'closed':
title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated);
break;
case 'open':
title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated);
break;
case 'acknowledged':
title = i18n.ACKNOWLEDGED_ALERT_SUCCESS_TOAST(updated);
}
addSuccess({ title });
}
if (onUpdateSuccess) {
onUpdateSuccess(updated, conflicts, newStatus);
}
},
[addSuccess, addWarning, onUpdateSuccess]
);
const onAlertStatusUpdateFailure = useCallback(
(newStatus: AlertWorkflowStatus, error: Error) => {
let title: string;
switch (newStatus) {
case 'closed':
title = i18n.CLOSED_ALERT_FAILED_TOAST;
break;
case 'open':
title = i18n.OPENED_ALERT_FAILED_TOAST;
break;
case 'acknowledged':
title = i18n.ACKNOWLEDGED_ALERT_FAILED_TOAST;
}
addError(error.message, { title });
if (onUpdateFailure) {
onUpdateFailure(newStatus, error);
}
},
[addError, onUpdateFailure]
);
const onClickUpdate = useCallback(
async (status: AlertWorkflowStatus, query?: string) => {
if (query) {
startTransaction({ name: APM_USER_INTERACTIONS.BULK_QUERY_STATUS_UPDATE });
} else {
startTransaction({ name: APM_USER_INTERACTIONS.STATUS_UPDATE });
}
try {
const response = await updateAlertStatus({
index: indexName,
status,
query: query ? JSON.parse(query) : {},
});
onAlertStatusUpdateSuccess(response.updated ?? 0, response.version_conflicts ?? 0, status);
} catch (error) {
onAlertStatusUpdateFailure(status, error);
}
},
[
updateAlertStatus,
indexName,
onAlertStatusUpdateSuccess,
onAlertStatusUpdateFailure,
startTransaction,
]
);
const items = useMemo(() => {
const getActionItems = (query?: string) => {
const actionItems: JSX.Element[] = [];
if (showAlertStatusActions) {
if (currentStatus !== FILTER_OPEN) {
actionItems.push(
<EuiContextMenuItem
key="open"
data-test-subj="open-alert-status"
onClick={() => onClickUpdate(FILTER_OPEN as AlertWorkflowStatus, query)}
>
{BULK_ACTION_OPEN_SELECTED}
</EuiContextMenuItem>
);
}
if (currentStatus !== FILTER_ACKNOWLEDGED) {
actionItems.push(
<EuiContextMenuItem
key="acknowledge"
data-test-subj="acknowledged-alert-status"
onClick={() => onClickUpdate(FILTER_ACKNOWLEDGED as AlertWorkflowStatus, query)}
>
{BULK_ACTION_ACKNOWLEDGED_SELECTED}
</EuiContextMenuItem>
);
}
if (currentStatus !== FILTER_CLOSED) {
actionItems.push(
<EuiContextMenuItem
key="close"
data-test-subj="close-alert-status"
onClick={() => onClickUpdate(FILTER_CLOSED as AlertWorkflowStatus, query)}
>
{BULK_ACTION_CLOSE_SELECTED}
</EuiContextMenuItem>
);
}
}
return actionItems;
};
return getActionItems;
}, [currentStatus, onClickUpdate, showAlertStatusActions]);
return items;
};

View file

@ -0,0 +1,56 @@
/*
* 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 { TableId } from '../../../../../common/types';
import * as i18n from '../translations';
export * from './group_stats';
export * from './group_panel_renderers';
export * from './group_take_action_items';
export * from './query_builder';
export const getDefaultGroupingOptions = (tableId: TableId) => {
if (tableId === TableId.alertsOnAlertsPage) {
return [
{
label: i18n.ruleName,
key: 'kibana.alert.rule.name',
},
{
label: i18n.userName,
key: 'user.name',
},
{
label: i18n.hostName,
key: 'host.name',
},
{
label: i18n.sourceIP,
key: 'source.ip',
},
];
} else if (tableId === TableId.alertsOnRuleDetailsPage) {
return [
{
label: i18n.sourceAddress,
key: 'source.address',
},
{
label: i18n.userName,
key: 'user.name',
},
{
label: i18n.hostName,
key: 'host.name',
},
{
label: i18n.destinationAddress,
key: 'destination.address,',
},
];
}
return [];
};

View file

@ -0,0 +1,258 @@
/*
* 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 { getAlertsGroupingQuery } from '.';
describe('getAlertsGroupingQuery', () => {
it('returns query with aggregations for kibana.alert.rule.name', () => {
const groupingQuery = getAlertsGroupingQuery({
from: '2022-12-29T22:57:34.029Z',
to: '2023-01-28T22:57:29.029Z',
pageIndex: 0,
pageSize: 25,
runtimeMappings: {},
selectedGroup: 'kibana.alert.rule.name',
additionalFilters: [
{
bool: {
must: [],
filter: [
{
match_phrase: {
'kibana.alert.workflow_status': 'acknowledged',
},
},
],
should: [],
must_not: [
{
exists: {
field: 'kibana.alert.building_block_type',
},
},
],
},
},
],
});
expect(groupingQuery).toStrictEqual({
_source: false,
aggs: {
alertsCount: {
terms: {
exclude: ['alerts'],
field: 'kibana.alert.rule.producer',
},
},
groupsNumber: {
cardinality: {
field: 'kibana.alert.rule.name',
},
},
stackByMultipleFields0: {
aggs: {
alertsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
bucket_truncate: {
bucket_sort: {
from: 0,
size: 25,
sort: undefined,
},
},
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
ruleTags: {
terms: {
field: 'kibana.alert.rule.tags',
},
},
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
usersCountAggregation: {
cardinality: {
field: 'user.name',
},
},
},
multi_terms: {
terms: [
{
field: 'kibana.alert.rule.name',
},
{
field: 'kibana.alert.rule.description',
},
],
},
},
},
query: {
bool: {
filter: [
{
bool: {
filter: [
{
match_phrase: {
'kibana.alert.workflow_status': 'acknowledged',
},
},
],
must: [],
must_not: [
{
exists: {
field: 'kibana.alert.building_block_type',
},
},
],
should: [],
},
},
{
range: {
'@timestamp': {
gte: '2022-12-29T22:57:34.029Z',
lte: '2023-01-28T22:57:29.029Z',
},
},
},
],
},
},
runtime_mappings: {},
size: 0,
});
});
it('returns default query with aggregations if the field specific metrics was not defined', () => {
const groupingQuery = getAlertsGroupingQuery({
from: '2022-12-29T22:57:34.029Z',
to: '2023-01-28T22:57:29.029Z',
pageIndex: 0,
pageSize: 25,
runtimeMappings: {},
selectedGroup: 'process.name',
additionalFilters: [
{
bool: {
must: [],
filter: [
{
match_phrase: {
'kibana.alert.workflow_status': 'acknowledged',
},
},
],
should: [],
must_not: [
{
exists: {
field: 'kibana.alert.building_block_type',
},
},
],
},
},
],
});
expect(groupingQuery).toStrictEqual({
_source: false,
aggs: {
alertsCount: {
terms: {
exclude: ['alerts'],
field: 'kibana.alert.rule.producer',
},
},
groupsNumber: {
cardinality: {
field: 'process.name',
},
},
stackByMultipleFields0: {
aggs: {
alertsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
bucket_truncate: {
bucket_sort: {
from: 0,
size: 25,
sort: undefined,
},
},
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
},
terms: {
field: 'process.name',
size: 10000,
},
},
},
query: {
bool: {
filter: [
{
bool: {
filter: [
{
match_phrase: {
'kibana.alert.workflow_status': 'acknowledged',
},
},
],
must: [],
must_not: [
{
exists: {
field: 'kibana.alert.building_block_type',
},
},
],
should: [],
},
},
{
range: {
'@timestamp': {
gte: '2022-12-29T22:57:34.029Z',
lte: '2023-01-28T22:57:29.029Z',
},
},
},
],
},
},
runtime_mappings: {},
size: 0,
});
});
});

View file

@ -0,0 +1,253 @@
/*
* 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 { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import type { BoolQuery } from '@kbn/es-query';
import type {
CardinalitySubAggregation,
TermsSubAggregation,
} from '../../../../common/components/grouping';
import { getGroupingQuery } from '../../../../common/components/grouping';
const getGroupFields = (groupValue: string) => {
if (groupValue === 'kibana.alert.rule.name') {
return [groupValue, 'kibana.alert.rule.description'];
} else {
return [groupValue];
}
};
interface AlertsGroupingQueryParams {
from: string;
to: string;
additionalFilters: Array<{
bool: BoolQuery;
}>;
selectedGroup: string;
runtimeMappings: MappingRuntimeFields;
pageSize: number;
pageIndex: number;
}
export const getAlertsGroupingQuery = ({
from,
to,
additionalFilters,
selectedGroup,
runtimeMappings,
pageSize,
pageIndex,
}: AlertsGroupingQueryParams) =>
getGroupingQuery({
additionalFilters,
additionalAggregationsRoot: [
{
alertsCount: {
terms: {
field: 'kibana.alert.rule.producer',
exclude: ['alerts'],
},
},
},
...(selectedGroup !== 'none'
? [
{
groupsNumber: {
cardinality: {
field: selectedGroup,
},
},
},
]
: []),
],
from,
runtimeMappings,
stackByMultipleFields0: selectedGroup !== 'none' ? getGroupFields(selectedGroup) : [],
to,
additionalStatsAggregationsFields0:
selectedGroup !== 'none' ? getAggregationsByGroupField(selectedGroup) : [],
stackByMultipleFields0Size: pageSize,
stackByMultipleFields0From: pageIndex * pageSize,
additionalStatsAggregationsFields1: [],
stackByMultipleFields1: [],
});
const getAggregationsByGroupField = (
field: string
): Array<CardinalitySubAggregation | TermsSubAggregation> => {
const aggMetrics: Array<CardinalitySubAggregation | TermsSubAggregation> = [
{
alertsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
},
];
switch (field) {
case 'kibana.alert.rule.name':
aggMetrics.push(
...[
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
},
{
usersCountAggregation: {
cardinality: {
field: 'user.name',
},
},
},
{
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
},
{
ruleTags: {
terms: {
field: 'kibana.alert.rule.tags',
},
},
},
]
);
break;
case 'host.name':
aggMetrics.push(
...[
{
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
},
{
usersCountAggregation: {
cardinality: {
field: 'user.name',
},
},
},
]
);
break;
case 'user.name':
aggMetrics.push(
...[
{
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
},
{
usersCountAggregation: {
cardinality: {
field: 'host.name',
},
},
},
{
usersCountAggregation: {
cardinality: {
field: 'user.name',
},
},
},
]
);
break;
case 'source.ip':
aggMetrics.push(
...[
{
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
},
{
usersCountAggregation: {
cardinality: {
field: 'host.name',
},
},
},
]
);
break;
default:
aggMetrics.push({
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
});
}
return aggMetrics;
};

View file

@ -6,14 +6,162 @@
*/
import React from 'react';
import { shallow } from 'enzyme';
import { mount, shallow } from 'enzyme';
import { waitFor } from '@testing-library/react';
import useResizeObserver from 'use-resize-observer/polyfilled';
import '../../../common/mock/match_media';
import { TestProviders } from '../../../common/mock';
import {
createSecuritySolutionStorageMock,
kibanaObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
TestProviders,
} from '../../../common/mock';
import { AlertsTableComponent } from '.';
import { TableId } from '../../../../common/types';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import type { UseFieldBrowserOptionsProps } from '../../../timelines/components/fields_browser';
import { mockCasesContext } from '@kbn/cases-plugin/public/mocks/mock_cases_context';
import { mockTimelines } from '../../../common/mock/mock_timelines_plugin';
import { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_manager/filter_manager.mock';
import type { State } from '../../../common/store';
import { createStore } from '../../../common/store';
jest.mock('../../../common/containers/sourcerer');
jest.mock('../../../common/containers/use_global_time', () => ({
useGlobalTime: jest.fn().mockReturnValue({
from: '2020-07-07T08:20:18.966Z',
isInitializing: false,
to: '2020-07-08T08:20:18.966Z',
setQuery: jest.fn(),
}),
}));
jest.mock('./grouping_settings', () => ({
getAlertsGroupingQuery: jest.fn(),
getDefaultGroupingOptions: () => [
{
label: 'ruleName',
key: 'kibana.alert.rule.name',
},
{
label: 'userName',
key: 'user.name',
},
{
label: 'hostName',
key: 'host.name',
},
{
label: 'sourceIP',
key: 'source.ip',
},
],
getSelectedGroupBadgeMetrics: jest.fn(),
getSelectedGroupButtonContent: jest.fn(),
getSelectedGroupCustomMetrics: jest.fn(),
useGroupTakeActionsItems: jest.fn(),
}));
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
jest.mock('../../../common/utils/normalize_time_range');
const mockUseFieldBrowserOptions = jest.fn();
jest.mock('../../../timelines/components/fields_browser', () => ({
useFieldBrowserOptions: (props: UseFieldBrowserOptionsProps) => mockUseFieldBrowserOptions(props),
}));
const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock;
jest.mock('use-resize-observer/polyfilled');
mockUseResizeObserver.mockImplementation(() => ({}));
const mockFilterManager = createFilterManagerMock();
jest.mock('../../../common/lib/kibana', () => {
const original = jest.requireActual('../../../common/lib/kibana');
return {
...original,
useUiSetting$: jest.fn().mockReturnValue([]),
useKibana: () => ({
services: {
application: {
navigateToUrl: jest.fn(),
capabilities: {
siem: { crud_alerts: true, read_alerts: true },
},
},
cases: {
ui: { getCasesContext: mockCasesContext },
},
uiSettings: {
get: jest.fn(),
},
timelines: { ...mockTimelines },
data: {
query: {
filterManager: mockFilterManager,
},
},
docLinks: {
links: {
siem: {
privileges: 'link',
},
},
},
storage: {
get: jest.fn(),
set: jest.fn(),
},
},
}),
useToasts: jest.fn().mockReturnValue({
addError: jest.fn(),
addSuccess: jest.fn(),
addWarning: jest.fn(),
remove: jest.fn(),
}),
};
});
const state: State = {
...mockGlobalState,
};
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
jest.mock('./timeline_actions/use_add_bulk_to_timeline', () => ({
useAddBulkToTimelineAction: jest.fn(() => {}),
}));
jest.mock('./timeline_actions/use_bulk_add_to_case_actions', () => ({
useBulkAddToCaseActions: jest.fn(() => []),
}));
const sourcererDataView = {
indicesExist: true,
loading: false,
indexPattern: {
fields: [],
},
};
describe('AlertsTableComponent', () => {
(useSourcererDataView as jest.Mock).mockReturnValue({
...sourcererDataView,
selectedPatterns: ['myFakebeat-*'],
});
it('renders correctly', () => {
const wrapper = shallow(
<TestProviders>
@ -36,10 +184,46 @@ describe('AlertsTableComponent', () => {
showOnlyThreatIndicatorAlerts={false}
onShowOnlyThreatIndicatorAlertsChanged={jest.fn()}
dispatch={jest.fn()}
runtimeMappings={{}}
signalIndexName={'test'}
/>
</TestProviders>
);
expect(wrapper.find('[title="Alerts"]')).toBeTruthy();
});
it('it renders groupping fields options when the grouping field is selected', async () => {
const wrapper = mount(
<TestProviders store={store}>
<AlertsTableComponent
tableId={TableId.test}
hasIndexWrite
hasIndexMaintenance
from={'2020-07-07T08:20:18.966Z'}
loading={false}
to={'2020-07-08T08:20:18.966Z'}
globalQuery={{
query: 'query',
language: 'language',
}}
globalFilters={[]}
loadingEventIds={[]}
isSelectAllChecked={false}
showBuildingBlockAlerts={false}
onShowBuildingBlockAlertsChanged={jest.fn()}
showOnlyThreatIndicatorAlerts={false}
onShowOnlyThreatIndicatorAlertsChanged={jest.fn()}
dispatch={jest.fn()}
runtimeMappings={{}}
signalIndexName={'test'}
/>
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="group-selector-dropdown"]').exists()).toBe(true);
wrapper.find('[data-test-subj="group-selector-dropdown"]').first().simulate('click');
expect(wrapper.find('[data-test-subj="panel-kibana.alert.rule.name"]').exists()).toBe(true);
});
});
});

View file

@ -6,11 +6,29 @@
*/
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import type { ConnectedProps } from 'react-redux';
import { connect, useDispatch } from 'react-redux';
import { v4 as uuidv4 } from 'uuid';
import type { Filter } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { InspectButton } from '../../../common/components/inspect';
import { defaultUnit } from '../../../common/components/toolbar/unit';
import type {
GroupingFieldTotalAggregation,
GroupingTableAggregation,
RawBucket,
} from '../../../common/components/grouping';
import {
GroupingContainer,
GroupsSelector,
isNoneGroup,
NONE_GROUP_KEY,
} from '../../../common/components/grouping';
import { useGlobalTime } from '../../../common/containers/use_global_time';
import { combineQueries } from '../../../common/lib/kuery';
import type { AlertWorkflowStatus } from '../../../common/types';
import type { TableIdLiteral } from '../../../../common/types';
@ -30,6 +48,8 @@ import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline
import { getDefaultControlColumn } from '../../../timelines/components/timeline/body/control_columns';
import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers';
import { getColumns, RenderCellValue } from '../../configurations/security_solution_detections';
import { useInspectButton } from '../alerts_kpis/common/hooks';
import { AdditionalFiltersAction } from './additional_filters_action';
import {
getAlertsDefaultModel,
@ -41,6 +61,22 @@ import * as i18n from './translations';
import { useLicense } from '../../../common/hooks/use_license';
import { useBulkAddToCaseActions } from './timeline_actions/use_bulk_add_to_case_actions';
import { useAddBulkToTimelineAction } from './timeline_actions/use_add_bulk_to_timeline';
import { useQueryAlerts } from '../../containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../containers/detection_engine/alerts/constants';
import {
getAlertsGroupingQuery,
getDefaultGroupingOptions,
getSelectedGroupBadgeMetrics,
getSelectedGroupButtonContent,
getSelectedGroupCustomMetrics,
useGroupTakeActionsItems,
} from './grouping_settings';
/** This local storage key stores the `Grid / Event rendered view` selection */
export const ALERTS_TABLE_GROUPS_SELECTION_KEY = 'securitySolution.alerts.table.group-selection';
const storage = new Storage(localStorage);
const ALERTS_GROUPING_ID = 'alerts-grouping';
interface OwnProps {
defaultFilters?: Filter[];
@ -56,6 +92,8 @@ interface OwnProps {
tableId: TableIdLiteral;
to: string;
filterGroup?: Status;
runtimeMappings: MappingRuntimeFields;
signalIndexName: string | null;
}
type AlertsTableComponentProps = OwnProps & PropsFromRedux;
@ -78,8 +116,13 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
tableId,
to,
filterGroup,
runtimeMappings,
signalIndexName,
}) => {
const dispatch = useDispatch();
const [selectedGroup, setSelectedGroup] = useState<string>(
storage.get(`${ALERTS_TABLE_GROUPS_SELECTION_KEY}-${tableId}`) ?? NONE_GROUP_KEY
);
const {
browserFields,
@ -211,11 +254,165 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
[addToCaseBulkActions, addBulkToTimelineAction]
);
if (loading || isEmpty(selectedPatterns)) {
const { deleteQuery, setQuery } = useGlobalTime(false);
// create a unique, but stable (across re-renders) query id
const uniqueQueryId = useMemo(() => `${ALERTS_GROUPING_ID}-${uuidv4()}`, []);
const additionalFilters = useMemo(() => {
try {
return [
buildEsQuery(undefined, globalQuery != null ? [globalQuery] : [], [
...(globalFilters?.filter((f) => f.meta.disabled === false) ?? []),
...(defaultFiltersMemo ?? []),
]),
];
} catch (e) {
return [];
}
}, [defaultFiltersMemo, globalFilters, globalQuery]);
const [groupsActivePage, setGroupsActivePage] = useState<number>(0);
const [groupsItemsPerPage, setGroupsItemsPerPage] = useState<number>(25);
const pagination = useMemo(
() => ({
pageIndex: groupsActivePage,
pageSize: groupsItemsPerPage,
onChangeItemsPerPage: (itemsPerPageNumber: number) =>
setGroupsItemsPerPage(itemsPerPageNumber),
onChangePage: (pageNumber: number) => setGroupsActivePage(pageNumber),
}),
[groupsActivePage, groupsItemsPerPage]
);
const queryGroups = useMemo(
() =>
getAlertsGroupingQuery({
additionalFilters,
selectedGroup,
from,
runtimeMappings,
to,
pageSize: pagination.pageSize,
pageIndex: pagination.pageIndex,
}),
[
additionalFilters,
selectedGroup,
from,
runtimeMappings,
to,
pagination.pageSize,
pagination.pageIndex,
]
);
const {
data: alertsGroupsData,
loading: isLoadingGroups,
refetch,
request,
response,
setQuery: setAlertsQuery,
} = useQueryAlerts<{}, GroupingTableAggregation & GroupingFieldTotalAggregation>({
query: queryGroups,
indexName: signalIndexName,
queryName: ALERTS_QUERY_NAMES.ALERTS_GROUPING,
skip: isNoneGroup(selectedGroup),
});
useEffect(() => {
if (!isNoneGroup(selectedGroup)) {
setAlertsQuery(queryGroups);
}
}, [queryGroups, selectedGroup, setAlertsQuery]);
useInspectButton({
deleteQuery,
loading: isLoadingGroups,
response,
setQuery,
refetch,
request,
uniqueQueryId,
});
const inspect = useMemo(
() => (
<InspectButton queryId={uniqueQueryId} inspectIndex={0} title={i18n.INSPECT_GROUPING_TITLE} />
),
[uniqueQueryId]
);
const defaultGroupingOptions = getDefaultGroupingOptions(tableId);
const [options, setOptions] = useState(
defaultGroupingOptions.find((o) => o.key === selectedGroup)
? defaultGroupingOptions
: [
...defaultGroupingOptions,
...(!isNoneGroup(selectedGroup)
? [
{
key: selectedGroup,
label: selectedGroup,
},
]
: []),
]
);
const groupsSelector = useMemo(
() => (
<GroupsSelector
groupSelected={selectedGroup}
data-test-subj="alerts-table-group-selector"
onGroupChange={(groupSelection: string) => {
if (groupSelection === selectedGroup) {
return;
}
storage.set(`${ALERTS_TABLE_GROUPS_SELECTION_KEY}-${tableId}`, groupSelection);
setGroupsActivePage(0);
setSelectedGroup(groupSelection);
if (!isNoneGroup(groupSelection) && !options.find((o) => o.key === groupSelection)) {
setOptions([
...defaultGroupingOptions,
{
label: groupSelection,
key: groupSelection,
},
]);
} else {
setOptions(defaultGroupingOptions);
}
}}
fields={indexPatterns.fields}
options={options}
title={i18n.GROUP_ALERTS_SELECTOR}
/>
),
[defaultGroupingOptions, indexPatterns.fields, options, selectedGroup, tableId]
);
const takeActionItems = useGroupTakeActionsItems({
indexName: indexPatterns.title,
currentStatus: filterGroup as AlertWorkflowStatus,
showAlertStatusActions: hasIndexWrite && hasIndexMaintenance,
});
const getTakeActionItems = useCallback(
(groupFilters: Filter[]) =>
takeActionItems(
getGlobalQuery([...(defaultFiltersMemo ?? []), ...groupFilters])?.filterQuery
),
[defaultFiltersMemo, getGlobalQuery, takeActionItems]
);
if (loading || isLoadingGroups || isEmpty(selectedPatterns)) {
return null;
}
return (
const dataTable = (
<StatefulEventsViewer
additionalFilters={additionalFiltersComponent}
currentFilter={filterGroup as AlertWorkflowStatus}
@ -232,8 +429,58 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
rowRenderers={defaultRowRenderers}
sourcererScope={SourcererScopeName.detections}
start={from}
additionalRightMenuOptions={isNoneGroup(selectedGroup) ? [groupsSelector] : []}
/>
);
return (
<>
{isNoneGroup(selectedGroup) ? (
dataTable
) : (
<>
<GroupingContainer
selectedGroup={selectedGroup}
groupsSelector={groupsSelector}
inspectButton={inspect}
takeActionItems={getTakeActionItems}
data={alertsGroupsData?.aggregations ?? {}}
renderChildComponent={(groupFilter) => (
<StatefulEventsViewer
additionalFilters={additionalFiltersComponent}
currentFilter={filterGroup as AlertWorkflowStatus}
defaultCellActions={defaultCellActions}
defaultModel={getAlertsDefaultModel(license)}
end={to}
bulkActions={bulkActions}
hasCrudPermissions={hasIndexWrite && hasIndexMaintenance}
tableId={tableId}
leadingControlColumns={leadingControlColumns}
onRuleChange={onRuleChange}
pageFilters={[...(defaultFiltersMemo ?? []), ...groupFilter]}
renderCellValue={RenderCellValue}
rowRenderers={defaultRowRenderers}
sourcererScope={SourcererScopeName.detections}
start={from}
additionalRightMenuOptions={isNoneGroup(selectedGroup) ? [groupsSelector] : []}
/>
)}
unit={defaultUnit}
pagination={pagination}
groupPanelRenderer={(fieldBucket: RawBucket) =>
getSelectedGroupButtonContent(selectedGroup, fieldBucket)
}
badgeMetricStats={(fieldBucket: RawBucket) =>
getSelectedGroupBadgeMetrics(selectedGroup, fieldBucket)
}
customMetricStats={(fieldBucket: RawBucket) =>
getSelectedGroupCustomMetrics(selectedGroup, fieldBucket)
}
/>
</>
)}
</>
);
};
const makeMapStateToProps = () => {

View file

@ -294,3 +294,127 @@ export const INVESTIGATE_BULK_IN_TIMELINE = i18n.translate(
defaultMessage: 'Investigate in timeline',
}
);
export const TAKE_ACTION = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.additionalActions.takeAction',
{
defaultMessage: 'Take actions',
}
);
export const STATS_GROUP_ALERTS = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.alertsCount',
{
defaultMessage: 'Alerts:',
}
);
export const STATS_GROUP_HOSTS = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.hostsCount',
{
defaultMessage: 'Hosts:',
}
);
export const STATS_GROUP_IPS = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.ipsCount',
{
defaultMessage: `IP's:`,
}
);
export const GROUP_ALERTS_SELECTOR = i18n.translate(
'xpack.securitySolution.detectionEngine.selectGroup.title',
{
defaultMessage: `Group alerts by`,
}
);
export const STATS_GROUP_USERS = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.usersCount',
{
defaultMessage: 'Users:',
}
);
export const STATS_GROUP_RULES = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.rulesCount',
{
defaultMessage: 'Rules:',
}
);
export const STATS_GROUP_SEVERITY = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.severity',
{
defaultMessage: 'Severity:',
}
);
export const STATS_GROUP_SEVERITY_MULTI = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.severity.multi',
{
defaultMessage: 'Multi',
}
);
export const STATS_GROUP_SEVERITY_LOW = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.severity.low',
{
defaultMessage: 'Low',
}
);
export const STATS_GROUP_SEVERITY_HIGH = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.severity.high',
{
defaultMessage: 'High',
}
);
export const STATS_GROUP_SEVERITY_CRITICAL = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.severity.critical',
{
defaultMessage: 'Critical',
}
);
export const STATS_GROUP_SEVERITY_MEDIUM = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.severity.medium',
{
defaultMessage: 'Medium',
}
);
export const ruleName = i18n.translate('xpack.securitySolution.selector.groups.ruleName.label', {
defaultMessage: 'Rule name',
});
export const userName = i18n.translate('xpack.securitySolution.selector.grouping.userName.label', {
defaultMessage: 'User name',
});
export const hostName = i18n.translate('xpack.securitySolution.selector.grouping.hostName.label', {
defaultMessage: 'Host name',
});
export const sourceIP = i18n.translate('xpack.securitySolution.selector.grouping.sourceIP.label', {
defaultMessage: 'Source IP',
});
export const sourceAddress = i18n.translate(
'xpack.securitySolution.selector.groups.sourceAddress.label',
{
defaultMessage: 'Source address',
}
);
export const destinationAddress = i18n.translate(
'xpack.securitySolution.selector.groups.destinationAddress.label',
{
defaultMessage: 'Destination address',
}
);
export const INSPECT_GROUPING_TITLE = i18n.translate(
'xpack.securitySolution.detectionsEngine.grouping.inspectTitle',
{
defaultMessage: 'Grouping query',
}
);

View file

@ -10,6 +10,7 @@ import { APP_UI_ID } from '../../../../../common/constants';
export const ALERTS_QUERY_NAMES = {
ADD_EXCEPTION_FLYOUT: `${APP_UI_ID} fetchAlerts addExceptionFlyout`,
ALERTS_COUNT_BY_STATUS: `${APP_UI_ID} fetchAlerts byRulebyCount`,
ALERTS_GROUPING: `${APP_UI_ID} fetchAlerts grouping`,
BY_ID: `${APP_UI_ID} fetchAlerts byId`,
BY_RULE_BY_STATUS: `${APP_UI_ID} fetchAlerts byRulebyStatus`,
BY_RULE_ID: `${APP_UI_ID} fetchAlerts byRuleId`,

View file

@ -458,6 +458,8 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChangedCallback}
showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts}
onShowOnlyThreatIndicatorAlertsChanged={onShowOnlyThreatIndicatorAlertsCallback}
signalIndexName={signalIndexName}
runtimeMappings={runtimeMappings}
to={to}
/>
</SecuritySolutionPageWrapper>

View file

@ -7,6 +7,7 @@
import { useCallback, useEffect, useState } from 'react';
import { firstNonNullValue } from '../../../../../common/endpoint/models/ecs_safety_helpers';
import { useQueryInspector } from '../../../../common/components/page/manage_query';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import type { GenericBuckets } from '../../../../../common/search_strategy';
@ -248,7 +249,7 @@ function parseHostsData(
return buckets.reduce<HostAlertsItem[]>((accumalatedAlertsByHost, currentHost) => {
accumalatedAlertsByHost.push({
hostName: currentHost.key || '—',
hostName: firstNonNullValue(currentHost.key) ?? '-',
totalAlerts: currentHost.doc_count,
low: currentHost.low.doc_count,
medium: currentHost.medium.doc_count,

View file

@ -7,6 +7,7 @@
import { useCallback, useEffect, useState } from 'react';
import { firstNonNullValue } from '../../../../../common/endpoint/models/ecs_safety_helpers';
import { useQueryInspector } from '../../../../common/components/page/manage_query';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import type { GenericBuckets } from '../../../../../common/search_strategy';
@ -246,7 +247,7 @@ function parseUsersData(rawAggregation: AlertCountersBySeverityAggregation): Use
return buckets.reduce<UserAlertsItem[]>((accumalatedAlertsByUser, currentUser) => {
accumalatedAlertsByUser.push({
userName: currentUser.key || '—',
userName: firstNonNullValue(currentUser.key) ?? '-',
totalAlerts: currentUser.doc_count,
low: currentUser.low.doc_count,
medium: currentUser.medium.doc_count,

View file

@ -83,8 +83,8 @@ export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{ key: 200, doc_count: 72174 },
{ key: 401, doc_count: 34530 },
{ key: '200', doc_count: 72174 },
{ key: '401', doc_count: 34530 },
],
},
},
@ -125,8 +125,8 @@ export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{ key: 200, doc_count: 75394 },
{ key: 401, doc_count: 1350 },
{ key: '200', doc_count: 75394 },
{ key: '401', doc_count: 1350 },
],
},
},
@ -166,7 +166,7 @@ export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
status: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [{ key: 200, doc_count: 58746 }],
buckets: [{ key: '200', doc_count: 58746 }],
},
},
{
@ -202,7 +202,7 @@ export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
status: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [{ key: 200, doc_count: 28715 }],
buckets: [{ key: '200', doc_count: 28715 }],
},
},
{
@ -238,7 +238,7 @@ export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
status: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [{ key: 200, doc_count: 28161 }],
buckets: [{ key: '200', doc_count: 28161 }],
},
},
{
@ -277,7 +277,7 @@ export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
status: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [{ key: 200, doc_count: 23283 }],
buckets: [{ key: '200', doc_count: 23283 }],
},
},
{
@ -317,8 +317,8 @@ export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{ key: 200, doc_count: 12084 },
{ key: 401, doc_count: 8640 },
{ key: '200', doc_count: 12084 },
{ key: '401', doc_count: 8640 },
],
},
},
@ -365,10 +365,10 @@ export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 3,
buckets: [
{ key: 401, doc_count: 18220 },
{ key: 404, doc_count: 30 },
{ key: 302, doc_count: 27 },
{ key: 200, doc_count: 26 },
{ key: '401', doc_count: 18220 },
{ key: '404', doc_count: 30 },
{ key: '302', doc_count: 27 },
{ key: '200', doc_count: 26 },
],
},
},
@ -408,7 +408,7 @@ export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
status: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [{ key: 200, doc_count: 18048 }],
buckets: [{ key: '200', doc_count: 18048 }],
},
},
{
@ -444,7 +444,7 @@ export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
status: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [{ key: 200, doc_count: 14046 }],
buckets: [{ key: '200', doc_count: 14046 }],
},
},
],

View file

@ -8,6 +8,7 @@
import { get, getOr } from 'lodash/fp';
import type { IEsSearchResponse } from '@kbn/data-plugin/common';
import { firstNonNullValue } from '../../../../../../common/endpoint/models/ecs_safety_helpers';
import type {
NetworkHttpBuckets,
NetworkHttpEdges,
@ -17,19 +18,24 @@ export const getHttpEdges = (response: IEsSearchResponse<unknown>): NetworkHttpE
formatHttpEdges(getOr([], `aggregations.url.buckets`, response.rawResponse));
const formatHttpEdges = (buckets: NetworkHttpBuckets[]): NetworkHttpEdges[] =>
buckets.map((bucket: NetworkHttpBuckets) => ({
node: {
_id: bucket.key,
domains: bucket.domains.buckets.map(({ key }) => key),
methods: bucket.methods.buckets.map(({ key }) => key),
statuses: bucket.status.buckets.map(({ key }) => `${key}`),
lastHost: get('source.hits.hits[0].fields["host.name"]', bucket),
lastSourceIp: get('source.hits.hits[0].fields["source.ip"]', bucket),
path: bucket.key,
requestCount: bucket.doc_count,
},
cursor: {
value: bucket.key,
tiebreaker: null,
},
}));
buckets.map((bucket: NetworkHttpBuckets) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getKey = ({ key }: any) => firstNonNullValue(key);
const bucketKey = firstNonNullValue(bucket.key);
return {
node: {
_id: bucketKey,
domains: bucket.domains.buckets.map(getKey),
methods: bucket.methods.buckets.map(getKey),
statuses: bucket.status.buckets.map(getKey),
lastHost: get('source.hits.hits[0].fields["host.name"]', bucket),
lastSourceIp: get('source.hits.hits[0].fields["source.ip"]', bucket),
path: bucketKey,
requestCount: bucket.doc_count,
},
cursor: {
value: bucketKey,
tiebreaker: null,
},
};
});