[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:
parent
6cac02e6c1
commit
b4e42d52c0
|
@ -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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
Loading…
Reference in a new issue