Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution] Alerts Grouping MVP #149145

Merged
merged 61 commits into from
Feb 7, 2023
Merged
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
75e7231
added grouping select
YulNaumenko Jan 13, 2023
4500ea8
Merge remote-tracking branch 'upstream/main' into security-solution-g…
YulNaumenko Jan 13, 2023
7f94a8b
Basic single level functionality
YulNaumenko Jan 18, 2023
69e02fb
added paging and groups count
YulNaumenko Jan 19, 2023
955b411
fixed styles due to mockups
YulNaumenko Jan 20, 2023
e0e78d9
-
YulNaumenko Jan 23, 2023
5c48166
-
YulNaumenko Jan 23, 2023
faa974f
Merge remote-tracking branch 'upstream/main' into security-solution-g…
YulNaumenko Jan 23, 2023
251e85e
-
YulNaumenko Jan 23, 2023
18eb12d
Custom field select
YulNaumenko Jan 24, 2023
a581de8
-
YulNaumenko Jan 24, 2023
6f1d82a
refresh groups on table settings changed
YulNaumenko Jan 24, 2023
c9c7c01
Moved all the common code to grouping folder
YulNaumenko Jan 26, 2023
15b16d6
fixed take actions
YulNaumenko Jan 26, 2023
a17ed70
Merge remote-tracking branch 'upstream/main' into security-solution-g…
stephmilovic Jan 26, 2023
300e8b9
Fixed typechecks: labels issues
YulNaumenko Jan 26, 2023
e767f34
Merge branch 'main' into security-solution-grouping-mvp
YulNaumenko Jan 26, 2023
5bedcce
Added empty state panel
YulNaumenko Jan 26, 2023
4597358
Show one table at a time
YulNaumenko Jan 26, 2023
aa4ef24
Fixed empty panel and default title for group select
YulNaumenko Jan 26, 2023
014b476
Force accordion to close fix
YulNaumenko Jan 26, 2023
6a79345
right top menu test
stephmilovic Jan 27, 2023
1161121
Merge branch 'security-solution-grouping-mvp' of github.com:YulNaumen…
stephmilovic Jan 27, 2023
6b778a3
Move query from alerts file
YulNaumenko Jan 27, 2023
d31eb43
Splitted local storage for rule details and alerts pages
YulNaumenko Jan 27, 2023
2005485
tests for grouping accordion dir
stephmilovic Jan 27, 2023
3986c1e
Merge branch 'security-solution-grouping-mvp' of github.com:YulNaumen…
stephmilovic Jan 27, 2023
ded6584
more
stephmilovic Jan 27, 2023
2da0e98
more1
stephmilovic Jan 27, 2023
53720da
Design changes for select group control
YulNaumenko Jan 27, 2023
8d4b2c0
Merge remote-tracking branch 'origin/security-solution-grouping-mvp' …
YulNaumenko Jan 27, 2023
0013c42
more tests
stephmilovic Jan 27, 2023
cc868c6
fix typo
stephmilovic Jan 27, 2023
ad947d0
fix console errors
stephmilovic Jan 27, 2023
a469c35
more
stephmilovic Jan 27, 2023
7d8de87
rm whoops
stephmilovic Jan 27, 2023
d5738f7
more
stephmilovic Jan 28, 2023
1e7633e
More design changes
YulNaumenko Jan 28, 2023
a2a2e1f
Merge remote-tracking branch 'origin/security-solution-grouping-mvp' …
YulNaumenko Jan 28, 2023
f9e13ec
Merge branch 'main' into security-solution-grouping-mvp
YulNaumenko Jan 28, 2023
43541fc
Added detections tests
YulNaumenko Jan 29, 2023
797a038
fixed tests
YulNaumenko Jan 29, 2023
933c049
fixed tests
YulNaumenko Jan 30, 2023
d410ce5
-
YulNaumenko Jan 30, 2023
55ef3bb
trimmed the long field name to max 300px length
YulNaumenko Jan 30, 2023
e45a068
Merge branch 'main' into security-solution-grouping-mvp
YulNaumenko Jan 30, 2023
d0a862c
remove arrowDisplay="none"
stephmilovic Feb 1, 2023
c1617fa
moar fix
stephmilovic Feb 1, 2023
fb8e1d2
Merge branch 'main' into security-solution-grouping-mvp
YulNaumenko Feb 4, 2023
87d41e2
Fixed due to comments
YulNaumenko Feb 6, 2023
9457a38
Update x-pack/plugins/security_solution/public/common/components/grou…
YulNaumenko Feb 6, 2023
09cad3a
Update x-pack/plugins/security_solution/public/common/components/grou…
YulNaumenko Feb 6, 2023
d07948c
Fixed click on customField
YulNaumenko Feb 6, 2023
57bf483
Design changes
YulNaumenko Feb 6, 2023
6b36938
FIxed tests
YulNaumenko Feb 6, 2023
de28174
Merge branch 'main' into security-solution-grouping-mvp
YulNaumenko Feb 6, 2023
1802189
Merge branch 'main' into security-solution-grouping-mvp
kibanamachine Feb 7, 2023
7d1c21b
tests fix
YulNaumenko Feb 7, 2023
be77c53
Merge remote-tracking branch 'origin/security-solution-grouping-mvp' …
YulNaumenko Feb 7, 2023
2e64142
fixed default state to none
YulNaumenko Feb 7, 2023
429579b
Merge branch 'main' into security-solution-grouping-mvp
YulNaumenko Feb 7, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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();
});
});
});