From dd05b8b9110344e20acd68e44f8b06ce18d0e581 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Mon, 13 Jan 2020 17:19:33 -0700 Subject: [PATCH] [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)~ --- .../components/embeddables/embedded_map.tsx | 6 +- .../embeddables/embedded_map_helpers.test.tsx | 27 +-- .../embeddables/embedded_map_helpers.tsx | 26 --- .../public/components/toasters/index.test.tsx | 33 +++- .../siem/public/components/toasters/index.tsx | 26 +++ .../containers/detection_engine/rules/api.ts | 39 ++--- .../detection_engine/rules/types.ts | 12 ++ .../rules/all/__mocks__/mock.ts | 154 ++++++++++++++++++ .../detection_engine/rules/all/actions.tsx | 82 ++++++++-- .../rules/all/batch_actions.tsx | 17 +- .../detection_engine/rules/all/columns.tsx | 18 +- .../rules/all/helpers.test.tsx | 61 +++++++ .../detection_engine/rules/all/helpers.ts | 28 +++- .../detection_engine/rules/all/index.tsx | 45 +++-- .../rules/components/rule_switch/index.tsx | 4 +- .../detection_engine/rules/translations.ts | 41 +++++ .../pages/detection_engine/rules/types.ts | 1 + 17 files changed, 500 insertions(+), 120 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx index b39d43cc01b42..771e220a2a0b3 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx @@ -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); } } diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx index 4e5fcee439827..a83e8377deeb6 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx @@ -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(); diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx index b9a9df9824eee..838e74cc5624c 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx @@ -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 -) => { - const toast: AppToast = { - id: uuid.v4(), - title: errorTitle, - color: 'danger', - iconType: 'alert', - errors: [errorMessage], - }; - dispatchToaster({ - type: 'addToaster', - toast, - }); -}; - /** * Creates MapEmbeddable with provided initial configuration * diff --git a/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx index 5ef5a5ab31d4b..9338eb9f0fabd 100644 --- a/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx @@ -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); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx b/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx index 27d59d429913c..7098e618aeb55 100644 --- a/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx @@ -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 +) => { + const toast: AppToast = { + id: uuid.v4(), + title: errorTitle, + color: 'danger', + iconType: 'alert', + errors: errorMessages, + }; + dispatchToaster({ + type: 'addToaster', + toast, + }); +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index b69a8de29e047..8f8b66ae35a3b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -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 => { - 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>(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 => { - // 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> => { + 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>(response => response.json()) - ); + await throwIfNotOk(response); + return response.json(); }; /** diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index a329d96d444aa..147b04567f6c7 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -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; export type Rules = t.TypeOf; +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; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts new file mode 100644 index 0000000000000..3762cb0a4ba07 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -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: [], + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx index 469745262d944..24e3cfde1e448 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx @@ -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) => { - 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, + dispatchToaster: Dispatch +) => { + 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) => { dispatch({ type: 'setExportPayload', exportPayload: rules }); }; -export const deleteRulesAction = async (ids: string[], dispatch: React.Dispatch) => { - 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, + dispatchToaster: Dispatch +) => { + 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 + dispatch: React.Dispatch, + dispatchToaster: Dispatch ) => { + 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 }); } }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx index 72d38454ad9bc..3356ef101677d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx @@ -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, + dispatch: Dispatch, + dispatchToaster: Dispatch, 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 [ { 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 = ( { closePopover(); await deleteRulesAction( selectedState.map(({ sourceRule: { id } }) => id), - dispatch + dispatch, + dispatchToaster ); }} > diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index ce2c2c32ab86a..2c0804bb5349f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -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, history: H.History) => [ +const getActions = ( + dispatch: React.Dispatch, + dispatchToaster: Dispatch, + history: H.History +) => [ { description: i18n.EDIT_RULE_SETTINGS, type: 'icon', @@ -54,7 +59,8 @@ const getActions = (dispatch: React.Dispatch, 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, 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 | EuiTableActionsColumnType, + dispatchToaster: Dispatch, 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, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx new file mode 100644 index 0000000000000..e925161444e42 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx @@ -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 = mockRule(uuid.v4()); + const mockRule2: Readonly = mockRule(uuid.v4()); + const mockRuleError1: Readonly = mockRuleError(uuid.v4()); + const mockRuleError2: Readonly = 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], + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts index f5d3955314242..b18938920082d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts @@ -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 from bulk rules API + */ +export const bucketRulesResponse = (response: Array) => + response.reduce( + (acc, cv): RuleResponseBuckets => { + return 'error' in cv + ? { rules: [...acc.rules], errors: [...acc.errors, cv] } + : { rules: [...acc.rules, cv], errors: [...acc.errors] }; + }, + { rules: [], errors: [] } + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index d928cc0949851..202be75f09e69 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -84,11 +84,35 @@ export const AllRules = React.memo<{ const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => ( - + ), - [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<{ { - 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, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx index 09be3df7d6929..9cb0323ed8987 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx @@ -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({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts index 8d4407b9f73e8..d55e08e9ecd73 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts @@ -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', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index 13b328e9061c9..3da294fc9b845 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -25,6 +25,7 @@ export interface EuiBasicTableOnChange { export interface TableData { id: string; + immutable: boolean; rule_id: string; rule: { href: string;