[SIEM][Detection Engine] Adds actions to Rule Details (#54828)

## Summary

This PR adds the following actions to the `Rule Details` page via the `RuleActionsOverflow` component (which is permission-aware):
* Duplicate
* Export
* Delete 

Additional fixes include:
* Fixes duplication action (recent regression as part of status update additions)
* i18n of `Duplicate` postfix when duplicating rules
* Adds success toast when duplication is a success
* Enabled `Edit Index Patterns` batch action
* Removes unused `Run Rule Manually` action

Rule Details Actions:
![image](https://user-images.githubusercontent.com/2946766/72385375-9c3a6880-36dc-11ea-8249-4ae92eb72dd1.png)

Edit Index Patterns Batch Action:
![image](https://user-images.githubusercontent.com/2946766/72385468-c5f38f80-36dc-11ea-93c8-b70e4982f01a.png)



### 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-14 17:05:49 -07:00 committed by GitHub
parent 6cac02e6c1
commit b4e42d52c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 273 additions and 26 deletions

View file

@ -191,7 +191,7 @@ export const duplicateRules = async ({ rules }: DuplicateRulesProps): Promise<Ru
},
body: JSON.stringify({
...rule,
name: `${rule.name} [Duplicate]`,
name: `${rule.name} [${i18n.DUPLICATE}]`,
created_at: undefined,
created_by: undefined,
id: undefined,
@ -200,6 +200,10 @@ export const duplicateRules = async ({ rules }: DuplicateRulesProps): Promise<Ru
updated_by: undefined,
enabled: rule.enabled,
immutable: false,
last_success_at: undefined,
last_success_message: undefined,
status: undefined,
status_date: undefined,
}),
})
);

View file

