[Security Solution][Grouped table] allow changing default groups, buttonContent and extraAction via props (for AI4DSOC) (#216572)

## Summary

This PR makes changes to the GroupedAlertTable code to support a
behavior in the AI for the SOC Alert summary page that the current code
cannot.
In the new Alert summary page (see
[mocks](https://www.figma.com/design/DYs7j4GQdAhg7aWTLI4R69/AI4DSOC?node-id=3284-69401&p=f&m=dev))
there are a few customization that we need to be able to do:
- we need a different set of default groups to be shown in the dropdown
- we need to be able to customize the title shown in the EuiAccordion in
a way that would conflict with the current implementation
- we need to also customize the group statistics shown in the
EuiAccordion

### Challenge

The current implementation within the GroupedAlertTable was not allowing
full customization.
- while the default groups could be changed, it was done via if/else
conditions, using the `tableId` to know where the table was being used.
This isn't a clean way to do this. The component shouldn't be aware of
where it's being used...
- regarding the title and group statistics, these were hardcoded and not
customizable. While I could also have added if/else conditions to
support the Alert summary page different behavior, this would only have
built more tech debt...

### Approach

Instead of continuing adding more if/else conditions, the approach in
the PR adds 3 new props to the GroupedAlertTable:
- `accordionButtonContent` allows to customize how the EuiAccordion
`buttonContent` (title) is rendered
- `accordionExtraActionGroupStats` allows to customize how the
EuiAccordion `extraAction` (statistics) are rendered. This actually
consists of 2 sub properties:
  - `renderer` which will drive the UI
  - aggregations which will be used to fetch the data
- `defaultGroupingOptions` allows to customize the default values in the
dropdown

### Notes

**_The 3 places where the GroupedAlertTable is used have been updated to
use the same default values. Their behavior should be unchanged. A
follow up PR will implement the Alert summary variation._**

In the new state, any new usage of the alerts table with no default
values will provide the following behavior:
- the EuiAccordion `buttonContent` will use [the default
component](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-grouping/src/components/accordion_panel/index.tsx#L33)
from the `kbn-grouping` package
- the EuiAccordion `extraAction` will display only the number of alerts
within the group
- the default options in the `Group alerts by` dropdown will be `None`
and `Custom field`


https://github.com/user-attachments/assets/57563735-78ee-455f-aab6-806028aec713


https://github.com/user-attachments/assets/0659c74e-b4a0-4051-8fb7-25457424c06b

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

Will help https://github.com/elastic/security-team/issues/11973
This commit is contained in:
Philippe Oberti 2025-04-03 17:53:30 +02:00 committed by GitHub
parent ef907a32f2
commit 0e633d777a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 737 additions and 338 deletions

View file

@ -41264,12 +41264,6 @@
"xpack.securitySolution.securityIntegration.cribl.mapsTo": "MAPPE À",
"xpack.securitySolution.securityIntegration.cribl.missingPermissionsCalloutDescription": "Pour configurer cette intégration, vous devez disposer des privilèges \"manage_index_templates\" et \"manage_pipeline\" ou \"manage_ingest_pipelines\".",
"xpack.securitySolution.securityIntegration.cribl.missingPermissionsCalloutTitle": "Assurez-vous de disposer des privilèges nécessaires",
"xpack.securitySolution.selector.grouping.hostName.label": "Nom d'hôte",
"xpack.securitySolution.selector.grouping.sourceIP.label": "IP source",
"xpack.securitySolution.selector.grouping.userName.label": "Nom d'utilisateur",
"xpack.securitySolution.selector.groups.destinationAddress.label": "Adresse de destination",
"xpack.securitySolution.selector.groups.ruleName.label": "Nom de règle",
"xpack.securitySolution.selector.groups.sourceAddress.label": "Adresse de la source",
"xpack.securitySolution.selector.summaryView.eventRendererView.label": "Vue rendue des événements",
"xpack.securitySolution.selector.summaryView.gridView.label": "Vue Grille",
"xpack.securitySolution.selector.summaryView.options.default.description": "Afficher sous forme de données tabulaires avec la possibilité de regrouper et de trier selon des champs spécifiques",

View file

@ -41237,12 +41237,6 @@
"xpack.securitySolution.securityIntegration.cribl.mapsTo": "マッピング先",
"xpack.securitySolution.securityIntegration.cribl.missingPermissionsCalloutDescription": "この統合を構成するには、manage_index_templates権限と、manage_pipelineまたはmanage_ingest_pipelines権限が必要です。",
"xpack.securitySolution.securityIntegration.cribl.missingPermissionsCalloutTitle": "必要な権限があることを確認してください",
"xpack.securitySolution.selector.grouping.hostName.label": "ホスト名",
"xpack.securitySolution.selector.grouping.sourceIP.label": "ソース IP",
"xpack.securitySolution.selector.grouping.userName.label": "ユーザー名",
"xpack.securitySolution.selector.groups.destinationAddress.label": "ターゲットアドレス",
"xpack.securitySolution.selector.groups.ruleName.label": "ルール名",
"xpack.securitySolution.selector.groups.sourceAddress.label": "ソースアドレス",
"xpack.securitySolution.selector.summaryView.eventRendererView.label": "イベント表示ビュー",
"xpack.securitySolution.selector.summaryView.gridView.label": "グリッドビュー",
"xpack.securitySolution.selector.summaryView.options.default.description": "特定のフィールドでグループ化および並べ替えることができるタブ形式のデータとして表示",

View file

@ -41302,12 +41302,6 @@
"xpack.securitySolution.securityIntegration.cribl.mapsTo": "映射到",
"xpack.securitySolution.securityIntegration.cribl.missingPermissionsCalloutDescription": "要配置此集成,您必须具有 `manage_index_templates` 权限和 `manage_pipeline` 或 `manage_ingest_pipelines` 权限。",
"xpack.securitySolution.securityIntegration.cribl.missingPermissionsCalloutTitle": "请确保您具有必要权限",
"xpack.securitySolution.selector.grouping.hostName.label": "主机名",
"xpack.securitySolution.selector.grouping.sourceIP.label": "源 IP",
"xpack.securitySolution.selector.grouping.userName.label": "用户名",
"xpack.securitySolution.selector.groups.destinationAddress.label": "目标地址",
"xpack.securitySolution.selector.groups.ruleName.label": "规则名称",
"xpack.securitySolution.selector.groups.sourceAddress.label": "源地址",
"xpack.securitySolution.selector.summaryView.eventRendererView.label": "事件渲染视图",
"xpack.securitySolution.selector.summaryView.gridView.label": "网格视图",
"xpack.securitySolution.selector.summaryView.options.default.description": "以表格数据方式查看,这样可以按特定字段分组和排序",

View file

@ -7,11 +7,12 @@
import actionCreatorFactory from 'typescript-fsa';
import type { TableId } from '@kbn/securitysolution-data-table';
import type { GroupOption } from '@kbn/grouping';
const actionCreator = actionCreatorFactory('x-pack/security_solution/groups');
export const updateGroups = actionCreator<{
activeGroups?: string[];
tableId: TableId;
options?: Array<{ key: string; label: string }>;
options?: GroupOption[];
}>('UPDATE_GROUPS');

View file

@ -6,19 +6,21 @@
*/
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { getDefaultGroupingOptions } from '../../utils/alerts';
import { DEFAULT_GROUPING_OPTIONS } from '../../../detections/components/alerts_table/alerts_grouping';
import { updateGroups } from './actions';
import type { Groups } from './types';
export const initialGroupingState: Groups = {};
const EMPTY_ACTIVE_GROUP: string[] = [];
export const groupsReducer = reducerWithInitialState(initialGroupingState).case(
updateGroups,
(state, { tableId, ...rest }) => ({
...state,
[tableId]: {
activeGroups: [],
options: getDefaultGroupingOptions(tableId),
activeGroups: EMPTY_ACTIVE_GROUP,
options: DEFAULT_GROUPING_OPTIONS,
...(state[tableId] ? state[tableId] : {}),
...rest,
},

View file

@ -8,9 +8,6 @@
import { merge } from '@kbn/std';
import { isPlainObject } from 'lodash';
import type { Ecs } from '@kbn/cases-plugin/common';
import { TableId } from '@kbn/securitysolution-data-table';
import type { GroupOption } from '@kbn/grouping';
import * as i18n from './translations';
export const buildAlertsQuery = (alertIds: string[]) => {
if (alertIds.length === 0) {
@ -121,47 +118,3 @@ export interface Alert {
signal: Signal;
[key: string]: unknown;
}
// generates default grouping option for alerts table
export const getDefaultGroupingOptions = (tableId: TableId): GroupOption[] => {
if (tableId === TableId.alertsOnAlertsPage || tableId === TableId.alertsRiskInputs) {
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

@ -1,34 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
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',
}
);

View file

@ -39,6 +39,11 @@ import {
TableId,
} from '@kbn/securitysolution-data-table';
import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy';
import {
defaultGroupStatsAggregations,
defaultGroupStatsRenderer,
defaultGroupTitleRenderers,
} from '../../../../detections/components/alerts_table/grouping_settings';
import { EndpointExceptionsViewer } from '../../../endpoint_exceptions/endpoint_exceptions_viewer';
import { DetectionEngineAlertsTable } from '../../../../detections/components/alerts_table';
import { GroupedAlertsTable } from '../../../../detections/components/alerts_table/alerts_grouping';
@ -179,6 +184,25 @@ const RuleFieldsSectionWrapper = styled.div`
overflow-wrap: anywhere;
`;
const defaultGroupingOptions = [
{
label: i18n.SOURCE_ADDRESS,
key: 'source.address',
},
{
label: i18n.USER_NAME,
key: 'user.name',
},
{
label: i18n.HOST_NAME,
key: 'host.name',
},
{
label: i18n.DESTINATION_ADDRESS,
key: 'destination.address',
},
];
type DetectionEngineComponentProps = PropsFromRedux;
const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
@ -535,6 +559,14 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
confirmManualRuleRun,
} = useManualRuleRunConfirmation();
const accordionExtraActionGroupStats = useMemo(
() => ({
aggregations: defaultGroupStatsAggregations,
renderer: defaultGroupStatsRenderer,
}),
[]
);
if (
redirectToDetections(
isSignalIndexExists,
@ -762,8 +794,11 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
</Display>
{ruleId != null && (
<GroupedAlertsTable
accordionButtonContent={defaultGroupTitleRenderers}
accordionExtraActionGroupStats={accordionExtraActionGroupStats}
currentAlertStatusFilterValue={currentAlertStatusFilterValue}
defaultFilters={alertMergedFilters}
defaultGroupingOptions={defaultGroupingOptions}
from={from}
globalFilters={filters}
globalQuery={query}

View file

@ -73,3 +73,31 @@ export const DELETE_CONFIRMATION_BODY = i18n.translate(
defaultMessage: 'This action will delete the rule. Click "Delete" to continue.',
}
);
export const SOURCE_ADDRESS = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.groups.sourceAddress',
{
defaultMessage: 'Source address',
}
);
export const USER_NAME = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.groups.userName',
{
defaultMessage: 'User name',
}
);
export const HOST_NAME = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.groups.hostName',
{
defaultMessage: 'Host name',
}
);
export const DESTINATION_ADDRESS = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.groups.destinationAddress',
{
defaultMessage: 'Destination address',
}
);

View file

@ -21,6 +21,12 @@ import { createTelemetryServiceMock } from '../../../common/lib/telemetry/teleme
import { useQueryAlerts } from '../../containers/detection_engine/alerts/use_query';
import { getQuery, groupingSearchResponse } from './grouping_settings/mock';
import { AlertsEventTypes } from '../../../common/lib/telemetry';
import {
defaultGroupingOptions,
defaultGroupStatsAggregations,
defaultGroupStatsRenderer,
defaultGroupTitleRenderers,
} from './grouping_settings';
jest.mock('../../containers/detection_engine/alerts/use_query');
jest.mock('../../../sourcerer/containers');
@ -45,10 +51,10 @@ jest.mock('../../../common/containers/use_global_time', () => {
});
const mockOptions = [
{ label: 'ruleName', key: 'kibana.alert.rule.name' },
{ label: 'userName', key: 'user.name' },
{ label: 'hostName', key: 'host.name' },
{ label: 'sourceIP', key: 'source.ip' },
{ 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' },
];
jest.mock('../../../common/utils/alerts', () => {
@ -113,7 +119,13 @@ const renderChildComponent = (groupingFilters: Filter[]) => <p data-test-subj="a
const testProps: AlertsTableComponentProps = {
...mockDate,
accordionButtonContent: defaultGroupTitleRenderers,
accordionExtraActionGroupStats: {
aggregations: defaultGroupStatsAggregations,
renderer: defaultGroupStatsRenderer,
},
defaultFilters: [],
defaultGroupingOptions,
globalFilters: [],
globalQuery: {
query: 'query',
@ -191,6 +203,7 @@ describe('GroupedAlertsTable', () => {
});
expect(mockDispatch.mock.calls[1][0].payload).toEqual({
activeGroups: ['none'],
options: mockOptions,
tableId: testProps.tableId,
});
});

View file

@ -8,28 +8,61 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import type { Filter, Query } from '@kbn/es-query';
import { isNoneGroup, useGrouping } from '@kbn/grouping';
import {
type GroupOption,
type GroupStatsItem,
isNoneGroup,
type NamedAggregation,
type RawBucket,
useGrouping,
} from '@kbn/grouping';
import { isEmpty, isEqual } from 'lodash/fp';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import type { TableIdLiteral } from '@kbn/securitysolution-data-table';
import type { GroupingArgs } from '@kbn/grouping/src';
import type { GetGroupStats, GroupingArgs, GroupPanelRenderer } from '@kbn/grouping/src';
import type { AlertsGroupingAggregation } from './grouping_settings/types';
import { groupIdSelector } from '../../../common/store/grouping/selectors';
import { getDefaultGroupingOptions } from '../../../common/utils/alerts';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { updateGroups } from '../../../common/store/grouping/actions';
import type { Status } from '../../../../common/api/detection_engine';
import { defaultUnit } from '../../../common/components/toolbar/unit';
import { useSourcererDataView } from '../../../sourcerer/containers';
import { SourcererScopeName } from '../../../sourcerer/store/model';
import type { RunTimeMappings } from '../../../sourcerer/store/model';
import { renderGroupPanel, getStats } from './grouping_settings';
import { SourcererScopeName } from '../../../sourcerer/store/model';
import { useKibana } from '../../../common/lib/kibana';
import { GroupedSubLevel } from './alerts_sub_grouping';
import { AlertsEventTypes, track } from '../../../common/lib/telemetry';
import * as i18n from './translations';
export interface AlertsTableComponentProps {
/**
* Allows to customize the `buttonContent` props of the EuiAccordion.
* It basically renders the text next to the chevron, used to expand/collapse the accordion.
* If none provided, the DefaultGroupPanelRenderer will be used (see kbn-grouping package).
*/
accordionButtonContent?: GroupPanelRenderer<AlertsGroupingAggregation>;
/**
* Allow to partially customize the `extraAction` props of the EuiAccordion.
* It basically renders the statistics to right side of the title and the left side of the Take actions button.
* If none provided, we display the number of alerts for the group.
*/
accordionExtraActionGroupStats?: {
/**
* Responsible to fetch the aggregation data to populate the UI values
*/
aggregations: (field: string) => NamedAggregation[];
/**
* Responsible for rendering the aggregation data
*/
renderer: GetGroupStats<AlertsGroupingAggregation>;
};
currentAlertStatusFilterValue?: Status[];
defaultFilters?: Filter[];
/**
* Default values to display in the group selection dropdown.
* If none are provided, the only options there will None (default) and be Custom field.
*/
defaultGroupingOptions?: GroupOption[];
from: string;
globalFilters: Filter[];
globalQuery: Query;
@ -46,6 +79,39 @@ export interface AlertsTableComponentProps {
const DEFAULT_PAGE_SIZE = 25;
const DEFAULT_PAGE_INDEX = 0;
const MAX_GROUPING_LEVELS = 3;
export const DEFAULT_GROUPING_OPTIONS: GroupOption[] = [];
/**
* This is used as default behavior if no group renderer is passed via props.
* This will render the number of alerts.
* It's paired with the DEFAULT_GROUP_STATS_AGGREGATION which retrieves the aggregation data.
*/
export const DEFAULT_GROUP_STATS_RENDERER: GetGroupStats<AlertsGroupingAggregation> = (
_: string,
bucket: RawBucket<AlertsGroupingAggregation>
): GroupStatsItem[] => [
{
title: i18n.STATS_GROUP_ALERTS,
badge: {
value: bucket.doc_count,
width: 50,
color: '#a83632',
},
},
];
/**
* This is used as default behavior if no group aggregations is passed via props.
* This will render retrieve the values to render the DEFAULT_GROUP_STATS_RENDERER above.
*/
export const DEFAULT_GROUP_STATS_AGGREGATION: (field: string) => NamedAggregation[] = () => [
{
unitsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
},
];
const useStorage = (storage: Storage, tableId: string) =>
useMemo(
@ -110,14 +176,29 @@ const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = (props)
const fields = useMemo(() => Object.values(sourcererDataView.fields || {}), [sourcererDataView]);
const groupingOptions = useMemo(
() => props.defaultGroupingOptions || DEFAULT_GROUPING_OPTIONS,
[props.defaultGroupingOptions]
);
const groupStatsRenderer = useMemo(
() => props.accordionExtraActionGroupStats?.renderer || DEFAULT_GROUP_STATS_RENDERER,
[props.accordionExtraActionGroupStats?.renderer]
);
const groupStatusAggregations = useMemo(
() => props.accordionExtraActionGroupStats?.aggregations || DEFAULT_GROUP_STATS_AGGREGATION,
[props.accordionExtraActionGroupStats?.aggregations]
);
const { getGrouping, selectedGroups, setSelectedGroups } = useGrouping({
componentProps: {
groupPanelRenderer: renderGroupPanel,
getGroupStats: getStats,
groupPanelRenderer: props.accordionButtonContent,
getGroupStats: groupStatsRenderer,
onGroupToggle,
unit: defaultUnit,
},
defaultGroupingOptions: getDefaultGroupingOptions(props.tableId),
defaultGroupingOptions: groupingOptions,
fields,
groupingId: props.tableId,
maxGroupingLevels: MAX_GROUPING_LEVELS,
@ -134,11 +215,12 @@ const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = (props)
dispatch(
updateGroups({
activeGroups: selectedGroups,
options: groupingOptions,
tableId: props.tableId,
})
);
}
}, [dispatch, props.tableId, selectedGroups]);
}, [groupingOptions, dispatch, props.tableId, selectedGroups]);
useEffect(() => {
if (groupInRedux != null && !isNoneGroup(groupInRedux.activeGroups)) {
@ -245,6 +327,7 @@ const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = (props)
{...props}
getGrouping={getGrouping}
groupingLevel={level}
groupStatsAggregations={groupStatusAggregations}
onGroupClose={() => resetGroupChildrenPagination(level)}
pageIndex={pageIndex[level] ?? DEFAULT_PAGE_INDEX}
pageSize={pageSize[level] ?? DEFAULT_PAGE_SIZE}
@ -256,7 +339,7 @@ const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = (props)
/>
);
},
[getGrouping, pageIndex, pageSize, props, selectedGroups, setPageVar]
[getGrouping, groupStatusAggregations, pageIndex, pageSize, props, selectedGroups, setPageVar]
);
if (isEmpty(selectedPatterns)) {

View file

@ -9,15 +9,15 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import type { Filter, Query } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
import type { GroupingAggregation } from '@kbn/grouping';
import type { GroupingAggregation, NamedAggregation } from '@kbn/grouping';
import { isNoneGroup } from '@kbn/grouping';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import type { DynamicGroupingProps } from '@kbn/grouping/src';
import { parseGroupingQuery } from '@kbn/grouping/src';
import type { TableIdLiteral } from '@kbn/securitysolution-data-table';
import type { RunTimeMappings } from '../../../sourcerer/store/model';
import { combineQueries } from '../../../common/lib/kuery';
import { SourcererScopeName } from '../../../sourcerer/store/model';
import { combineQueries } from '../../../common/lib/kuery';
import type { AlertsGroupingAggregation } from './grouping_settings/types';
import type { Status } from '../../../../common/api/detection_engine';
import { InspectButton } from '../../../common/components/inspect';
@ -46,6 +46,11 @@ interface OwnProps {
globalFilters: Filter[];
globalQuery: Query;
groupingLevel?: number;
/**
* Function that returns the group aggregations by field.
* This is then used to render values in the EuiAccordion `extraAction` section.
*/
groupStatsAggregations: (field: string) => NamedAggregation[];
hasIndexMaintenance: boolean;
hasIndexWrite: boolean;
loading: boolean;
@ -73,6 +78,7 @@ export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
globalFilters,
globalQuery,
groupingLevel,
groupStatsAggregations,
hasIndexMaintenance,
hasIndexWrite,
loading,
@ -147,6 +153,7 @@ export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
const queryGroups = useMemo(() => {
return getAlertsGroupingQuery({
groupStatsAggregations,
additionalFilters,
selectedGroup,
uniqueValue,
@ -159,6 +166,7 @@ export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
}, [
additionalFilters,
from,
groupStatsAggregations,
pageIndex,
pageSize,
runtimeMappings,
@ -251,6 +259,13 @@ export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
[defaultFilters, getGlobalQuery, selectedGroup, tableId, takeActionItems]
);
const onChangeGroupsItemsPerPage = useCallback(
(size: number) => setPageSize(size),
[setPageSize]
);
const onChangeGroupsPage = useCallback((index: number) => setPageIndex(index), [setPageIndex]);
return useMemo(
() =>
getGrouping({
@ -260,8 +275,8 @@ export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
inspectButton: inspect,
isLoading: loading || isLoadingGroups,
itemsPerPage: pageSize,
onChangeGroupsItemsPerPage: (size: number) => setPageSize(size),
onChangeGroupsPage: (index) => setPageIndex(index),
onChangeGroupsItemsPerPage,
onChangeGroupsPage,
onGroupClose,
renderChildComponent,
selectedGroup,
@ -275,13 +290,13 @@ export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
inspect,
isLoadingGroups,
loading,
onChangeGroupsItemsPerPage,
onChangeGroupsPage,
onGroupClose,
pageIndex,
pageSize,
renderChildComponent,
selectedGroup,
setPageIndex,
setPageSize,
]
);
};

View file

@ -0,0 +1,204 @@
/*
* 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 { defaultGroupStatsAggregations } from '.';
describe('defaultGroupStatsAggregations', () => {
it('should return the default values if the field is not supported', () => {
const aggregations = defaultGroupStatsAggregations('unknown');
expect(aggregations).toEqual([
{
unitsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
},
{
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
},
]);
});
it('should return values depending on the input field', () => {
const ruleAggregations = defaultGroupStatsAggregations('kibana.alert.rule.name');
expect(ruleAggregations).toEqual([
{
unitsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
},
{
description: {
terms: {
field: 'kibana.alert.rule.description',
size: 1,
},
},
},
{
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',
},
},
},
]);
const hostAggregations = defaultGroupStatsAggregations('host.name');
expect(hostAggregations).toEqual([
{
unitsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
},
{
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',
},
},
},
]);
const userAggregations = defaultGroupStatsAggregations('user.name');
expect(userAggregations).toEqual([
{
unitsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
},
{
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
},
{
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
},
]);
const sourceAggregations = defaultGroupStatsAggregations('source.ip');
expect(sourceAggregations).toEqual([
{
unitsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
},
{
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
},
{
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
},
]);
});
});

View file

@ -0,0 +1,187 @@
/*
* 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 { NamedAggregation } from '@kbn/grouping';
import { DEFAULT_GROUP_STATS_AGGREGATION } from '../alerts_grouping';
/**
* Returns aggregations to be used to calculate the statistics to be used in the `extraAction` property of the EUiAccordion component.
* It handles custom renders for the following fields:
* - kibana.alert.rule.name
* - host.name
* - user.name
* - source.ip
* And returns a default set of aggregation for all the other fields.
*
* This go hand in hand with defaultGroupingOptions, defaultGroupTitleRenderers and defaultGroupStatsRenderer.
*/
export const defaultGroupStatsAggregations = (field: string): NamedAggregation[] => {
const aggMetrics: NamedAggregation[] = DEFAULT_GROUP_STATS_AGGREGATION('');
switch (field) {
case 'kibana.alert.rule.name':
aggMetrics.push(
...[
{
description: {
terms: {
field: 'kibana.alert.rule.description',
size: 1,
},
},
},
{
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',
},
},
},
{
hostsCountAggregation: {
cardinality: {
field: 'host.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',
},
},
},
{
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
},
]
);
break;
default:
aggMetrics.push({
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
});
}
return aggMetrics;
};

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import { getStats } from '.';
import { defaultGroupStatsRenderer } from '.';
describe('getStats', () => {
it('returns array of badges which corresponds to the field name', () => {
const badgesRuleName = getStats('kibana.alert.rule.name', {
const badgesRuleName = defaultGroupStatsRenderer('kibana.alert.rule.name', {
key: [],
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 10 }] },
countSeveritySubAggregation: { value: 1 },
@ -52,7 +52,7 @@ describe('getStats', () => {
)
).toBeTruthy();
const badgesHostName = getStats('host.name', {
const badgesHostName = defaultGroupStatsRenderer('host.name', {
key: 'Host',
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 10 }] },
countSeveritySubAggregation: { value: 1 },
@ -95,7 +95,7 @@ describe('getStats', () => {
)
).toBeTruthy();
const badgesUserName = getStats('user.name', {
const badgesUserName = defaultGroupStatsRenderer('user.name', {
key: 'User test',
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 10 }] },
countSeveritySubAggregation: { value: 1 },
@ -138,7 +138,7 @@ describe('getStats', () => {
)
).toBeTruthy();
const badgesSourceIp = getStats('source.ip', {
const badgesSourceIp = defaultGroupStatsRenderer('source.ip', {
key: 'User test',
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 10 }] },
countSeveritySubAggregation: { value: 1 },
@ -183,7 +183,7 @@ describe('getStats', () => {
});
it('should return default badges if the field specific does not exist', () => {
const badges = getStats('process.name', {
const badges = defaultGroupStatsRenderer('process.name', {
key: 'process',
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 10 }] },
countSeveritySubAggregation: { value: 1 },

View file

@ -8,6 +8,7 @@
import { EuiIcon } from '@elastic/eui';
import React from 'react';
import type { GroupStatsItem, RawBucket } from '@kbn/grouping';
import { DEFAULT_GROUP_STATS_RENDERER } from '../alerts_grouping';
import type { AlertsGroupingAggregation } from './types';
import * as i18n from '../translations';
@ -64,7 +65,18 @@ const multiSeverity = (
</>
);
export const getStats = (
/**
* Returns statistics to be used in the`extraAction` property of the EuiAccordion component used within the kbn-grouping package.
* It handles custom renders for the following fields:
* - kibana.alert.rule.name
* - host.name
* - user.name
* - source.ip
* And returns a default view for all the other fields.
*
* This go hand in hand with defaultGroupingOptions, defaultGroupTitleRenderers and defaultGroupStatsAggregations.
*/
export const defaultGroupStatsRenderer = (
selectedGroup: string,
bucket: RawBucket<AlertsGroupingAggregation>
): GroupStatsItem[] => {
@ -86,16 +98,7 @@ export const getStats = (
},
];
const defaultBadges: GroupStatsItem[] = [
{
title: i18n.STATS_GROUP_ALERTS,
badge: {
value: bucket.doc_count,
width: 50,
color: '#a83632',
},
},
];
const defaultBadges: GroupStatsItem[] = DEFAULT_GROUP_STATS_RENDERER(selectedGroup, bucket);
switch (selectedGroup) {
case 'kibana.alert.rule.name':

View file

@ -5,13 +5,13 @@
* 2.0.
*/
import { renderGroupPanel } from '.';
import { defaultGroupTitleRenderers } from '.';
import { render } from '@testing-library/react';
describe('renderGroupPanel', () => {
describe('defaultGroupTitleRenderers', () => {
it('renders correctly when the field renderer exists', () => {
let { getByTestId } = render(
renderGroupPanel(
defaultGroupTitleRenderers(
'kibana.alert.rule.name',
{
key: ['Rule name test', 'Some description'],
@ -23,7 +23,7 @@ describe('renderGroupPanel', () => {
expect(getByTestId('rule-name-group-renderer')).toBeInTheDocument();
const result1 = render(
renderGroupPanel(
defaultGroupTitleRenderers(
'host.name',
{
key: 'Host',
@ -37,7 +37,7 @@ describe('renderGroupPanel', () => {
expect(getByTestId('host-name-group-renderer')).toBeInTheDocument();
const result2 = render(
renderGroupPanel(
defaultGroupTitleRenderers(
'user.name',
{
key: 'User test',
@ -50,7 +50,7 @@ describe('renderGroupPanel', () => {
expect(getByTestId('host-name-group-renderer')).toBeInTheDocument();
const result3 = render(
renderGroupPanel(
defaultGroupTitleRenderers(
'source.ip',
{
key: 'sourceIp',
@ -65,7 +65,7 @@ describe('renderGroupPanel', () => {
});
it('returns undefined when the renderer does not exist', () => {
const wrapper = renderGroupPanel(
const wrapper = defaultGroupTitleRenderers(
'process.name',
{
key: 'process',

View file

@ -24,7 +24,18 @@ import type { GenericBuckets } from '../../../../../common/search_strategy';
import { PopoverItems } from '../../../../common/components/popover_items';
import { COLUMN_TAGS } from '../../../../detection_engine/common/translations';
export const renderGroupPanel: GroupPanelRenderer<AlertsGroupingAggregation> = (
/**
* Returns renderers to be used in the `buttonContent` property of the EuiAccordion component used within the kbn-grouping package.
* It handles custom renders for the following fields:
* - kibana.alert.rule.name
* - host.name
* - user.name
* - source.ip
* For all the other fields the default renderer managed within the kbn-grouping package will be used.
*
* This go hand in hand with defaultGroupingOptions and defaultGroupStatsRenderer and defaultGroupStatsAggregations.
*/
export const defaultGroupTitleRenderers: GroupPanelRenderer<AlertsGroupingAggregation> = (
selectedGroup,
bucket,
nullGroupMessage

View file

@ -0,0 +1,49 @@
/*
* 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 { GroupOption } from '@kbn/grouping/src';
import { i18n } from '@kbn/i18n';
const RULE_NAME = i18n.translate('xpack.securitySolution.alertsTable.groups.ruleName', {
defaultMessage: 'Rule name',
});
const USER_NAME = i18n.translate('xpack.securitySolution.alertsTable.groups.userName', {
defaultMessage: 'User name',
});
const HOST_NAME = i18n.translate('xpack.securitySolution.alertsTable.groups.hostName', {
defaultMessage: 'Host name',
});
const SOURCE_IP = i18n.translate('xpack.securitySolution.alertsTable.groups.sourceIP', {
defaultMessage: 'Source IP',
});
/**
* Returns a list of fields for the default grouping options. These are displayed in the `Group alerts by` dropdown button.
*
* These go hand in hand with defaultGroupTitleRenderers, defaultGroupStats and defaultGroupStatsAggregations.
*/
export const defaultGroupingOptions: GroupOption[] = [
{
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',
},
];

View file

@ -5,7 +5,9 @@
* 2.0.
*/
export * from './group_stats';
export * from './group_panel_renderers';
export * from './default_grouping_options';
export * from './default_group_stats_aggregations';
export * from './default_group_stats_renderers';
export * from './default_group_title_renderers';
export * from './group_take_action_items';
export * from './query_builder';

View file

@ -5,18 +5,20 @@
* 2.0.
*/
import { getAlertsGroupingQuery } from '.';
import type { AlertsGroupingQueryParams } from '.';
import { defaultGroupStatsAggregations, getAlertsGroupingQuery } from '.';
import { getQuery } from './mock';
let sampleData = {
let sampleData: AlertsGroupingQueryParams = {
additionalFilters: [{ bool: { filter: [], must: [], must_not: [], should: [] } }],
from: '2022-12-29T22:57:34.029Z',
to: '2023-01-28T22:57:29.029Z',
groupStatsAggregations: defaultGroupStatsAggregations,
pageIndex: 0,
pageSize: 25,
runtimeMappings: {},
uniqueValue: 'aSuperUniqueValue',
selectedGroup: 'kibana.alert.rule.name',
additionalFilters: [{ bool: { filter: [], must: [], must_not: [], should: [] } }],
uniqueValue: 'aSuperUniqueValue',
to: '2023-01-28T22:57:29.029Z',
};
describe('getAlertsGroupingQuery', () => {

View file

@ -6,15 +6,19 @@
*/
import type { BoolQuery } from '@kbn/es-query';
import type { NamedAggregation } from '@kbn/grouping';
import { isNoneGroup, getGroupingQuery } from '@kbn/grouping';
import { getGroupingQuery, isNoneGroup, type NamedAggregation } from '@kbn/grouping';
import type { RunTimeMappings } from '../../../../sourcerer/store/model';
interface AlertsGroupingQueryParams {
export interface AlertsGroupingQueryParams {
additionalFilters: Array<{
bool: BoolQuery;
}>;
from: string;
/**
* Function that returns the group aggregations by field.
* This is then used to render values in the EuiAccordion `extraAction` section.
*/
groupStatsAggregations: (field: string) => NamedAggregation[];
pageIndex: number;
pageSize: number;
runtimeMappings: RunTimeMappings;
@ -26,6 +30,7 @@ interface AlertsGroupingQueryParams {
export const getAlertsGroupingQuery = ({
additionalFilters,
from,
groupStatsAggregations,
pageIndex,
pageSize,
runtimeMappings,
@ -40,186 +45,10 @@ export const getAlertsGroupingQuery = ({
to,
},
groupByField: selectedGroup,
statsAggregations: !isNoneGroup([selectedGroup])
? getAggregationsByGroupField(selectedGroup)
: [],
statsAggregations: !isNoneGroup([selectedGroup]) ? groupStatsAggregations(selectedGroup) : [],
pageNumber: pageIndex * pageSize,
runtimeMappings,
uniqueValue,
size: pageSize,
sort: [{ unitsCount: { order: 'desc' } }],
});
const getAggregationsByGroupField = (field: string): NamedAggregation[] => {
const aggMetrics: NamedAggregation[] = [
{
unitsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
},
];
switch (field) {
case 'kibana.alert.rule.name':
aggMetrics.push(
...[
{
description: {
terms: {
field: 'kibana.alert.rule.description',
size: 1,
},
},
},
{
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',
},
},
},
{
hostsCountAggregation: {
cardinality: {
field: 'host.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',
},
},
},
{
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
},
]
);
break;
default:
aggMetrics.push({
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
});
}
return aggMetrics;
};

View file

@ -34,6 +34,12 @@ import {
import { isEqual } from 'lodash';
import type { FilterGroupHandler } from '@kbn/alerts-ui-shared';
import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy';
import {
defaultGroupingOptions,
defaultGroupStatsAggregations,
defaultGroupStatsRenderer,
defaultGroupTitleRenderers,
} from '../../components/alerts_table/grouping_settings';
import { DetectionEngineFilters } from '../../components/detection_engine_filters/detection_engine_filters';
import { FilterByAssigneesPopover } from '../../../common/components/filter_by_assignees_popover/filter_by_assignees_popover';
import type { AssigneesIdsSelection } from '../../../common/components/assignees/types';
@ -321,6 +327,14 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ()
[alertsTableDefaultFilters, isAlertTableLoading]
);
const accordionExtraActionGroupStats = useMemo(
() => ({
aggregations: defaultGroupStatsAggregations,
renderer: defaultGroupStatsRenderer,
}),
[]
);
if (loading) {
return (
<SecuritySolutionPageWrapper>
@ -420,8 +434,11 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ()
<EuiSpacer size="l" />
</Display>
<GroupedAlertsTable
accordionButtonContent={defaultGroupTitleRenderers}
accordionExtraActionGroupStats={accordionExtraActionGroupStats}
currentAlertStatusFilterValue={statusFilter}
defaultFilters={alertsTableDefaultFilters}
defaultGroupingOptions={defaultGroupingOptions}
from={from}
globalFilters={filters}
globalQuery={query}

View file

@ -9,10 +9,14 @@ import React, { useCallback, useMemo } from 'react';
import { TableId } from '@kbn/securitysolution-data-table';
import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy';
import {
defaultGroupingOptions,
defaultGroupStatsAggregations,
defaultGroupStatsRenderer,
defaultGroupTitleRenderers,
} from '../../../detections/components/alerts_table/grouping_settings';
import { HeaderSection } from '../../../common/components/header_section';
import * as i18n from './translations';
import type { RiskInputs } from '../../../../common/entity_analytics/risk_engine';
import type { EntityRiskScore } from '../../../../common/search_strategy';
@ -95,6 +99,16 @@ export const TopRiskScoreContributorsAlerts = <T extends EntityType>({
[inputFilters, filters]
);
const defaultFilters = useMemo(() => [...inputFilters, ...filters], [filters, inputFilters]);
const accordionExtraActionGroupStats = useMemo(
() => ({
aggregations: defaultGroupStatsAggregations,
renderer: defaultGroupStatsRenderer,
}),
[]
);
return (
<EuiPanel hasBorder data-test-subj="topRiskScoreContributorsAlerts">
<EuiFlexGroup gutterSize={'none'}>
@ -117,7 +131,10 @@ export const TopRiskScoreContributorsAlerts = <T extends EntityType>({
>
<EuiFlexItem grow={1}>
<GroupedAlertsTable
defaultFilters={[...inputFilters, ...filters]}
accordionButtonContent={defaultGroupTitleRenderers}
accordionExtraActionGroupStats={accordionExtraActionGroupStats}
defaultFilters={defaultFilters}
defaultGroupingOptions={defaultGroupingOptions}
from={from}
globalFilters={filters}
globalQuery={query}