mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
## 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:  (Ignore failed requests from test env -- request count is important here) ##### Activate/Deactivate After:  ##### Delete Before:  (Ignore failed requests from test env -- request count is important here) ##### Delete After:  ### 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:
parent
a90e9fd204
commit
dd05b8b911
17 changed files with 500 additions and 120 deletions
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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: [],
|
||||
},
|
||||
];
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -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>,
|
||||
];
|
||||
|
|
|
@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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: [] }
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -25,6 +25,7 @@ export interface EuiBasicTableOnChange {
|
|||
|
||||
export interface TableData {
|
||||
id: string;
|
||||
immutable: boolean;
|
||||
rule_id: string;
|
||||
rule: {
|
||||
href: string;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue