mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
c71725ee46
commit
705ba7b5c8
47 changed files with 3544 additions and 63 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
})) ?? [];
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
|
|
|
@ -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) ?? '']],
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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" />}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 });
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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;
|
|
@ -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,
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
`;
|
|
@ -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',
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -94,7 +94,6 @@ export const LandingCards = memo(() => {
|
|||
<EuiFlexItem>
|
||||
<iframe
|
||||
allowFullScreen
|
||||
allowTransparency
|
||||
className="vidyard_iframe"
|
||||
frameBorder="0"
|
||||
height="100%"
|
||||
|
|
|
@ -853,6 +853,8 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
}
|
||||
onRuleChange={refreshRule}
|
||||
to={to}
|
||||
signalIndexName={signalIndexName}
|
||||
runtimeMappings={runtimeMappings}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -85,6 +85,8 @@ export const getAlertsHistogramQuery = (
|
|||
},
|
||||
},
|
||||
runtime_mappings: runtimeMappings,
|
||||
_source: false,
|
||||
size: 0,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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 [];
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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 [];
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -458,6 +458,8 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChangedCallback}
|
||||
showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts}
|
||||
onShowOnlyThreatIndicatorAlertsChanged={onShowOnlyThreatIndicatorAlertsCallback}
|
||||
signalIndexName={signalIndexName}
|
||||
runtimeMappings={runtimeMappings}
|
||||
to={to}
|
||||
/>
|
||||
</SecuritySolutionPageWrapper>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue