[SIEM] Use bulk actions API when updating or deleting rules (#54521) (#54663)

## Summary

This PR updates the `All Rules Table` actions to use the new bulk API introduced in https://github.com/elastic/kibana/pull/53543. More robust error reporting has also been added to let the user know exactly which operation has failed. Note that individual `update`/`delete` requests now also go through the bulk API as this simplifies the implementation and error handling.

Additional features:
* Adds toast error when failing to activate, deactivate or delete a rule (related https://github.com/elastic/kibana/issues/54515)
* Extracted commonly used toast utility for better re-use
* Removes ability to delete `immutable` rules


##### Activate/Deactivate Before:
![bulk_activate_before](https://user-images.githubusercontent.com/2946766/72196245-0ea50300-33d4-11ea-8d49-5ebdb63db1a1.gif)
(Ignore failed requests from test env -- request count is important here)


##### Activate/Deactivate After:
![bulk_activate_after](https://user-images.githubusercontent.com/2946766/72196361-c0443400-33d4-11ea-9a42-11f66c64e925.gif)



##### Delete Before:
![bulk_delete_before](https://user-images.githubusercontent.com/2946766/72196249-149ae400-33d4-11ea-80fc-b2f7fb83245f.gif)
(Ignore failed requests from test env -- request count is important here)

##### Delete After:
![bulk_delete_after](https://user-images.githubusercontent.com/2946766/72196366-c803d880-33d4-11ea-90d8-f1917b18035f.gif)

### Checklist

Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR.

- [x] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)
- [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)
- [ ] ~[Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~
- [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios
- [ ] ~This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~

### For maintainers

- [ ] ~This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~
- [ ] ~This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~
This commit is contained in:
Garrett Spong 2020-01-13 17:19:33 -07:00 committed by GitHub
parent a90e9fd204
commit dd05b8b911
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 500 additions and 120 deletions

View file

@ -15,10 +15,10 @@ import { DEFAULT_INDEX_KEY } from '../../../common/constants';
import { getIndexPatternTitleIdMapping } from '../../hooks/api/helpers';
import { useIndexPatterns } from '../../hooks/use_index_patterns';
import { Loader } from '../loader';
import { useStateToaster } from '../toasters';
import { displayErrorToast, useStateToaster } from '../toasters';
import { Embeddable } from './embeddable';
import { EmbeddableHeader } from './embeddable_header';
import { createEmbeddable, displayErrorToast } from './embedded_map_helpers';
import { createEmbeddable } from './embedded_map_helpers';
import { IndexPatternsMissingPrompt } from './index_patterns_missing_prompt';
import { MapToolTip } from './map_tool_tip/map_tool_tip';
import * as i18n from './translations';
@ -134,7 +134,7 @@ export const EmbeddedMapComponent = ({
}
} catch (e) {
if (isSubscribed) {
displayErrorToast(i18n.ERROR_CREATING_EMBEDDABLE, e.message, dispatchToaster);
displayErrorToast(i18n.ERROR_CREATING_EMBEDDABLE, [e.message], dispatchToaster);
setIsError(true);
}
}

View file

@ -4,19 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { createEmbeddable, displayErrorToast } from './embedded_map_helpers';
import { createEmbeddable } from './embedded_map_helpers';
import { createUiNewPlatformMock } from 'ui/new_platform/__mocks__/helpers';
import { createPortalNode } from 'react-reverse-portal';
jest.mock('ui/new_platform');
jest.mock('uuid', () => {
return {
v1: jest.fn(() => '27261ae0-0bbb-11ea-b0ea-db767b07ea47'),
v4: jest.fn(() => '9e1f72a9-7c73-4b7f-a562-09940f7daf4a'),
};
});
const { npStart } = createUiNewPlatformMock();
npStart.plugins.embeddable.getEmbeddableFactory = jest.fn().mockImplementation(() => ({
createFromState: () => ({
@ -25,24 +18,6 @@ npStart.plugins.embeddable.getEmbeddableFactory = jest.fn().mockImplementation((
}));
describe('embedded_map_helpers', () => {
describe('displayErrorToast', () => {
test('dispatches toast with correct title and message', () => {
const mockToast = {
toast: {
color: 'danger',
errors: ['message'],
iconType: 'alert',
id: '9e1f72a9-7c73-4b7f-a562-09940f7daf4a',
title: 'Title',
},
type: 'addToaster',
};
const dispatchToasterMock = jest.fn();
displayErrorToast('Title', 'message', dispatchToasterMock);
expect(dispatchToasterMock.mock.calls[0][0]).toEqual(mockToast);
});
});
describe('createEmbeddable', () => {
test('attaches refresh action', async () => {
const setQueryMock = jest.fn();

View file

@ -7,7 +7,6 @@
import uuid from 'uuid';
import React from 'react';
import { OutPortal, PortalNode } from 'react-reverse-portal';
import { ActionToaster, AppToast } from '../toasters';
import { ViewMode } from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public';
import {
IndexPatternMapping,
@ -22,31 +21,6 @@ import { MAP_SAVED_OBJECT_TYPE } from '../../../../maps/common/constants';
import * as i18n from './translations';
import { Query, esFilters } from '../../../../../../../src/plugins/data/public';
/**
* Displays an error toast for the provided title and message
*
* @param errorTitle Title of error to display in toaster and modal
* @param errorMessage Message to display in error modal when clicked
* @param dispatchToaster provided by useStateToaster()
*/
export const displayErrorToast = (
errorTitle: string,
errorMessage: string,
dispatchToaster: React.Dispatch<ActionToaster>
) => {
const toast: AppToast = {
id: uuid.v4(),
title: errorTitle,
color: 'danger',
iconType: 'alert',
errors: [errorMessage],
};
dispatchToaster({
type: 'addToaster',
toast,
});
};
/**
* Creates MapEmbeddable with provided initial configuration
*

View file

@ -8,7 +8,20 @@ import { cloneDeep, set } from 'lodash/fp';
import { mount } from 'enzyme';
import React, { useEffect } from 'react';
import { AppToast, useStateToaster, ManageGlobalToaster, GlobalToaster } from '.';
import {
AppToast,
useStateToaster,
ManageGlobalToaster,
GlobalToaster,
displayErrorToast,
} from '.';
jest.mock('uuid', () => {
return {
v1: jest.fn(() => '27261ae0-0bbb-11ea-b0ea-db767b07ea47'),
v4: jest.fn(() => '9e1f72a9-7c73-4b7f-a562-09940f7daf4a'),
};
});
const mockToast: AppToast = {
color: 'danger',
@ -270,4 +283,22 @@ describe('Toaster', () => {
expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test');
});
});
describe('displayErrorToast', () => {
test('dispatches toast with correct title and message', () => {
const mockErrorToast = {
toast: {
color: 'danger',
errors: ['message'],
iconType: 'alert',
id: '9e1f72a9-7c73-4b7f-a562-09940f7daf4a',
title: 'Title',
},
type: 'addToaster',
};
const dispatchToasterMock = jest.fn();
displayErrorToast('Title', ['message'], dispatchToasterMock);
expect(dispatchToasterMock.mock.calls[0][0]).toEqual(mockErrorToast);
});
});
});

View file

@ -8,6 +8,7 @@ import { EuiGlobalToastList, EuiGlobalToastListToast as Toast, EuiButton } from
import { noop } from 'lodash/fp';
import React, { createContext, Dispatch, useReducer, useContext, useState } from 'react';
import styled from 'styled-components';
import uuid from 'uuid';
import { ModalAllErrors } from './modal_all_errors';
import * as i18n from './translations';
@ -122,3 +123,28 @@ const ErrorToastContainer = styled.div`
`;
ErrorToastContainer.displayName = 'ErrorToastContainer';
/**
* Displays an error toast for the provided title and message
*
* @param errorTitle Title of error to display in toaster and modal
* @param errorMessages Message to display in error modal when clicked
* @param dispatchToaster provided by useStateToaster()
*/
export const displayErrorToast = (
errorTitle: string,
errorMessages: string[],
dispatchToaster: React.Dispatch<ActionToaster>
) => {
const toast: AppToast = {
id: uuid.v4(),
title: errorTitle,
color: 'danger',
iconType: 'alert',
errors: errorMessages,
};
dispatchToaster({
type: 'addToaster',
toast,
});
};

View file

@ -16,6 +16,7 @@ import {
Rule,
FetchRuleProps,
BasicFetchProps,
RuleError,
} from './types';
import { throwIfNotOk } from '../../../hooks/api/api';
import {
@ -122,50 +123,50 @@ export const fetchRuleById = async ({ id, signal }: FetchRuleProps): Promise<Rul
*
* @param ids array of Rule ID's (not rule_id) to enable/disable
* @param enabled to enable or disable
*
* @throws An error if response is not OK
*/
export const enableRules = async ({ ids, enabled }: EnableRulesProps): Promise<Rule[]> => {
const requests = ids.map(id =>
fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}`, {
const response = await fetch(
`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_bulk_update`,
{
method: 'PUT',
credentials: 'same-origin',
headers: {
'content-type': 'application/json',
'kbn-xsrf': 'true',
},
body: JSON.stringify({ id, enabled }),
})
body: JSON.stringify(ids.map(id => ({ id, enabled }))),
}
);
const responses = await Promise.all(requests);
await responses.map(response => throwIfNotOk(response));
return Promise.all(
responses.map<Promise<Rule>>(response => response.json())
);
await throwIfNotOk(response);
return response.json();
};
/**
* Deletes provided Rule ID's
*
* @param ids array of Rule ID's (not rule_id) to delete
*
* @throws An error if response is not OK
*/
export const deleteRules = async ({ ids }: DeleteRulesProps): Promise<Rule[]> => {
// TODO: Don't delete if immutable!
const requests = ids.map(id =>
fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}?id=${id}`, {
export const deleteRules = async ({ ids }: DeleteRulesProps): Promise<Array<Rule | RuleError>> => {
const response = await fetch(
`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_bulk_delete`,
{
method: 'DELETE',
credentials: 'same-origin',
headers: {
'content-type': 'application/json',
'kbn-xsrf': 'true',
},
})
body: JSON.stringify(ids.map(id => ({ id }))),
}
);
const responses = await Promise.all(requests);
await responses.map(response => throwIfNotOk(response));
return Promise.all(
responses.map<Promise<Rule>>(response => response.json())
);
await throwIfNotOk(response);
return response.json();
};
/**

View file

@ -78,9 +78,11 @@ export const RuleSchema = t.intersection([
updated_by: t.string,
}),
t.partial({
output_index: t.string,
saved_id: t.string,
timeline_id: t.string,
timeline_title: t.string,
version: t.number,
}),
]);
@ -89,6 +91,16 @@ export const RulesSchema = t.array(RuleSchema);
export type Rule = t.TypeOf<typeof RuleSchema>;
export type Rules = t.TypeOf<typeof RulesSchema>;
export interface RuleError {
rule_id: string;
error: { status_code: number; message: string };
}
export interface RuleResponseBuckets {
rules: Rule[];
errors: RuleError[];
}
export interface PaginationOptions {
page: number;
perPage: number;

View file

@ -0,0 +1,154 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Rule, RuleError } from '../../../../../containers/detection_engine/rules';
import { TableData } from '../../types';
export const mockRule = (id: string): Rule => ({
created_at: '2020-01-10T21:11:45.839Z',
updated_at: '2020-01-10T21:11:45.839Z',
created_by: 'elastic',
description: '24/7',
enabled: true,
false_positives: [],
filters: [],
from: 'now-300s',
id,
immutable: false,
index: ['auditbeat-*'],
interval: '5m',
rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea',
language: 'kuery',
output_index: '.siem-signals-default',
max_signals: 100,
risk_score: 21,
name: 'Home Grown!',
query: '',
references: [],
saved_id: "Garrett's IP",
timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2',
timeline_title: 'Untitled timeline',
meta: { from: '0m' },
severity: 'low',
updated_by: 'elastic',
tags: [],
to: 'now',
type: 'saved_query',
threats: [],
version: 1,
});
export const mockRuleError = (id: string): RuleError => ({
rule_id: id,
error: { status_code: 404, message: `id: "${id}" not found` },
});
export const mockRules: Rule[] = [
mockRule('abe6c564-050d-45a5-aaf0-386c37dd1f61'),
mockRule('63f06f34-c181-4b2d-af35-f2ace572a1ee'),
];
export const mockTableData: TableData[] = [
{
activate: true,
id: 'abe6c564-050d-45a5-aaf0-386c37dd1f61',
immutable: false,
isLoading: false,
lastCompletedRun: undefined,
lastResponse: { type: '—' },
method: 'saved_query',
rule: {
href: '#/detection-engine/rules/id/abe6c564-050d-45a5-aaf0-386c37dd1f61',
name: 'Home Grown!',
status: 'Status Placeholder',
},
rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea',
severity: 'low',
sourceRule: {
created_at: '2020-01-10T21:11:45.839Z',
created_by: 'elastic',
description: '24/7',
enabled: true,
false_positives: [],
filters: [],
from: 'now-300s',
id: 'abe6c564-050d-45a5-aaf0-386c37dd1f61',
immutable: false,
index: ['auditbeat-*'],
interval: '5m',
language: 'kuery',
max_signals: 100,
meta: { from: '0m' },
name: 'Home Grown!',
output_index: '.siem-signals-default',
query: '',
references: [],
risk_score: 21,
rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea',
saved_id: "Garrett's IP",
severity: 'low',
tags: [],
threats: [],
timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2',
timeline_title: 'Untitled timeline',
to: 'now',
type: 'saved_query',
updated_at: '2020-01-10T21:11:45.839Z',
updated_by: 'elastic',
version: 1,
},
tags: [],
},
{
activate: true,
id: '63f06f34-c181-4b2d-af35-f2ace572a1ee',
immutable: false,
isLoading: false,
lastCompletedRun: undefined,
lastResponse: { type: '—' },
method: 'saved_query',
rule: {
href: '#/detection-engine/rules/id/63f06f34-c181-4b2d-af35-f2ace572a1ee',
name: 'Home Grown!',
status: 'Status Placeholder',
},
rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea',
severity: 'low',
sourceRule: {
created_at: '2020-01-10T21:11:45.839Z',
created_by: 'elastic',
description: '24/7',
enabled: true,
false_positives: [],
filters: [],
from: 'now-300s',
id: '63f06f34-c181-4b2d-af35-f2ace572a1ee',
immutable: false,
index: ['auditbeat-*'],
interval: '5m',
language: 'kuery',
max_signals: 100,
meta: { from: '0m' },
name: 'Home Grown!',
output_index: '.siem-signals-default',
query: '',
references: [],
risk_score: 21,
rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea',
saved_id: "Garrett's IP",
severity: 'low',
tags: [],
threats: [],
timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2',
timeline_title: 'Untitled timeline',
to: 'now',
type: 'saved_query',
updated_at: '2020-01-10T21:11:45.839Z',
updated_by: 'elastic',
version: 1,
},
tags: [],
},
];

View file

@ -5,7 +5,7 @@
*/
import * as H from 'history';
import React from 'react';
import React, { Dispatch } from 'react';
import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine';
import {
@ -16,40 +16,92 @@ import {
} from '../../../../containers/detection_engine/rules';
import { Action } from './reducer';
import { ActionToaster, displayErrorToast } from '../../../../components/toasters';
import * as i18n from '../translations';
import { bucketRulesResponse } from './helpers';
export const editRuleAction = (rule: Rule, history: H.History) => {
history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${rule.id}/edit`);
};
export const runRuleAction = () => {};
export const duplicateRuleAction = async (rule: Rule, dispatch: React.Dispatch<Action>) => {
dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: true });
const duplicatedRule = await duplicateRules({ rules: [rule] });
dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: false });
dispatch({ type: 'updateRules', rules: duplicatedRule, appendRuleId: rule.id });
export const duplicateRuleAction = async (
rule: Rule,
dispatch: React.Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>
) => {
try {
dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: true });
const duplicatedRule = await duplicateRules({ rules: [rule] });
dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: false });
dispatch({ type: 'updateRules', rules: duplicatedRule, appendRuleId: rule.id });
} catch (e) {
displayErrorToast(i18n.DUPLICATE_RULE_ERROR, [e.message], dispatchToaster);
}
};
export const exportRulesAction = async (rules: Rule[], dispatch: React.Dispatch<Action>) => {
dispatch({ type: 'setExportPayload', exportPayload: rules });
};
export const deleteRulesAction = async (ids: string[], dispatch: React.Dispatch<Action>) => {
dispatch({ type: 'updateLoading', ids, isLoading: true });
const deletedRules = await deleteRules({ ids });
dispatch({ type: 'deleteRules', rules: deletedRules });
export const deleteRulesAction = async (
ids: string[],
dispatch: React.Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>
) => {
try {
dispatch({ type: 'updateLoading', ids, isLoading: true });
const response = await deleteRules({ ids });
const { rules, errors } = bucketRulesResponse(response);
dispatch({ type: 'deleteRules', rules });
if (errors.length > 0) {
displayErrorToast(
i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ids.length),
errors.map(e => e.error.message),
dispatchToaster
);
}
} catch (e) {
displayErrorToast(
i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ids.length),
[e.message],
dispatchToaster
);
}
};
export const enableRulesAction = async (
ids: string[],
enabled: boolean,
dispatch: React.Dispatch<Action>
dispatch: React.Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>
) => {
const errorTitle = enabled
? i18n.BATCH_ACTION_ACTIVATE_SELECTED_ERROR(ids.length)
: i18n.BATCH_ACTION_DEACTIVATE_SELECTED_ERROR(ids.length);
try {
dispatch({ type: 'updateLoading', ids, isLoading: true });
const updatedRules = await enableRules({ ids, enabled });
dispatch({ type: 'updateRules', rules: updatedRules });
} catch {
// TODO Add error toast support to actions (and @throw jsdoc to api calls)
const response = await enableRules({ ids, enabled });
const { rules, errors } = bucketRulesResponse(response);
dispatch({ type: 'updateRules', rules });
if (errors.length > 0) {
displayErrorToast(
errorTitle,
errors.map(e => e.error.message),
dispatchToaster
);
}
} catch (e) {
displayErrorToast(errorTitle, [e.message], dispatchToaster);
dispatch({ type: 'updateLoading', ids, isLoading: false });
}
};

View file

@ -5,20 +5,23 @@
*/
import { EuiContextMenuItem } from '@elastic/eui';
import React from 'react';
import React, { Dispatch } from 'react';
import * as i18n from '../translations';
import { TableData } from '../types';
import { Action } from './reducer';
import { deleteRulesAction, enableRulesAction, exportRulesAction } from './actions';
import { ActionToaster } from '../../../../components/toasters';
export const getBatchItems = (
selectedState: TableData[],
dispatch: React.Dispatch<Action>,
dispatch: Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>,
closePopover: () => void
) => {
const containsEnabled = selectedState.some(v => v.activate);
const containsDisabled = selectedState.some(v => !v.activate);
const containsLoading = selectedState.some(v => v.isLoading);
const containsImmutable = selectedState.some(v => v.immutable);
return [
<EuiContextMenuItem
@ -28,7 +31,7 @@ export const getBatchItems = (
onClick={async () => {
closePopover();
const deactivatedIds = selectedState.filter(s => !s.activate).map(s => s.id);
await enableRulesAction(deactivatedIds, true, dispatch);
await enableRulesAction(deactivatedIds, true, dispatch, dispatchToaster);
}}
>
{i18n.BATCH_ACTION_ACTIVATE_SELECTED}
@ -40,7 +43,7 @@ export const getBatchItems = (
onClick={async () => {
closePopover();
const activatedIds = selectedState.filter(s => s.activate).map(s => s.id);
await enableRulesAction(activatedIds, false, dispatch);
await enableRulesAction(activatedIds, false, dispatch, dispatchToaster);
}}
>
{i18n.BATCH_ACTION_DEACTIVATE_SELECTED}
@ -72,12 +75,14 @@ export const getBatchItems = (
<EuiContextMenuItem
key={i18n.BATCH_ACTION_DELETE_SELECTED}
icon="trash"
disabled={containsLoading || selectedState.length === 0}
title={containsImmutable ? i18n.BATCH_ACTION_DELETE_SELECTED_IMMUTABLE : undefined}
disabled={containsImmutable || containsLoading || selectedState.length === 0}
onClick={async () => {
closePopover();
await deleteRulesAction(
selectedState.map(({ sourceRule: { id } }) => id),
dispatch
dispatch,
dispatchToaster
);
}}
>

View file

@ -15,7 +15,7 @@ import {
EuiTextColor,
} from '@elastic/eui';
import * as H from 'history';
import React from 'react';
import React, { Dispatch } from 'react';
import { getEmptyTagValue } from '../../../../components/empty_value';
import {
deleteRulesAction,
@ -31,8 +31,13 @@ import * as i18n from '../translations';
import { PreferenceFormattedDate } from '../../../../components/formatted_date';
import { RuleSwitch } from '../components/rule_switch';
import { SeverityBadge } from '../components/severity_badge';
import { ActionToaster } from '../../../../components/toasters';
const getActions = (dispatch: React.Dispatch<Action>, history: H.History) => [
const getActions = (
dispatch: React.Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>,
history: H.History
) => [
{
description: i18n.EDIT_RULE_SETTINGS,
type: 'icon',
@ -54,7 +59,8 @@ const getActions = (dispatch: React.Dispatch<Action>, history: H.History) => [
type: 'icon',
icon: 'copy',
name: i18n.DUPLICATE_RULE,
onClick: (rowItem: TableData) => duplicateRuleAction(rowItem.sourceRule, dispatch),
onClick: (rowItem: TableData) =>
duplicateRuleAction(rowItem.sourceRule, dispatch, dispatchToaster),
},
{
description: i18n.EXPORT_RULE,
@ -68,7 +74,8 @@ const getActions = (dispatch: React.Dispatch<Action>, history: H.History) => [
type: 'icon',
icon: 'trash',
name: i18n.DELETE_RULE,
onClick: (rowItem: TableData) => deleteRulesAction([rowItem.id], dispatch),
onClick: (rowItem: TableData) => deleteRulesAction([rowItem.id], dispatch, dispatchToaster),
enabled: (rowItem: TableData) => !rowItem.immutable,
},
];
@ -77,6 +84,7 @@ type RulesColumns = EuiBasicTableColumn<TableData> | EuiTableActionsColumnType<T
// Michael: Are we able to do custom, in-table-header filters, as shown in my wireframes?
export const getColumns = (
dispatch: React.Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>,
history: H.History,
hasNoPermissions: boolean
): RulesColumns[] => {
@ -169,7 +177,7 @@ export const getColumns = (
];
const actions: RulesColumns[] = [
{
actions: getActions(dispatch, history),
actions: getActions(dispatch, dispatchToaster, history),
width: '40px',
} as EuiTableActionsColumnType<TableData>,
];

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import { bucketRulesResponse, formatRules } from './helpers';
import { mockRule, mockRuleError, mockRules, mockTableData } from './__mocks__/mock';
import uuid from 'uuid';
import { Rule, RuleError } from '../../../../containers/detection_engine/rules';
describe('AllRulesTable Helpers', () => {
const mockRule1: Readonly<Rule> = mockRule(uuid.v4());
const mockRule2: Readonly<Rule> = mockRule(uuid.v4());
const mockRuleError1: Readonly<RuleError> = mockRuleError(uuid.v4());
const mockRuleError2: Readonly<RuleError> = mockRuleError(uuid.v4());
describe('formatRules', () => {
test('formats rules with no selection', () => {
const formattedRules = formatRules(mockRules);
expect(formattedRules).toEqual(mockTableData);
});
test('formats rules with selection', () => {
const mockTableDataWithSelected = [...mockTableData];
mockTableDataWithSelected[0].isLoading = true;
const formattedRules = formatRules(mockRules, [mockRules[0].id]);
expect(formattedRules).toEqual(mockTableDataWithSelected);
});
});
describe('bucketRulesResponse', () => {
test('buckets empty response', () => {
const bucketedResponse = bucketRulesResponse([]);
expect(bucketedResponse).toEqual({ rules: [], errors: [] });
});
test('buckets all error response', () => {
const bucketedResponse = bucketRulesResponse([mockRuleError1, mockRuleError2]);
expect(bucketedResponse).toEqual({ rules: [], errors: [mockRuleError1, mockRuleError2] });
});
test('buckets all success response', () => {
const bucketedResponse = bucketRulesResponse([mockRule1, mockRule2]);
expect(bucketedResponse).toEqual({ rules: [mockRule1, mockRule2], errors: [] });
});
test('buckets mixed success/error response', () => {
const bucketedResponse = bucketRulesResponse([
mockRule1,
mockRuleError1,
mockRule2,
mockRuleError2,
]);
expect(bucketedResponse).toEqual({
rules: [mockRule1, mockRule2],
errors: [mockRuleError1, mockRuleError2],
});
});
});
});

View file

@ -4,13 +4,24 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Rule } from '../../../../containers/detection_engine/rules';
import {
Rule,
RuleError,
RuleResponseBuckets,
} from '../../../../containers/detection_engine/rules';
import { TableData } from '../types';
import { getEmptyValue } from '../../../../components/empty_value';
/**
* Formats rules into the correct format for the AllRulesTable
*
* @param rules as returned from the Rules API
* @param selectedIds ids of the currently selected rules
*/
export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[] =>
rules.map(rule => ({
id: rule.id,
immutable: rule.immutable,
rule_id: rule.rule_id,
rule: {
href: `#/detection-engine/rules/id/${encodeURIComponent(rule.id)}`,
@ -28,3 +39,18 @@ export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[]
sourceRule: rule,
isLoading: selectedIds?.includes(rule.id) ?? false,
}));
/**
* Separates rules/errors from bulk rules API response (create/update/delete)
*
* @param response Array<Rule | RuleError> from bulk rules API
*/
export const bucketRulesResponse = (response: Array<Rule | RuleError>) =>
response.reduce<RuleResponseBuckets>(
(acc, cv): RuleResponseBuckets => {
return 'error' in cv
? { rules: [...acc.rules], errors: [...acc.errors, cv] }
: { rules: [...acc.rules, cv], errors: [...acc.errors] };
},
{ rules: [], errors: [] }
);

View file

@ -84,11 +84,35 @@ export const AllRules = React.memo<{
const getBatchItemsPopoverContent = useCallback(
(closePopover: () => void) => (
<EuiContextMenuPanel items={getBatchItems(selectedItems, dispatch, closePopover)} />
<EuiContextMenuPanel
items={getBatchItems(selectedItems, dispatch, dispatchToaster, closePopover)}
/>
),
[selectedItems, dispatch]
[selectedItems, dispatch, dispatchToaster]
);
const tableOnChangeCallback = useCallback(
({ page, sort }: EuiBasicTableOnChange) => {
dispatch({
type: 'updatePagination',
pagination: { ...pagination, page: page.index + 1, perPage: page.size },
});
dispatch({
type: 'updateFilterOptions',
filterOptions: {
...filterOptions,
sortField: 'enabled', // Only enabled is supported for sorting currently
sortOrder: sort?.direction ?? 'desc',
},
});
},
[dispatch, filterOptions, pagination]
);
const columns = useMemo(() => {
return getColumns(dispatch, dispatchToaster, history, hasNoPermissions);
}, [dispatch, dispatchToaster, history]);
useEffect(() => {
dispatch({ type: 'loading', isLoading: isLoadingRules });
@ -195,24 +219,11 @@ export const AllRules = React.memo<{
</UtilityBar>
<EuiBasicTable
columns={getColumns(dispatch, history, hasNoPermissions)}
columns={columns}
isSelectable={!hasNoPermissions ?? false}
itemId="rule_id"
items={tableData}
onChange={({ page, sort }: EuiBasicTableOnChange) => {
dispatch({
type: 'updatePagination',
pagination: { ...pagination, page: page.index + 1, perPage: page.size },
});
dispatch({
type: 'updateFilterOptions',
filterOptions: {
...filterOptions,
sortField: 'enabled', // Only enabled is supported for sorting currently
sortOrder: sort!.direction,
},
});
}}
onChange={tableOnChangeCallback}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.perPage,

View file

@ -18,6 +18,7 @@ import React, { useCallback, useState, useEffect } from 'react';
import { enableRules } from '../../../../../containers/detection_engine/rules';
import { enableRulesAction } from '../../all/actions';
import { Action } from '../../all/reducer';
import { useStateToaster } from '../../../../../components/toasters';
const StaticSwitch = styled(EuiSwitch)`
.euiSwitch__thumb,
@ -50,12 +51,13 @@ export const RuleSwitchComponent = ({
}: RuleSwitchProps) => {
const [myIsLoading, setMyIsLoading] = useState(false);
const [myEnabled, setMyEnabled] = useState(enabled ?? false);
const [, dispatchToaster] = useStateToaster();
const onRuleStateChange = useCallback(
async (event: EuiSwitchEvent) => {
setMyIsLoading(true);
if (dispatch != null) {
await enableRulesAction([id], event.target.checked!, dispatch);
await enableRulesAction([id], event.target.checked!, dispatch, dispatchToaster);
} else {
try {
const updatedRules = await enableRules({

View file

@ -50,6 +50,15 @@ export const BATCH_ACTION_ACTIVATE_SELECTED = i18n.translate(
}
);
export const BATCH_ACTION_ACTIVATE_SELECTED_ERROR = (totalRules: number) =>
i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.batchActions.activateSelectedErrorTitle',
{
values: { totalRules },
defaultMessage: 'Error activating {totalRules, plural, =1 {rule} other {rules}}…',
}
);
export const BATCH_ACTION_DEACTIVATE_SELECTED = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.batchActions.deactivateSelectedTitle',
{
@ -57,6 +66,15 @@ export const BATCH_ACTION_DEACTIVATE_SELECTED = i18n.translate(
}
);
export const BATCH_ACTION_DEACTIVATE_SELECTED_ERROR = (totalRules: number) =>
i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.batchActions.deactivateSelectedErrorTitle',
{
values: { totalRules },
defaultMessage: 'Error deactivating {totalRules, plural, =1 {rule} other {rules}}…',
}
);
export const BATCH_ACTION_EXPORT_SELECTED = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.batchActions.exportSelectedTitle',
{
@ -78,6 +96,22 @@ export const BATCH_ACTION_DELETE_SELECTED = i18n.translate(
}
);
export const BATCH_ACTION_DELETE_SELECTED_IMMUTABLE = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle',
{
defaultMessage: 'Selection contains immutable rules which cannot be deleted',
}
);
export const BATCH_ACTION_DELETE_SELECTED_ERROR = (totalRules: number) =>
i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle',
{
values: { totalRules },
defaultMessage: 'Error deleting {totalRules, plural, =1 {rule} other {rules}}…',
}
);
export const EXPORT_FILENAME = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.exportFilenameTitle',
{
@ -143,6 +177,13 @@ export const DUPLICATE_RULE = i18n.translate(
}
);
export const DUPLICATE_RULE_ERROR = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription',
{
defaultMessage: 'Error duplicating rule…',
}
);
export const EXPORT_RULE = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.actions.exportRuleDescription',
{

View file

@ -25,6 +25,7 @@ export interface EuiBasicTableOnChange {
export interface TableData {
id: string;
immutable: boolean;
rule_id: string;
rule: {
href: string;