Skip to content

Commit

Permalink
[Security Solution] Alerts Grouping MVP (#149145)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
4 people authored Feb 7, 2023
1 parent c71725e commit 705ba7b
Show file tree
Hide file tree
Showing 47 changed files with 3,544 additions and 63 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* 2.0.
*/

import { firstNonNullValue } from '../../../../../../common/endpoint/models/ecs_safety_helpers';
import type { RawBucket, FlattenedBucket } from '../../types';

export const flattenBucket = ({
Expand All @@ -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,
})) ?? [];
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
}),
{}
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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) ?? '']],
[]
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,7 +29,7 @@ export const getLegendItemFromRawBucket = ({
}): LegendItem => ({
color: showColor
? getFillColor({
riskScore: maxRiskSubAggregations[bucket.key] ?? 0,
riskScore: maxRiskSubAggregations[firstNonNullValue(bucket.key) ?? ''] ?? 0,
colorPalette,
})
: undefined,
Expand All @@ -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 = ({
Expand All @@ -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,
Expand Down Expand Up @@ -106,7 +107,7 @@ export const getLegendMap = ({
buckets.reduce<Record<string, LegendItem[]>>(
(acc, bucket) => ({
...acc,
[bucket.key]: [
[firstNonNullValue(bucket.key) ?? '']: [
getLegendItemFromRawBucket({
bucket,
colorPalette,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');

Expand All @@ -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(() => ({}));
Expand All @@ -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(
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export interface EventsViewerProps {
unit?: (n: number) => string;
indexNames?: string[];
bulkActions: boolean | BulkActionsProp;
additionalRightMenuOptions?: React.ReactNode[];
}

/**
Expand Down Expand Up @@ -124,6 +125,7 @@ const StatefulEventsViewerComponent: React.FC<EventsViewerProps & PropsFromRedux
bulkActions,
setSelected,
clearSelected,
additionalRightMenuOptions,
}) => {
const dispatch = useDispatch();
const theme: EuiTheme = useContext(ThemeContext);
Expand Down Expand Up @@ -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" />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface Props {
onViewChange: (viewSelection: ViewSelection) => void;
additionalFilters?: React.ReactNode;
hasRightOffset?: boolean;
additionalMenuOptions?: React.ReactNode[];
}

export const RightTopMenu = ({
Expand All @@ -36,13 +37,27 @@ 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]);

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}
Expand All @@ -63,6 +78,7 @@ export const RightTopMenu = ({
<SummaryViewSelector viewSelected={tableView} onViewChange={onViewChange} />
</UpdatedFlexItem>
)}
{menuOptions}
</UpdatedFlexGroup>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading

0 comments on commit 705ba7b

Please sign in to comment.