[Security Solution] Alerts grouping, fix redux misuse (#156924)

This commit is contained in:
Steph Milovic 2023-05-30 14:28:51 -06:00 committed by GitHub
parent 28b4ec1490
commit 99d948c771
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 754 additions and 362 deletions

View file

@ -7,7 +7,7 @@
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useGetGroupSelector } from './use_get_group_selector';
import { useGetGroupSelector, useGetGroupSelectorStateless } from './use_get_group_selector';
import { initialState } from './state';
import { ActionType, defaultGroup } from '..';
import { METRIC_TYPE } from '@kbn/analytics';
@ -20,255 +20,302 @@ const defaultGroupingOptions = [
];
const groupingId = 'test-table';
const dispatch = jest.fn();
const defaultArgs = {
const onGroupChange = jest.fn();
const statelessArgs = {
defaultGroupingOptions,
dispatch,
fields: [],
groupingId,
fields: [],
onGroupChange,
maxGroupingLevels: 3,
};
const defaultArgs = {
...statelessArgs,
dispatch,
groupingState: initialState,
tracker: jest.fn(),
onGroupChange: jest.fn(),
};
const customField = 'custom.field';
describe('useGetGroupSelector', () => {
describe('Group Selector Hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Initializes a group with options', () => {
renderHook(() => useGetGroupSelector(defaultArgs));
expect(dispatch).toHaveBeenCalledWith({
payload: {
id: groupingId,
newOptionList: defaultGroupingOptions,
},
type: ActionType.updateGroupOptions,
describe('useGetGroupSelector', () => {
beforeEach(() => {
jest.clearAllMocks();
});
});
it('Initializes a group with custom selected group', () => {
renderHook(() =>
useGetGroupSelector({
...defaultArgs,
groupingState: {
groupById: { [groupingId]: { ...defaultGroup, activeGroups: [customField] } },
it('Initializes a group with options', () => {
renderHook(() => useGetGroupSelector(defaultArgs));
expect(dispatch).toHaveBeenCalledWith({
payload: {
id: groupingId,
newOptionList: defaultGroupingOptions,
},
})
);
type: ActionType.updateGroupOptions,
});
});
expect(dispatch).toHaveBeenCalledWith({
payload: {
id: groupingId,
newOptionList: [
...defaultGroupingOptions,
{
key: customField,
label: customField,
it('Initializes a group with custom selected group', () => {
renderHook(() =>
useGetGroupSelector({
...defaultArgs,
groupingState: {
groupById: { [groupingId]: { ...defaultGroup, activeGroups: [customField] } },
},
],
},
type: ActionType.updateGroupOptions,
});
});
})
);
it('On group change, removes selected group if already selected', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
expect(dispatch).toHaveBeenCalledWith({
payload: {
id: groupingId,
newOptionList: [
...defaultGroupingOptions,
{
key: customField,
label: customField,
},
],
},
},
type: ActionType.updateGroupOptions,
});
});
act(() => result.current.props.onGroupChange('host.name'));
expect(dispatch).toHaveBeenCalledWith({
payload: {
id: groupingId,
activeGroups: ['none'],
},
type: ActionType.updateActiveGroups,
});
});
it('Passes custom options to the onOptionsChange callback when it is provided', () => {
const onOptionsChange = jest.fn();
renderHook(() =>
useGetGroupSelector({
...defaultArgs,
groupingState: {
groupById: { [groupingId]: { ...defaultGroup, activeGroups: [customField] } },
},
onOptionsChange,
})
);
it('On group change to none, remove all previously selected groups', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name', 'user.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
expect(onOptionsChange).toHaveBeenCalledWith([
...defaultGroupingOptions,
{
key: customField,
label: customField,
},
},
]);
});
act(() => result.current.props.onGroupChange('none'));
expect(dispatch).toHaveBeenCalledWith({
payload: {
id: groupingId,
activeGroups: ['none'],
},
type: ActionType.updateActiveGroups,
});
});
it('On group change, resets active page, sets active group, and leaves options alone', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
it('On group change, removes selected group if already selected', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name'],
},
},
});
act(() => result.current.props.onGroupChange('user.name'));
expect(dispatch).toHaveBeenNthCalledWith(1, {
payload: {
id: groupingId,
activeGroups: ['host.name', 'user.name'],
},
type: ActionType.updateActiveGroups,
});
expect(dispatch).toHaveBeenCalledTimes(1);
});
it('On group change, sends telemetry', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
});
act(() => result.current.props.onGroupChange(customField));
expect(defaultArgs.tracker).toHaveBeenCalledTimes(1);
expect(defaultArgs.tracker).toHaveBeenCalledWith(
METRIC_TYPE.CLICK,
`alerts_table_group_by_test-table_${customField}`
);
});
it('On group change, executes callback', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
});
act(() => result.current.props.onGroupChange(customField));
expect(defaultArgs.onGroupChange).toHaveBeenCalledTimes(1);
expect(defaultArgs.onGroupChange).toHaveBeenCalledWith({
tableId: groupingId,
groupByField: customField,
});
});
it('On group change to custom field, updates options', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name'],
},
};
const { result, rerender } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
});
act(() => result.current.props.onGroupChange(customField));
expect(dispatch).toHaveBeenCalledTimes(1);
rerender({
...defaultArgs,
groupingState: {
groupById: {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name', customField],
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
},
});
act(() => result.current.props.onGroupChange('host.name'));
expect(dispatch).toHaveBeenCalledWith({
payload: {
id: groupingId,
activeGroups: ['none'],
},
type: ActionType.updateActiveGroups,
});
});
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(2, {
payload: {
newOptionList: [...defaultGroupingOptions, { label: customField, key: customField }],
id: 'test-table',
},
type: ActionType.updateGroupOptions,
it('On group change to none, remove all previously selected groups', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name', 'user.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
});
act(() => result.current.props.onGroupChange('none'));
expect(dispatch).toHaveBeenCalledWith({
payload: {
id: groupingId,
activeGroups: ['none'],
},
type: ActionType.updateActiveGroups,
});
});
it('On group change, resets active page, sets active group, and leaves options alone', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
});
act(() => result.current.props.onGroupChange('user.name'));
expect(dispatch).toHaveBeenNthCalledWith(1, {
payload: {
id: groupingId,
activeGroups: ['host.name', 'user.name'],
},
type: ActionType.updateActiveGroups,
});
expect(dispatch).toHaveBeenCalledTimes(1);
});
it('On group change, sends telemetry', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
});
act(() => result.current.props.onGroupChange(customField));
expect(defaultArgs.tracker).toHaveBeenCalledTimes(1);
expect(defaultArgs.tracker).toHaveBeenCalledWith(
METRIC_TYPE.CLICK,
`alerts_table_group_by_test-table_${customField}`
);
});
it('On group change, executes callback', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
});
act(() => result.current.props.onGroupChange(customField));
expect(defaultArgs.onGroupChange).toHaveBeenCalledTimes(1);
expect(defaultArgs.onGroupChange).toHaveBeenCalledWith({
tableId: groupingId,
groupByField: customField,
});
});
it('On group change to custom field, updates options', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name'],
},
};
const { result, rerender } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
});
act(() => result.current.props.onGroupChange(customField));
expect(dispatch).toHaveBeenCalledTimes(1);
rerender({
...defaultArgs,
groupingState: {
groupById: {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name', customField],
},
},
},
});
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(2, {
payload: {
newOptionList: [...defaultGroupingOptions, { label: customField, key: customField }],
id: 'test-table',
},
type: ActionType.updateGroupOptions,
});
});
it('Supports multiple custom fields on initial load', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name', customField, 'another.custom'],
},
};
renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
});
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({
payload: {
newOptionList: [
...defaultGroupingOptions,
{ label: customField, key: customField },
{ label: 'another.custom', key: 'another.custom' },
],
id: 'test-table',
},
type: ActionType.updateGroupOptions,
});
});
});
it('Supports multiple custom fields on initial load', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroups: ['host.name', customField, 'another.custom'],
},
};
renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
groupById: testGroup,
},
},
describe('useGetGroupSelectorStateless', () => {
it('Initializes a group with options', () => {
const { result } = renderHook(() => useGetGroupSelectorStateless(statelessArgs));
expect(result.current.props.groupingId).toEqual(groupingId);
expect(result.current.props.options.length).toEqual(4);
});
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith({
payload: {
newOptionList: [
...defaultGroupingOptions,
{ label: customField, key: customField },
{ label: 'another.custom', key: 'another.custom' },
],
id: 'test-table',
},
type: ActionType.updateGroupOptions,
it('On group change, removes selected group if already selected', () => {
const { result } = renderHook(() => useGetGroupSelectorStateless(statelessArgs));
act(() => result.current.props.onGroupChange('host.name'));
expect(onGroupChange).toHaveBeenCalledWith(['host.name']);
});
});
});