@ -16,7 +16,11 @@ import {
} from '../../../../containers/detection_engine/rules';
import { Action } from './reducer';
import { ActionToaster, displayErrorToast } from '../../../../components/toasters';
import {
ActionToaster,
displayErrorToast,
displaySuccessToast,
} from '../../../../components/toasters';
import * as i18n from '../translations';
import { bucketRulesResponse } from './helpers';
@ -25,8 +29,6 @@ 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>,
@ -37,6 +39,7 @@ export const duplicateRuleAction = async (
const duplicatedRule = await duplicateRules({ rules: [rule] });
dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: false });
dispatch({ type: 'updateRules', rules: duplicatedRule, appendRuleId: rule.id });
displaySuccessToast(i18n.SUCCESSFULLY_DUPLICATED_RULES(duplicatedRule.length), dispatchToaster);
} catch (e) {
displayErrorToast(i18n.DUPLICATE_RULE_ERROR, [e.message], dispatchToaster);
}
@ -49,7 +52,8 @@ export const exportRulesAction = async (rules: Rule[], dispatch: React.Dispatch<
export const deleteRulesAction = async (
ids: string[],
dispatch: React.Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>
dispatchToaster: Dispatch<ActionToaster>,
onRuleDeleted?: () => void
) => {
try {
dispatch({ type: 'updateLoading', ids, isLoading: true });
@ -65,6 +69,9 @@ export const deleteRulesAction = async (
errors.map(e => e.error.message),
dispatchToaster
);
} else {
// FP: See https://github.com/typescript-eslint/typescript-eslint/issues/1138#issuecomment-566929566
onRuleDeleted?.(); // eslint-disable-line no-unused-expressions
}
} catch (e) {
displayErrorToast(

View file

@ -6,22 +6,26 @@
import { EuiContextMenuItem } from '@elastic/eui';
import React, { Dispatch } from 'react';
import * as H from 'history';
import * as i18n from '../translations';
import { TableData } from '../types';
import { Action } from './reducer';
import { deleteRulesAction, enableRulesAction, exportRulesAction } from './actions';
import { ActionToaster } from '../../../../components/toasters';
import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine';
export const getBatchItems = (
selectedState: TableData[],
dispatch: Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>,
history: H.History,
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);
const containsMultipleRules = Array.from(new Set(selectedState.map(v => v.rule_id))).length > 1;
return [
<EuiContextMenuItem
@ -65,9 +69,12 @@ export const getBatchItems = (
<EuiContextMenuItem
key={i18n.BATCH_ACTION_EDIT_INDEX_PATTERNS}
icon="indexEdit"
disabled={true}
disabled={
containsImmutable || containsLoading || containsMultipleRules || selectedState.length === 0
}
onClick={async () => {
closePopover();
history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${selectedState[0].id}/edit`);
}}
>
{i18n.BATCH_ACTION_EDIT_INDEX_PATTERNS}

View file

@ -22,7 +22,6 @@ import {
duplicateRuleAction,
editRuleAction,
exportRulesAction,
runRuleAction,
} from './actions';
import { Action } from './reducer';
@ -45,13 +44,6 @@ const getActions = (
onClick: (rowItem: TableData) => editRuleAction(rowItem.sourceRule, history),
enabled: (rowItem: TableData) => !rowItem.sourceRule.immutable,
},
{
description: i18n.RUN_RULE_MANUALLY,
icon: 'play',
name: i18n.RUN_RULE_MANUALLY,
onClick: runRuleAction,
enabled: () => false,
},
{
description: i18n.DUPLICATE_RULE,
icon: 'copy',

View file

@ -85,10 +85,10 @@ export const AllRules = React.memo<{
const getBatchItemsPopoverContent = useCallback(
(closePopover: () => void) => (
<EuiContextMenuPanel
items={getBatchItems(selectedItems, dispatch, dispatchToaster, closePopover)}
items={getBatchItems(selectedItems, dispatch, dispatchToaster, history, closePopover)}
/>
),
[selectedItems, dispatch, dispatchToaster]
[selectedItems, dispatch, dispatchToaster, history]
);
const tableOnChangeCallback = useCallback(

View file

@ -0,0 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RuleActionsOverflow renders correctly against snapshot 1`] = `
<Fragment>
<EuiPopover
anchorPosition="leftCenter"
button={
<EuiToolTip
content="All actions"
delay="regular"
position="top"
>
<EuiButtonIcon
aria-label="All actions"
iconType="boxesHorizontal"
isDisabled={false}
onClick={[Function]}
/>
</EuiToolTip>
}
closePopover={[Function]}
display="inlineBlock"
hasArrow={true}
id="ruleActionsOverflow"
isOpen={false}
ownFocus={true}
panelPaddingSize="none"
>
<EuiContextMenuPanel
hasFocus={true}
items={
Array [
<EuiContextMenuItem
disabled={false}
icon="exportAction"
onClick={[Function]}
>
Duplicate rule…
</EuiContextMenuItem>,
<EuiContextMenuItem
disabled={false}
icon="indexEdit"
onClick={[Function]}
>
Export rule
</EuiContextMenuItem>,
<EuiContextMenuItem
disabled={false}
icon="trash"
onClick={[Function]}
>
Delete rule…
</EuiContextMenuItem>,
]
}
/>
</EuiPopover>
<RuleDownloader
filename="rules_export.ndjson"
onExportComplete={[Function]}
/>
</Fragment>
`;

View file

@ -0,0 +1,26 @@
/*
* 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 { shallow } from 'enzyme';
import React from 'react';
import { RuleActionsOverflow } from './index';
import { mockRule } from '../../all/__mocks__/mock';
jest.mock('react-router-dom', () => ({
useHistory: () => ({
push: jest.fn(),
}),
}));
describe('RuleActionsOverflow', () => {
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<RuleActionsOverflow rule={mockRule('id')} userHasNoPermissions={false} />
);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,127 @@
/*
* 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 {
EuiButtonIcon,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiPopover,
EuiToolTip,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { noop } from 'lodash/fp';
import { useHistory } from 'react-router-dom';
import { Rule } from '../../../../../containers/detection_engine/rules';
import * as i18n from './translations';
import * as i18nActions from '../../../rules/translations';
import { deleteRulesAction, duplicateRuleAction } from '../../all/actions';
import { displaySuccessToast, useStateToaster } from '../../../../../components/toasters';
import { RuleDownloader } from '../rule_downloader';
import { DETECTION_ENGINE_PAGE_NAME } from '../../../../../components/link_to/redirect_to_detection_engine';
interface RuleActionsOverflowComponentProps {
rule: Rule | null;
userHasNoPermissions: boolean;
}
/**
* Overflow Actions for a Rule
*/
const RuleActionsOverflowComponent = ({
rule,
userHasNoPermissions,
}: RuleActionsOverflowComponentProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [rulesToExport, setRulesToExport] = useState<Rule[] | undefined>(undefined);
const history = useHistory();
const [, dispatchToaster] = useStateToaster();
const onRuleDeletedCallback = useCallback(() => {
history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules`);
}, [history]);
const actions = useMemo(
() =>
rule != null
? [
<EuiContextMenuItem
key={i18nActions.DUPLICATE_RULE}
icon="exportAction"
disabled={userHasNoPermissions}
onClick={async () => {
setIsPopoverOpen(false);
await duplicateRuleAction(rule, noop, dispatchToaster);
}}
>
{i18nActions.DUPLICATE_RULE}
</EuiContextMenuItem>,
<EuiContextMenuItem
key={i18nActions.EXPORT_RULE}
icon="indexEdit"
disabled={userHasNoPermissions || rule.immutable}
onClick={async () => {
setIsPopoverOpen(false);
setRulesToExport([rule]);
}}
>
{i18nActions.EXPORT_RULE}
</EuiContextMenuItem>,
<EuiContextMenuItem
key={i18nActions.DELETE_RULE}
icon="trash"
disabled={userHasNoPermissions || rule.immutable}
onClick={async () => {
setIsPopoverOpen(false);
await deleteRulesAction([rule.id], noop, dispatchToaster, onRuleDeletedCallback);
}}
>
{i18nActions.DELETE_RULE}
</EuiContextMenuItem>,
]
: [],
[rule, userHasNoPermissions]
);
return (
<>
<EuiPopover
anchorPosition="leftCenter"
button={
<EuiToolTip position="top" content={i18n.ALL_ACTIONS}>
<EuiButtonIcon
iconType="boxesHorizontal"
aria-label={i18n.ALL_ACTIONS}
isDisabled={userHasNoPermissions}
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
/>
</EuiToolTip>
}
closePopover={() => setIsPopoverOpen(false)}
id="ruleActionsOverflow"
isOpen={isPopoverOpen}
ownFocus={true}
panelPaddingSize="none"
>
<EuiContextMenuPanel items={actions} />
</EuiPopover>
<RuleDownloader
filename={`${i18nActions.EXPORT_FILENAME}.ndjson`}
rules={rulesToExport}
onExportComplete={exportCount => {
displaySuccessToast(
i18nActions.SUCCESSFULLY_EXPORTED_RULES(exportCount),
dispatchToaster
);
}}
/>
</>
);
};
export const RuleActionsOverflow = React.memo(RuleActionsOverflowComponent);
RuleActionsOverflow.displayName = 'RuleActionsOverflow';

View file

@ -0,0 +1,14 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ALL_ACTIONS = i18n.translate(
'xpack.siem.detectionEngine.rules.components.ruleActionsOverflow.allActionsTitle',
{
defaultMessage: 'All actions',
}
);

View file

@ -63,6 +63,7 @@ import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from
import { getEmptyTagValue } from '../../../../components/empty_value';
import { RuleStatusFailedCallOut } from './status_failed_callout';
import { FailureHistory } from './failure_history';
import { RuleActionsOverflow } from '../components/rule_actions_overflow';
interface ReduxProps {
filters: esFilters.Filter[];
@ -302,6 +303,12 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
{ruleI18n.EDIT_RULE_SETTINGS}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RuleActionsOverflow
rule={rule}
userHasNoPermissions={userHasNoPermissions}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -21,13 +21,6 @@ export const ADD_NEW_RULE = i18n.translate('xpack.siem.detectionEngine.rules.add
defaultMessage: 'Add new rule',
});
export const ACTIVITY_MONITOR = i18n.translate(
'xpack.siem.detectionEngine.rules.activityMonitorTitle',
{
defaultMessage: 'Activity monitor',
}
);
export const PAGE_TITLE = i18n.translate('xpack.siem.detectionEngine.rules.pageTitle', {
defaultMessage: 'Rules',
});
@ -163,10 +156,10 @@ export const EDIT_RULE_SETTINGS = i18n.translate(
}
);
export const RUN_RULE_MANUALLY = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.actions.runRuleManuallyDescription',
export const DUPLICATE = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.actions.duplicateTitle',
{
defaultMessage: 'Run rule manually…',
defaultMessage: 'Duplicate',
}
);
@ -177,6 +170,13 @@ export const DUPLICATE_RULE = i18n.translate(
}
);
export const SUCCESSFULLY_DUPLICATED_RULES = (totalRules: number) =>
i18n.translate('xpack.siem.detectionEngine.rules.allRules.successfullyDuplicatedRulesTitle', {
values: { totalRules },
defaultMessage:
'Successfully duplicated {totalRules, plural, =1 {{totalRules} rule} other {{totalRules} rules}}',
});
export const DUPLICATE_RULE_ERROR = i18n.translate(
'xpack.siem.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription',
{