[Security Solution][Detections] Adds UI for bulk applying timeline template (#128691)

**Addresses:** https://github.com/elastic/kibana/issues/93083, https://github.com/elastic/security-team/issues/2078 (internal)

## Summary

This PR adds a UI for applying a timeline template to multiple rules in bulk.

- A new bulk actions menu item to the Rule Management table.
- A new form flyout for applying a timeline template.
- Some glue code to connect them.

There are a few issues that I'd like to address in a follow-up PR after the FF:

1. Resetting already applied templates to `None` doesn't work because of the way the `patchRules` function works. This is a known bug in this implementation. We will need to replace `patchRules` with something else for bulk editing actions.
2. I need to add some test coverage.

Other notes:

- I changed some copies to hopefully make it a little bit clearer. Let me know if you want to rephrase.

## Screenshots

![](https://puu.sh/IRpnL/9abe2ce1b5.png)

The template selector doesn't look good on a smaller screen:

![](https://puu.sh/IRpyP/eb7bebabc7.png)
This commit is contained in:
Georgii Gorbachev 2022-03-29 13:42:36 +02:00 committed by GitHub
parent 53dde5f44e
commit 17086ad6ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 187 additions and 11 deletions

View file

@ -14,6 +14,7 @@ import {
import { IndexPatternsForm } from './forms/index_patterns_form';
import { TagsForm } from './forms/tags_form';
import { TimelineTemplateForm } from './forms/timeline_template_form';
interface BulkEditFlyoutProps {
onClose: () => void;
@ -35,6 +36,9 @@ const BulkEditFlyoutComponent = ({ editAction, tags, ...props }: BulkEditFlyoutP
case BulkActionEditType.set_tags:
return <TagsForm {...props} editAction={editAction} tags={tags} />;
case BulkActionEditType.set_timeline:
return <TimelineTemplateForm {...props} />;
default:
return null;
}

View file

@ -24,19 +24,21 @@ import { Form, FormHook } from '../../../../../../../shared_imports';
import * as i18n from '../../../translations';
interface BulkEditFormWrapperProps {
form: FormHook;
title: string;
banner?: React.ReactNode;
children: React.ReactNode;
onClose: () => void;
onSubmit: () => void;
title: string;
form: FormHook;
children: React.ReactNode;
}
const BulkEditFormWrapperComponent: FC<BulkEditFormWrapperProps> = ({
form,
title,
banner,
children,
onClose,
onSubmit,
children,
title,
}) => {
const simpleFlyoutTitleId = useGeneratedHtmlId({
prefix: 'RulesBulkEditForm',
@ -50,7 +52,7 @@ const BulkEditFormWrapperComponent: FC<BulkEditFormWrapperProps> = ({
<h2 id={simpleFlyoutTitleId}>{title}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFlyoutBody banner={banner}>
<Form form={form}>{children}</Form>
</EuiFlyoutBody>
<EuiFlyoutFooter>

View file

@ -0,0 +1,102 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import { EuiCallOut } from '@elastic/eui';
import { useForm, UseField, FormSchema } from '../../../../../../../shared_imports';
import { PickTimeline } from '../../../../../../components/rules/pick_timeline';
import {
BulkActionEditType,
BulkActionEditPayload,
} from '../../../../../../../../common/detection_engine/schemas/common/schemas';
import { BulkEditFormWrapper } from './bulk_edit_form_wrapper';
import { bulkApplyTimelineTemplate as i18n } from '../translations';
export interface TimelineTemplateFormData {
timeline: {
id: string | null;
title: string;
};
}
const formSchema: FormSchema<TimelineTemplateFormData> = {
timeline: {
label: i18n.TEMPLATE_SELECTOR_LABEL,
helpText: i18n.TEMPLATE_SELECTOR_HELP_TEXT,
},
};
const defaultFormData: TimelineTemplateFormData = {
timeline: {
id: null,
title: i18n.TEMPLATE_SELECTOR_DEFAULT_VALUE,
},
};
interface TimelineTemplateFormProps {
rulesCount: number;
onClose: () => void;
onConfirm: (bulkActionEditPayload: BulkActionEditPayload) => void;
}
const TimelineTemplateFormComponent = (props: TimelineTemplateFormProps) => {
const { rulesCount, onClose, onConfirm } = props;
const { form } = useForm({
schema: formSchema,
defaultValue: defaultFormData,
});
const handleSubmit = useCallback(async () => {
const { data, isValid } = await form.submit();
if (!isValid) {
return;
}
const timelineId = data.timeline.id || '';
const timelineTitle = timelineId ? data.timeline.title : '';
onConfirm({
type: BulkActionEditType.set_timeline,
value: {
timeline_id: timelineId,
timeline_title: timelineTitle,
},
});
}, [form, onConfirm]);
const warningCallout = (
<EuiCallOut color="warning" data-test-subj="bulkEditRulesTimelineTemplateWarning">
{i18n.warningCalloutMessage(rulesCount)}
</EuiCallOut>
);
return (
<BulkEditFormWrapper
form={form}
title={i18n.FORM_TITLE}
banner={warningCallout}
onClose={onClose}
onSubmit={handleSubmit}
>
{/* Timeline template selector */}
<UseField
path="timeline"
component={PickTimeline}
componentProps={{
idAria: 'bulkEditRulesTimelineTemplateSelector',
dataTestSubj: 'bulkEditRulesTimelineTemplateSelector',
}}
/>
</BulkEditFormWrapper>
);
};
export const TimelineTemplateForm = React.memo(TimelineTemplateFormComponent);
TimelineTemplateForm.displayName = 'TimelineTemplateForm';

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
export const bulkApplyTimelineTemplate = {
FORM_TITLE: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.applyTimelineTemplate.formTitle',
{
defaultMessage: 'Apply timeline template',
}
),
TEMPLATE_SELECTOR_LABEL: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.applyTimelineTemplate.templateSelectorLabel',
{
defaultMessage: 'Apply timeline template to selected rules',
}
),
TEMPLATE_SELECTOR_HELP_TEXT: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.applyTimelineTemplate.templateSelectorHelpText',
{
defaultMessage:
'Select which timeline to apply to selected rules when investigating generated alerts.',
}
),
TEMPLATE_SELECTOR_DEFAULT_VALUE: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.applyTimelineTemplate.templateSelectorDefaultValue',
{
defaultMessage: 'None',
}
),
warningCalloutMessage: (rulesCount: number): JSX.Element => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.applyTimelineTemplate.warningCalloutMessage"
defaultMessage="You are about to apply changes to {rulesCount, plural, one {# selected rule} other {# selected rules}}.
If you already applied any templates to these rules, they will be overwritten or (if you select 'None') reset to none."
values={{ rulesCount }}
/>
),
};

View file

@ -307,6 +307,16 @@ export const useBulkActions = ({
disabled: isEditDisabled,
panel: 1,
},
{
key: i18n.BULK_ACTION_APPLY_TIMELINE_TEMPLATE,
name: i18n.BULK_ACTION_APPLY_TIMELINE_TEMPLATE,
'data-test-subj': 'applyTimelineTemplateBulk',
disabled: isEditDisabled,
onClick: handleBulkEdit(BulkActionEditType.set_timeline),
toolTipContent: missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined,
toolTipPosition: 'right',
icon: undefined,
},
{
key: i18n.BULK_ACTION_EXPORT,
name: i18n.BULK_ACTION_EXPORT,

View file

@ -193,6 +193,13 @@ export const BULK_ACTION_DELETE_TAGS = i18n.translate(
}
);
export const BULK_ACTION_APPLY_TIMELINE_TEMPLATE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.applyTimelineTemplateTitle',
{
defaultMessage: 'Apply timeline template',
}
);
export const BULK_ACTION_MENU_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.contextMenuTitle',
{

View file

@ -78,11 +78,12 @@ export const applyBulkActionEditToRule = (
// timeline actions
case BulkActionEditType.set_timeline:
rule.params = {
...rule.params,
timelineId: action.value.timeline_id,
timelineTitle: action.value.timeline_title,
};
const timelineId = action.value.timeline_id.trim() || undefined;
const timelineTitle = timelineId ? action.value.timeline_title : undefined;
rule.params.timelineId = timelineId;
rule.params.timelineTitle = timelineTitle;
break;
}
return rule;