View file

@ -23,6 +23,7 @@ export interface UseGetGroupSelectorArgs {
groupingId: string;
groupingState: GroupMap;
maxGroupingLevels?: number;
onOptionsChange?: (newOptions: GroupOption[]) => void;
onGroupChange?: (param: { groupByField: string; tableId: string }) => void;
tracker?: (
type: UiCounterMetricType,
@ -31,6 +32,48 @@ export interface UseGetGroupSelectorArgs {
) => void;
}
interface UseGetGroupSelectorStateless
extends Pick<
UseGetGroupSelectorArgs,
'defaultGroupingOptions' | 'groupingId' | 'fields' | 'maxGroupingLevels'
> {
onGroupChange: (selectedGroups: string[]) => void;
}
// only use this component to use a group selector that displays when isNoneGroup is true
// by selecting a group with the groupSelectorStateless component
// the contents are within the grouping component and from that point
// the grouping component will handle the group selector. When the group selector is set back to none,
// the consumer can again use the groupSelectorStateless component to select a new group
export const useGetGroupSelectorStateless = ({
defaultGroupingOptions,
groupingId,
fields,
onGroupChange,
maxGroupingLevels,
}: UseGetGroupSelectorStateless) => {
const onChange = useCallback(
(groupSelection: string) => {
onGroupChange([groupSelection]);
},
[onGroupChange]
);
return (
<GroupSelector
{...{
groupingId,
groupsSelected: ['none'],
'data-test-subj': 'alerts-table-group-selector',
onGroupChange: onChange,
fields,
maxGroupingLevels,
options: defaultGroupingOptions,
}}
/>
);
};
export const useGetGroupSelector = ({
defaultGroupingOptions,
dispatch,
@ -39,6 +82,7 @@ export const useGetGroupSelector = ({
groupingState,
maxGroupingLevels = 1,
onGroupChange,
onOptionsChange,
tracker,
}: UseGetGroupSelectorArgs) => {
const { activeGroups: selectedGroups, options } =
@ -59,8 +103,9 @@ export const useGetGroupSelector = ({
const setOptions = useCallback(
(newOptions: GroupOption[]) => {
dispatch(groupActions.updateGroupOptions({ id: groupingId, newOptionList: newOptions }));
onOptionsChange?.(newOptions);
},
[dispatch, groupingId]
[dispatch, groupingId, onOptionsChange]
);
const onChange = useCallback(
@ -92,7 +137,7 @@ export const useGetGroupSelector = ({
);
useEffect(() => {
if (options.length === 0) {
if (options.length === 0 && defaultGroupingOptions.length > 0) {
return setOptions(
defaultGroupingOptions.find((o) => selectedGroups.find((selected) => selected === o.key))
? defaultGroupingOptions

View file

@ -11,7 +11,7 @@ import React, { useCallback, useMemo, useReducer } from 'react';
import { UiCounterMetricType } from '@kbn/analytics';
import { groupsReducerWithStorage, initialState } from './state/reducer';
import { GroupingProps, GroupSelectorProps, isNoneGroup } from '..';
import { groupByIdSelector } from './state';
import { groupActions, groupByIdSelector } from './state';
import { useGetGroupSelector } from './use_get_group_selector';
import { defaultGroup, GroupOption } from './types';
import { Grouping as GroupingComponent } from '../components/grouping';
@ -23,6 +23,7 @@ interface Grouping<T> {
getGrouping: (props: DynamicGroupingProps<T>) => React.ReactElement;
groupSelector: React.ReactElement<GroupSelectorProps>;
selectedGroups: string[];
setSelectedGroups: (selectedGroups: string[]) => void;
}
/** Type for static grouping component props where T is the consumer `GroupingAggregation`
@ -65,6 +66,7 @@ interface GroupingArgs<T> {
* @param param { groupByField: string; tableId: string } selected group and table id
*/
onGroupChange?: (param: { groupByField: string; tableId: string }) => void;
onOptionsChange?: (options: GroupOption[]) => void;
tracker?: (
type: UiCounterMetricType,
event: string | string[],
@ -81,6 +83,7 @@ interface GroupingArgs<T> {
* @param groupingId Unique identifier of the grouping component. Used in local storage
* @param maxGroupingLevels maximum group nesting levels (optional)
* @param onGroupChange callback executed when selected group is changed, used for tracking
* @param onOptionsChange callback executed when grouping options are changed, used for consumer grouping selector
* @param tracker telemetry handler
* @returns {@link Grouping} the grouping constructor { getGrouping, groupSelector, pagination, selectedGroups }
*/
@ -91,6 +94,7 @@ export const useGrouping = <T,>({
groupingId,
maxGroupingLevels,
onGroupChange,
onOptionsChange,
tracker,
}: GroupingArgs<T>): Grouping<T> => {
const [groupingState, dispatch] = useReducer(groupsReducerWithStorage, initialState);
@ -99,6 +103,18 @@ export const useGrouping = <T,>({
[groupingId, groupingState]
);
const setSelectedGroups = useCallback(
(activeGroups: string[]) => {
dispatch(
groupActions.updateActiveGroups({
id: groupingId,
activeGroups,
})
);
},
[groupingId]
);
const groupSelector = useGetGroupSelector({
defaultGroupingOptions,
dispatch,
@ -107,6 +123,7 @@ export const useGrouping = <T,>({
groupingState,
maxGroupingLevels,
onGroupChange,
onOptionsChange,
tracker,
});
@ -135,7 +152,8 @@ export const useGrouping = <T,>({
getGrouping,
groupSelector,
selectedGroups,
setSelectedGroups,
}),
[getGrouping, groupSelector, selectedGroups]
[getGrouping, groupSelector, selectedGroups, setSelectedGroups]
);
};

View file

@ -6,10 +6,12 @@
*/
import actionCreatorFactory from 'typescript-fsa';
import type React from 'react';
import type { TableId } from '@kbn/securitysolution-data-table';
const actionCreator = actionCreatorFactory('x-pack/security_solution/groups');
export const updateGroupSelector = actionCreator<{
groupSelector: React.ReactElement | null;
}>('UPDATE_GROUP_SELECTOR');
export const updateGroups = actionCreator<{
activeGroups?: string[];
tableId: TableId;
options?: Array<{ key: string; label: string }>;
}>('UPDATE_GROUPS');

View file

@ -6,17 +6,21 @@
*/
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { updateGroupSelector } from './actions';
import type { GroupModel } from './types';
import { getDefaultGroupingOptions } from '../../utils/alerts';
import { updateGroups } from './actions';
import type { Groups } from './types';
export const initialGroupingState: GroupModel = {
groupSelector: null,
};
export const initialGroupingState: Groups = {};
export const groupsReducer = reducerWithInitialState(initialGroupingState).case(
updateGroupSelector,
(state, { groupSelector }) => ({
updateGroups,
(state, { tableId, ...rest }) => ({
...state,
groupSelector,
[tableId]: {
activeGroups: [],
options: getDefaultGroupingOptions(tableId),
...(state[tableId] ? state[tableId] : {}),
...rest,
},
})
);

View file

@ -6,8 +6,7 @@
*/
import { createSelector } from 'reselect';
import type { GroupState } from './types';
import type { GroupModel, GroupState } from './types';
const groupSelector = (state: GroupState) => state.groups.groupSelector;
export const getGroupSelector = () => createSelector(groupSelector, (selector) => selector);
export const groupSelector = ({ groups }: GroupState, id: string): GroupModel => groups[id];
export const groupIdSelector = () => createSelector(groupSelector, (group) => group);

View file

@ -6,9 +6,14 @@
*/
export interface GroupModel {
groupSelector: React.ReactElement | null;
activeGroups: string[];
options: Array<{ key: string; label: string }>;
}
export interface Groups {
[tableId: string]: GroupModel;
}
export interface GroupState {
groups: GroupModel;
groups: Groups;
}

View file

@ -8,6 +8,9 @@
import { merge } from '@kbn/std';
import { isPlainObject } from 'lodash';
import type { Ecs } from '@kbn/cases-plugin/common';
import { TableId } from '@kbn/securitysolution-data-table';
import type { GroupOption } from '@kbn/securitysolution-grouping';
import * as i18n from './translations';
export const buildAlertsQuery = (alertIds: string[]) => {
if (alertIds.length === 0) {
@ -118,3 +121,47 @@ export interface Alert {
signal: Signal;
[key: string]: unknown;
}
// generates default grouping option for alerts table
export const getDefaultGroupingOptions = (tableId: TableId): GroupOption[] => {
if (tableId === TableId.alertsOnAlertsPage) {
return [
{
label: i18n.ruleName,
key: 'kibana.alert.rule.name',
},
{
label: i18n.userName,
key: 'user.name',
},
{
label: i18n.hostName,
key: 'host.name',
},
{
label: i18n.sourceIP,
key: 'source.ip',
},
];
} else if (tableId === TableId.alertsOnRuleDetailsPage) {
return [
{
label: i18n.sourceAddress,
key: 'source.address',
},
{
label: i18n.userName,
key: 'user.name',
},
{
label: i18n.hostName,
key: 'host.name',
},
{
label: i18n.destinationAddress,
key: 'destination.address,',
},
];
}
return [];
};

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const ruleName = i18n.translate('xpack.securitySolution.selector.groups.ruleName.label', {
defaultMessage: 'Rule name',
});
export const userName = i18n.translate('xpack.securitySolution.selector.grouping.userName.label', {
defaultMessage: 'User name',
});
export const hostName = i18n.translate('xpack.securitySolution.selector.grouping.hostName.label', {
defaultMessage: 'Host name',
});
export const sourceIP = i18n.translate('xpack.securitySolution.selector.grouping.sourceIP.label', {
defaultMessage: 'Source IP',
});
export const sourceAddress = i18n.translate(
'xpack.securitySolution.selector.groups.sourceAddress.label',
{
defaultMessage: 'Source address',
}
);
export const destinationAddress = i18n.translate(
'xpack.securitySolution.selector.groups.destinationAddress.label',
{
defaultMessage: 'Destination address',
}
);

View file

@ -58,8 +58,8 @@ const mockOptions = [
{ label: 'sourceIP', key: 'source.ip' },
];
jest.mock('./grouping_settings', () => {
const actual = jest.requireActual('./grouping_settings');
jest.mock('../../../common/utils/alerts', () => {
const actual = jest.requireActual('../../../common/utils/alerts');
return {
...actual,
@ -159,7 +159,17 @@ describe('GroupedAlertsTable', () => {
beforeEach(() => {
jest.clearAllMocks();
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
store = createStore(
{
...mockGlobalState,
groups: {
[testProps.tableId]: { options: mockOptions, activeGroups: ['kibana.alert.rule.name'] },
},
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage
);
(useSourcererDataView as jest.Mock).mockReturnValue({
...sourcererDataView,
selectedPatterns: ['myFakebeat-*'],
@ -176,6 +186,7 @@ describe('GroupedAlertsTable', () => {
});
it('calls the proper initial dispatch actions for groups', () => {
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const { getByTestId, queryByTestId } = render(
<TestProviders store={store}>
<GroupedAlertsTable {...testProps} />
@ -185,10 +196,16 @@ describe('GroupedAlertsTable', () => {
expect(queryByTestId('empty-results-panel')).not.toBeInTheDocument();
expect(queryByTestId('group-selector-dropdown')).not.toBeInTheDocument();
expect(getByTestId('alerts-table')).toBeInTheDocument();
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch.mock.calls[0][0].type).toEqual(
'x-pack/security_solution/groups/UPDATE_GROUP_SELECTOR'
);
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch.mock.calls[0][0].payload).toEqual({
options: mockOptions,
tableId: testProps.tableId,
});
expect(mockDispatch.mock.calls[1][0].payload).toEqual({
activeGroups: ['none'],
tableId: testProps.tableId,
});
});
it('renders empty grouping table when group is selected without data', () => {
@ -196,6 +213,7 @@ describe('GroupedAlertsTable', () => {
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name']));
const { getByTestId, queryByTestId } = render(
<TestProviders store={store}>
<GroupedAlertsTable {...testProps} />
@ -242,6 +260,20 @@ describe('GroupedAlertsTable', () => {
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name']));
store = createStore(
{
...mockGlobalState,
groups: {
[testProps.tableId]: {
options: mockOptions,
activeGroups: ['kibana.alert.rule.name', 'host.name'],
},
},
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage
);
const { getByTestId } = render(
<TestProviders store={store}>
@ -260,6 +292,20 @@ describe('GroupedAlertsTable', () => {
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
store = createStore(
{
...mockGlobalState,
groups: {
[testProps.tableId]: {
options: mockOptions,
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
},
},
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage
);
const { getByTestId, getAllByTestId } = render(
<TestProviders store={store}>
@ -309,6 +355,20 @@ describe('GroupedAlertsTable', () => {
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
store = createStore(
{
...mockGlobalState,
groups: {
[testProps.tableId]: {
options: mockOptions,
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
},
},
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage
);
const { getByTestId, rerender } = render(
<TestProviders store={store}>
@ -345,9 +405,20 @@ describe('GroupedAlertsTable', () => {
});
it('resets only most inner group pagination when its parent groups open/close', () => {
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
store = createStore(
{
...mockGlobalState,
groups: {
[testProps.tableId]: {
options: mockOptions,
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
},
},
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage
);
const { getByTestId } = render(
<TestProviders store={store}>
@ -397,11 +468,22 @@ describe('GroupedAlertsTable', () => {
});
it(`resets innermost level's current page when that level's page size updates`, () => {
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
store = createStore(
{
...mockGlobalState,
groups: {
[testProps.tableId]: {
options: mockOptions,
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
},
},
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage
);
const { getByTestId, getAllByTestId } = render(
const { getByTestId } = render(
<TestProviders store={store}>
<GroupedAlertsTable {...testProps} />
</TestProviders>
@ -409,12 +491,10 @@ describe('GroupedAlertsTable', () => {
fireEvent.click(getByTestId('pagination-button-1'));
fireEvent.click(within(getByTestId('level-0-group-0')).getByTestId('group-panel-toggle'));
fireEvent.click(within(getByTestId('level-0-group-0')).getByTestId('pagination-button-1'));
fireEvent.click(within(getByTestId('level-1-group-0')).getByTestId('group-panel-toggle'));
const level1 = getAllByTestId('grouping-accordion-content')[1];
fireEvent.click(within(level1).getByTestId('pagination-button-1'));
fireEvent.click(within(getByTestId('level-1-group-0')).getByTestId('pagination-button-1'));
fireEvent.click(
within(getByTestId('grouping-level-2')).getByTestId('tablePaginationPopoverButton')
);
@ -441,9 +521,20 @@ describe('GroupedAlertsTable', () => {
});
it(`resets outermost level's current page when that level's page size updates`, () => {
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
store = createStore(
{
...mockGlobalState,
groups: {
[testProps.tableId]: {
options: mockOptions,
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
},
},
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage
);
const { getByTestId, getAllByTestId } = render(
<TestProviders store={store}>

View file

@ -7,21 +7,21 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import type { Filter, Query } from '@kbn/es-query';
import type { GroupOption } from '@kbn/securitysolution-grouping';
import { isNoneGroup, useGrouping } from '@kbn/securitysolution-grouping';
import { isEmpty, isEqual } from 'lodash/fp';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import type { TableIdLiteral } from '@kbn/securitysolution-data-table';
import { groupSelectors } from '../../../common/store/grouping';
import type { State } from '../../../common/store';
import { updateGroupSelector } from '../../../common/store/grouping/actions';
import { getDefaultGroupingOptions } from '../../../common/utils/alerts';
import { groupIdSelector } from '../../../common/store/grouping/selectors';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { updateGroups } from '../../../common/store/grouping/actions';
import type { Status } from '../../../../common/detection_engine/schemas/common';
import { defaultUnit } from '../../../common/components/toolbar/unit';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { getDefaultGroupingOptions, renderGroupPanel, getStats } from './grouping_settings';
import { renderGroupPanel, getStats } from './grouping_settings';
import { useKibana } from '../../../common/lib/kibana';
import { GroupedSubLevel } from './alerts_sub_grouping';
import { track } from '../../../common/lib/telemetry';
@ -89,7 +89,19 @@ const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = (props)
[telemetry]
);
const { groupSelector, getGrouping, selectedGroups } = useGrouping({
const onOptionsChange = useCallback(
(options) => {
dispatch(
updateGroups({
tableId: props.tableId,
options,
})
);
},
[dispatch, props.tableId]
);
const { getGrouping, selectedGroups, setSelectedGroups } = useGrouping({
componentProps: {
groupPanelRenderer: renderGroupPanel,
groupStatsRenderer: getStats,
@ -101,27 +113,30 @@ const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = (props)
groupingId: props.tableId,
maxGroupingLevels: MAX_GROUPING_LEVELS,
onGroupChange,
onOptionsChange,
tracker: track,
});
const getGroupSelector = groupSelectors.getGroupSelector();
const groupSelectorInRedux = useSelector((state: State) => getGroupSelector(state));
const selectorOptions = useRef<GroupOption[]>([]);
const groupInRedux = useDeepEqualSelector((state) => groupIdSelector()(state, props.tableId));
useEffect(() => {
// only ever set to `none` - siem only handles group selector when `none` is selected
if (isNoneGroup(selectedGroups)) {
// set active groups from selected groups
dispatch(
updateGroups({
activeGroups: selectedGroups,
tableId: props.tableId,
})
);
}
}, [dispatch, props.tableId, selectedGroups]);
useEffect(() => {
if (
isNoneGroup(selectedGroups) &&
groupSelector.props.options.length > 0 &&
(groupSelectorInRedux == null ||
!isEqual(selectorOptions.current, groupSelector.props.options))
) {
selectorOptions.current = groupSelector.props.options;
dispatch(updateGroupSelector({ groupSelector }));
} else if (!isNoneGroup(selectedGroups) && groupSelectorInRedux !== null) {
dispatch(updateGroupSelector({ groupSelector: null }));
if (groupInRedux != null && !isNoneGroup(groupInRedux.activeGroups)) {
// set selected groups from active groups
setSelectedGroups(groupInRedux.activeGroups);
}
}, [dispatch, groupSelector, groupSelectorInRedux, selectedGroups]);
}, [groupInRedux, setSelectedGroups]);
const [pageIndex, setPageIndex] = useState<number[]>(
Array(MAX_GROUPING_LEVELS).fill(DEFAULT_PAGE_INDEX)

View file

@ -4,54 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { GroupOption } from '@kbn/securitysolution-grouping';
import { TableId } from '@kbn/securitysolution-data-table';
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): GroupOption[] => {
if (tableId === TableId.alertsOnAlertsPage) {
return [
{
label: i18n.ruleName,
key: 'kibana.alert.rule.name',
},
{
label: i18n.userName,
key: 'user.name',
},
{
label: i18n.hostName,
key: 'host.name',
},
{
label: i18n.sourceIP,
key: 'source.ip',
},
];
} else if (tableId === TableId.alertsOnRuleDetailsPage) {
return [
{
label: i18n.sourceAddress,
key: 'source.address',
},
{
label: i18n.userName,
key: 'user.name',
},
{
label: i18n.hostName,
key: 'host.name',
},
{
label: i18n.destinationAddress,
key: 'destination.address,',
},
];
}
return [];
};

View file

@ -383,32 +383,6 @@ export const STATS_GROUP_SEVERITY_MEDIUM = i18n.translate(
}
);
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',
{

View file

@ -0,0 +1,119 @@
/*
* 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 { getPersistentControlsHook } from './use_persistent_controls';
import { TableId } from '@kbn/securitysolution-data-table';
import { renderHook } from '@testing-library/react-hooks';
import { render, fireEvent } from '@testing-library/react';
import {
createSecuritySolutionStorageMock,
kibanaObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
TestProviders,
} from '../../../common/mock';
import { createStore } from '../../../common/store';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector';
import { useKibana as mockUseKibana } from '../../../common/lib/kibana/__mocks__';
import { createTelemetryServiceMock } from '../../../common/lib/telemetry/telemetry_service.mock';
const mockDispatch = jest.fn();
const mockedUseKibana = mockUseKibana();
const mockedTelemetry = createTelemetryServiceMock();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
jest.mock('../../../common/containers/sourcerer');
jest.mock('../../../common/hooks/use_selector');
jest.mock('../../../common/lib/kibana', () => {
const original = jest.requireActual('../../../common/lib/kibana');
return {
...original,
useKibana: () => ({
...mockedUseKibana,
services: {
...mockedUseKibana.services,
telemetry: mockedTelemetry,
},
}),
};
});
const sourcererDataView = {
indicesExist: true,
loading: false,
indexPattern: {
fields: [],
},
browserFields: {},
};
const mockOptions = [
{ label: 'ruleName', key: 'kibana.alert.rule.name' },
{ label: 'userName', key: 'user.name' },
{ label: 'hostName', key: 'host.name' },
{ label: 'sourceIP', key: 'source.ip' },
];
const tableId = TableId.test;
const groups = {
[tableId]: { options: mockOptions, activeGroups: ['kibana.alert.rule.name'] },
};
describe('usePersistentControls', () => {
const { storage } = createSecuritySolutionStorageMock();
let store: ReturnType<typeof createStore>;
beforeEach(() => {
(useDeepEqualSelector as jest.Mock).mockImplementation(() => groups[tableId]);
(useShallowEqualSelector as jest.Mock).mockReturnValue({
showOnlyThreatIndicatorAlerts: false,
showBuildBlockAlerts: false,
});
jest.clearAllMocks();
store = createStore(
{
...mockGlobalState,
groups,
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage
);
(useSourcererDataView as jest.Mock).mockReturnValue({
...sourcererDataView,
selectedPatterns: ['myFakebeat-*'],
});
});
afterEach(() => {
(useDeepEqualSelector as jest.Mock).mockClear();
});
test('Should render the group selector component and allow the user to select a grouping field', () => {
const usePersistentControls = getPersistentControlsHook(tableId);
const { result } = renderHook(() => usePersistentControls(), {
wrapper: ({ children }) => <TestProviders store={store}>{children}</TestProviders>,
});
const groupSelector = result.current.right.props.additionalMenuOptions[0];
const { getByTestId } = render(<TestProviders store={store}>{groupSelector}</TestProviders>);
fireEvent.click(getByTestId('group-selector-dropdown'));
fireEvent.click(getByTestId('panel-user.name'));
expect(mockDispatch.mock.calls[0][0].payload).toEqual({
activeGroups: ['user.name'],
tableId,
});
});
});

View file

@ -6,28 +6,66 @@
*/
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import {
dataTableSelectors,
tableDefaults,
dataTableActions,
} from '@kbn/securitysolution-data-table';
import type { ViewSelection, TableId } from '@kbn/securitysolution-data-table';
import type { State } from '../../../common/store';
import { useGetGroupSelectorStateless } from '@kbn/securitysolution-grouping/src/hooks/use_get_group_selector';
import { getTelemetryEvent } from '@kbn/securitysolution-grouping/src/telemetry/const';
import { groupIdSelector } from '../../../common/store/grouping/selectors';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { updateGroups } from '../../../common/store/grouping/actions';
import { useKibana } from '../../../common/lib/kibana';
import { METRIC_TYPE, track } from '../../../common/lib/telemetry';
import { useDataTableFilters } from '../../../common/hooks/use_data_table_filters';
import { useShallowEqualSelector } from '../../../common/hooks/use_selector';
import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector';
import { RightTopMenu } from '../../../common/components/events_viewer/right_top_menu';
import { AdditionalFiltersAction } from '../../components/alerts_table/additional_filters_action';
import { groupSelectors } from '../../../common/store/grouping';
const { changeViewMode } = dataTableActions;
export const getPersistentControlsHook = (tableId: TableId) => {
const usePersistentControls = () => {
const dispatch = useDispatch();
const getGroupSelector = groupSelectors.getGroupSelector();
const {
services: { telemetry },
} = useKibana();
const groupSelector = useSelector((state: State) => getGroupSelector(state));
const { indexPattern } = useSourcererDataView(SourcererScopeName.detections);
const { options } = useDeepEqualSelector((state) => groupIdSelector()(state, tableId)) ?? {
options: [],
};
const trackGroupChange = useCallback(
(groupSelection: string) => {
track?.(
METRIC_TYPE.CLICK,
getTelemetryEvent.groupChanged({ groupingId: tableId, selected: groupSelection })
);
telemetry.reportAlertsGroupingChanged({ groupByField: groupSelection, tableId });
},
[telemetry]
);
const onGroupChange = useCallback(
(selectedGroups: string[]) => {
selectedGroups.forEach((g) => trackGroupChange(g));
dispatch(updateGroups({ activeGroups: selectedGroups, tableId }));
},
[dispatch, trackGroupChange]
);
const groupSelector = useGetGroupSelectorStateless({
groupingId: tableId,
onGroupChange,
fields: indexPattern.fields,
defaultGroupingOptions: options,
maxGroupingLevels: 3,
});
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);