[Security Solution][Detections] Add Bulk Scheduling for rules (#140166)

Addresses [#2127](https://github.com/elastic/security-team/issues/2172) (internal)

## Summary

Adds feature to bulk edit schedule of rules (interval -runs every- and lookback time)


https://user-images.githubusercontent.com/5354282/188846852-8bcb128a-db02-4a81-9fc8-3029a97965c2.mov


### Checklist

- [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/main/packages/kbn-i18n/README.md)
- [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials
- [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
This commit is contained in:
Juan Pablo Djeredjian 2022-09-19 22:36:44 +02:00 committed by GitHub
parent 167587f86c
commit 672bdd25b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 763 additions and 15 deletions

View file

@ -30,5 +30,6 @@ export * from './src/operator';
export * from './src/positive_integer_greater_than_zero';
export * from './src/positive_integer';
export * from './src/string_to_positive_number';
export * from './src/time_duration';
export * from './src/uuid';
export * from './src/version';

View file

@ -0,0 +1,105 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { TimeDuration } from '.';
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
describe('time_unit', () => {
test('it should validate a correctly formed TimeDuration with time unit of seconds', () => {
const payload = '1s';
const decoded = TimeDuration.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate a correctly formed TimeDuration with time unit of minutes', () => {
const payload = '100m';
const decoded = TimeDuration.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate a correctly formed TimeDuration with time unit of hours', () => {
const payload = '10000000h';
const decoded = TimeDuration.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should NOT validate a negative TimeDuration', () => {
const payload = '-10s';
const decoded = TimeDuration.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "-10s" supplied to "TimeDuration"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate a TimeDuration with some other time unit', () => {
const payload = '10000000w';
const decoded = TimeDuration.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "10000000w" supplied to "TimeDuration"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate a TimeDuration with a time interval with incorrect format', () => {
const payload = '100ff0000w';
const decoded = TimeDuration.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "100ff0000w" supplied to "TimeDuration"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate an empty string', () => {
const payload = '';
const decoded = TimeDuration.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "TimeDuration"']);
expect(message.schema).toEqual({});
});
test('it should NOT validate an number', () => {
const payload = 100;
const decoded = TimeDuration.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "100" supplied to "TimeDuration"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate an TimeDuration with a valid time unit but unsafe integer', () => {
const payload = `${Math.pow(2, 53)}h`;
const decoded = TimeDuration.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
`Invalid value "${Math.pow(2, 53)}h" supplied to "TimeDuration"`,
]);
expect(message.schema).toEqual({});
});
});

View file

@ -0,0 +1,45 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
/**
* Types the TimeDuration as:
* - A string that is not empty, and composed of a positive integer greater than 0 followed by a unit of time
* - in the format {safe_integer}{timeUnit}, e.g. "30s", "1m", "2h"
*/
export const TimeDuration = new t.Type<string, string, unknown>(
'TimeDuration',
t.string.is,
(input, context): Either<t.Errors, string> => {
if (typeof input === 'string' && input.trim() !== '') {
try {
const inputLength = input.length;
const time = parseInt(input.trim().substring(0, inputLength - 1), 10);
const unit = input.trim().at(-1);
if (
time >= 1 &&
Number.isSafeInteger(time) &&
(unit === 's' || unit === 'm' || unit === 'h')
) {
return t.success(input);
} else {
return t.failure(input, context);
}
} catch (error) {
return t.failure(input, context);
}
} else {
return t.failure(input, context);
}
},
t.identity
);
export type TimeDurationC = typeof TimeDuration;

View file

@ -377,6 +377,125 @@ describe('perform_bulk_action_schema', () => {
});
});
describe('schedules', () => {
test('invalid request: wrong schedules payload type', () => {
const payload = {
query: 'name: test',
action: BulkAction.edit,
[BulkAction.edit]: [{ type: BulkActionEditType.set_schedule, value: [] }],
};
const message = retrieveValidationMessage(payload);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "edit" supplied to "action"',
'Invalid value "set_schedule" supplied to "edit,type"',
'Invalid value "[]" supplied to "edit,value"',
]);
expect(message.schema).toEqual({});
});
test('invalid request: wrong type of payload data', () => {
const payload = {
query: 'name: test',
action: BulkAction.edit,
[BulkAction.edit]: [
{
type: BulkActionEditType.set_schedule,
value: {
interval: '-10m',
lookback: '1m',
},
},
],
} as PerformBulkActionSchema;
const message = retrieveValidationMessage(payload);
expect(getPaths(left(message.errors))).toEqual(
expect.arrayContaining([
'Invalid value "edit" supplied to "action"',
'Invalid value "{"interval":"-10m","lookback":"1m"}" supplied to "edit,value"',
'Invalid value "-10m" supplied to "edit,value,interval"',
])
);
expect(message.schema).toEqual({});
});
test('invalid request: missing interval', () => {
const payload = {
query: 'name: test',
action: BulkAction.edit,
[BulkAction.edit]: [
{
type: BulkActionEditType.set_schedule,
value: {
lookback: '1m',
},
},
],
} as PerformBulkActionSchema;
const message = retrieveValidationMessage(payload);
expect(getPaths(left(message.errors))).toEqual(
expect.arrayContaining([
'Invalid value "edit" supplied to "action"',
'Invalid value "{"lookback":"1m"}" supplied to "edit,value"',
'Invalid value "undefined" supplied to "edit,value,interval"',
])
);
expect(message.schema).toEqual({});
});
test('invalid request: missing lookback', () => {
const payload = {
query: 'name: test',
action: BulkAction.edit,
[BulkAction.edit]: [
{
type: BulkActionEditType.set_schedule,
value: {
interval: '1m',
},
},
],
} as PerformBulkActionSchema;
const message = retrieveValidationMessage(payload);
expect(getPaths(left(message.errors))).toEqual(
expect.arrayContaining([
'Invalid value "edit" supplied to "action"',
'Invalid value "{"interval":"1m"}" supplied to "edit,value"',
'Invalid value "undefined" supplied to "edit,value,lookback"',
])
);
expect(message.schema).toEqual({});
});
test('valid request: set_schedule edit action', () => {
const payload: PerformBulkActionSchema = {
query: 'name: test',
action: BulkAction.edit,
[BulkAction.edit]: [
{
type: BulkActionEditType.set_schedule,
value: {
interval: '1m',
lookback: '1m',
},
},
],
} as PerformBulkActionSchema;
const message = retrieveValidationMessage(payload);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
});
describe('rule actions', () => {
test('invalid request: invalid rule actions payload', () => {
const payload = {

View file

@ -6,7 +6,7 @@
*/
import * as t from 'io-ts';
import { NonEmptyArray, enumeration } from '@kbn/securitysolution-io-ts-types';
import { NonEmptyArray, TimeDuration, enumeration } from '@kbn/securitysolution-io-ts-types';
import {
throttle,
@ -38,6 +38,7 @@ export enum BulkActionEditType {
'set_timeline' = 'set_timeline',
'add_rule_actions' = 'add_rule_actions',
'set_rule_actions' = 'set_rule_actions',
'set_schedule' = 'set_schedule',
}
const bulkActionEditPayloadTags = t.type({
@ -102,11 +103,21 @@ const bulkActionEditPayloadRuleActions = t.type({
export type BulkActionEditPayloadRuleActions = t.TypeOf<typeof bulkActionEditPayloadRuleActions>;
const bulkActionEditPayloadSchedule = t.type({
type: t.literal(BulkActionEditType.set_schedule),
value: t.type({
interval: TimeDuration,
lookback: TimeDuration,
}),
});
export type BulkActionEditPayloadSchedule = t.TypeOf<typeof bulkActionEditPayloadSchedule>;
export const bulkActionEditPayload = t.union([
bulkActionEditPayloadTags,
bulkActionEditPayloadIndexPatterns,
bulkActionEditPayloadTimeline,
bulkActionEditPayloadRuleActions,
bulkActionEditPayloadSchedule,
]);
export type BulkActionEditPayload = t.TypeOf<typeof bulkActionEditPayload>;
@ -116,14 +127,16 @@ export type BulkActionEditPayload = t.TypeOf<typeof bulkActionEditPayload>;
*/
export type BulkActionEditForRuleAttributes =
| BulkActionEditPayloadTags
| BulkActionEditPayloadRuleActions;
| BulkActionEditPayloadRuleActions
| BulkActionEditPayloadSchedule;
/**
* actions that modify rules params
*/
export type BulkActionEditForRuleParams =
| BulkActionEditPayloadIndexPatterns
| BulkActionEditPayloadTimeline;
| BulkActionEditPayloadTimeline
| BulkActionEditPayloadSchedule;
export const performBulkActionSchema = t.intersection([
t.exact(

View file

@ -64,6 +64,13 @@ import {
openBulkEditDeleteIndexPatternsForm,
selectTimelineTemplate,
checkTagsInTagsFilter,
clickUpdateScheduleMenuItem,
typeScheduleInterval,
typeScheduleLookback,
setScheduleLookbackTimeUnit,
setScheduleIntervalTimeUnit,
assertRuleScheduleValues,
assertUpdateScheduleWarningExists,
} from '../../tasks/rules_bulk_edit';
import { hasIndexPatterns, getDetails } from '../../tasks/rule_details';
@ -484,4 +491,51 @@ describe('Detection rules, bulk edit', () => {
getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', noneTimelineTemplate);
});
});
describe('Schedule', () => {
it('Updates schedule for custom rules', () => {
selectNumberOfRules(expectedNumberOfCustomRulesToBeEdited);
clickUpdateScheduleMenuItem();
assertUpdateScheduleWarningExists(expectedNumberOfCustomRulesToBeEdited);
typeScheduleInterval('20');
setScheduleIntervalTimeUnit('Hours');
typeScheduleLookback('10');
setScheduleLookbackTimeUnit('Minutes');
submitBulkEditForm();
waitForBulkEditActionToFinish({ rulesCount: expectedNumberOfCustomRulesToBeEdited });
goToTheRuleDetailsOf(RULE_NAME);
assertRuleScheduleValues({
interval: '20h',
lookback: '10m',
});
});
it('Validates invalid inputs when scheduling for custom rules', () => {
selectNumberOfRules(expectedNumberOfCustomRulesToBeEdited);
clickUpdateScheduleMenuItem();
// Validate invalid values are corrected to minimumValue - for 0 and negative values
typeScheduleInterval('0');
setScheduleIntervalTimeUnit('Hours');
typeScheduleLookback('-5');
setScheduleLookbackTimeUnit('Seconds');
submitBulkEditForm();
waitForBulkEditActionToFinish({ rulesCount: expectedNumberOfCustomRulesToBeEdited });
goToTheRuleDetailsOf(RULE_NAME);
assertRuleScheduleValues({
interval: '1h',
lookback: '1s',
});
});
});
});

View file

@ -13,6 +13,8 @@ export const ADD_INDEX_PATTERNS_RULE_BULK_MENU_ITEM =
export const DELETE_INDEX_PATTERNS_RULE_BULK_MENU_ITEM =
'[data-test-subj="deleteIndexPatternsBulkEditRule"]';
export const UPDATE_SCHEDULE_MENU_ITEM = '[data-test-subj="setScheduleBulk"]';
export const APPLY_TIMELINE_RULE_BULK_MENU_ITEM = '[data-test-subj="applyTimelineTemplateBulk"]';
export const TAGS_RULE_BULK_MENU_ITEM = '[data-test-subj="tagsBulkEditRule"]';
@ -45,3 +47,13 @@ export const RULES_BULK_EDIT_TIMELINE_TEMPLATES_SELECTOR =
export const RULES_BULK_EDIT_TIMELINE_TEMPLATES_WARNING =
'[data-test-subj="bulkEditRulesTimelineTemplateWarning"]';
export const RULES_BULK_EDIT_SCHEDULES_WARNING = '[data-test-subj="bulkEditRulesSchedulesWarning"]';
export const UPDATE_SCHEDULE_INTERVAL_INPUT =
'[data-test-subj="bulkEditRulesScheduleIntervalSelector"]';
export const UPDATE_SCHEDULE_LOOKBACK_INPUT =
'[data-test-subj="bulkEditRulesScheduleLookbackSelector"]';
export const UPDATE_SCHEDULE_TIME_UNIT_SELECT = '[data-test-subj="timeType"]';

View file

@ -31,7 +31,13 @@ import {
RULES_BULK_EDIT_OVERWRITE_TAGS_CHECKBOX,
RULES_BULK_EDIT_OVERWRITE_INDEX_PATTERNS_CHECKBOX,
RULES_BULK_EDIT_TIMELINE_TEMPLATES_SELECTOR,
UPDATE_SCHEDULE_MENU_ITEM,
UPDATE_SCHEDULE_INTERVAL_INPUT,
UPDATE_SCHEDULE_TIME_UNIT_SELECT,
UPDATE_SCHEDULE_LOOKBACK_INPUT,
RULES_BULK_EDIT_SCHEDULES_WARNING,
} from '../screens/rules_bulk_edit';
import { SCHEDULE_DETAILS } from '../screens/rule_details';
export const clickApplyTimelineTemplatesMenuItem = () => {
cy.get(BULK_ACTIONS_BTN).click();
@ -53,6 +59,11 @@ export const clickAddTagsMenuItem = () => {
cy.get(ADD_TAGS_RULE_BULK_MENU_ITEM).click();
};
export const clickUpdateScheduleMenuItem = () => {
cy.get(BULK_ACTIONS_BTN).click();
cy.get(UPDATE_SCHEDULE_MENU_ITEM).click().should('not.exist');
};
export const clickAddIndexPatternsMenuItem = () => {
clickIndexPatternsMenuItem();
cy.get(ADD_INDEX_PATTERNS_RULE_BULK_MENU_ITEM).click();
@ -166,3 +177,51 @@ export const checkTagsInTagsFilter = (tags: string[]) => {
cy.wrap($el).should('have.text', tags[index]);
});
};
export const typeScheduleInterval = (interval: string) => {
cy.get(UPDATE_SCHEDULE_INTERVAL_INPUT)
.find('input')
.type('{selectAll}')
.type(interval.toString())
.blur();
};
export const typeScheduleLookback = (lookback: string) => {
cy.get(UPDATE_SCHEDULE_LOOKBACK_INPUT)
.find('input')
.type('{selectAll}', { waitForAnimations: true })
.type(lookback.toString(), { waitForAnimations: true })
.blur();
};
type TimeUnit = 'Seconds' | 'Minutes' | 'Hours';
export const setScheduleIntervalTimeUnit = (timeUnit: TimeUnit) => {
cy.get(UPDATE_SCHEDULE_INTERVAL_INPUT).within(() => {
cy.get(UPDATE_SCHEDULE_TIME_UNIT_SELECT).select(timeUnit);
});
};
export const setScheduleLookbackTimeUnit = (timeUnit: TimeUnit) => {
cy.get(UPDATE_SCHEDULE_LOOKBACK_INPUT).within(() => {
cy.get(UPDATE_SCHEDULE_TIME_UNIT_SELECT).select(timeUnit);
});
};
export const assertUpdateScheduleWarningExists = (expectedNumberOfNotMLRules: number) => {
cy.get(RULES_BULK_EDIT_SCHEDULES_WARNING).should(
'have.text',
`You're about to apply changes to ${expectedNumberOfNotMLRules} selected rules. The changes you made will be overwritten to the existing Rule schedules and additional look-back time (if any).`
);
};
export const assertRuleScheduleValues = ({
interval,
lookback,
}: {
interval: string;
lookback: string;
}) => {
cy.get(SCHEDULE_DETAILS).within(() => {
cy.get('dd').eq(0).should('contain.text', interval);
cy.get('dd').eq(1).should('contain.text', lookback);
});
};

View file

@ -12,6 +12,7 @@ import {
EuiFormRow,
EuiSelect,
EuiFormControlLayout,
transparentize,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
@ -29,6 +30,7 @@ interface ScheduleItemProps {
isDisabled: boolean;
minimumValue?: number;
timeTypes?: string[];
fullWidth?: boolean;
}
const timeTypeOptions = [
@ -49,12 +51,27 @@ const StyledEuiFormRow = styled(EuiFormRow)`
max-width: none;
.euiFormControlLayout {
max-width: 200px !important;
max-width: auto;
width: auto;
}
.euiFormControlLayout__childrenWrapper > *:first-child {
box-shadow: none;
height: 38px;
width: 100%;
}
.euiFormControlLayout__childrenWrapper > select {
background-color: ${({ theme }) => transparentize(theme.eui.euiColorPrimary, 0.1)};
color: ${({ theme }) => theme.eui.euiColorPrimary};
}
.euiFormControlLayout--group .euiFormControlLayout {
min-width: 100px;
}
.euiFormControlLayoutIcons {
color: ${({ theme }) => theme.eui.euiColorPrimary};
}
.euiFormControlLayout:not(:first-child) {
@ -66,12 +83,12 @@ const MyEuiSelect = styled(EuiSelect)`
width: auto;
`;
const getNumberFromUserInput = (input: string, defaultValue = 0): number => {
const getNumberFromUserInput = (input: string, minimumValue = 0): number => {
const number = parseInt(input, 10);
if (Number.isNaN(number)) {
return defaultValue;
return minimumValue;
} else {
return Math.min(number, Number.MAX_SAFE_INTEGER);
return Math.max(minimumValue, Math.min(number, Number.MAX_SAFE_INTEGER));
}
};
@ -82,6 +99,7 @@ export const ScheduleItem = ({
isDisabled,
minimumValue = 0,
timeTypes = ['s', 'm', 'h'],
fullWidth = false,
}: ScheduleItemProps) => {
const [timeType, setTimeType] = useState(timeTypes[0]);
const [timeVal, setTimeVal] = useState<number>(0);
@ -150,7 +168,7 @@ export const ScheduleItem = ({
helpText={field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth={false}
fullWidth={fullWidth}
data-test-subj={dataTestSubj}
describedByIds={idAria ? [idAria] : undefined}
>

View file

@ -6,8 +6,8 @@
*/
import type { FC } from 'react';
import styled from 'styled-components';
import React, { memo, useCallback, useEffect } from 'react';
import type { RuleStepProps, ScheduleStepRule } from '../../../pages/detection_engine/rules/types';
import { RuleStep } from '../../../pages/detection_engine/rules/types';
import { StepRuleDescription } from '../description_step';
@ -17,6 +17,9 @@ import { StepContentWrapper } from '../step_content_wrapper';
import { NextStep } from '../next_step';
import { schema } from './schema';
const StyledForm = styled(Form)`
max-width: 235px !important;
`;
interface StepScheduleRuleProps extends RuleStepProps {
defaultValues: ScheduleStepRule;
onRuleDataChange?: (data: ScheduleStepRule) => void;
@ -84,7 +87,7 @@ const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({
) : (
<>
<StepContentWrapper addPadding={!isUpdateView}>
<Form form={form} data-test-subj="stepScheduleRule">
<StyledForm form={form} data-test-subj="stepScheduleRule">
<UseField
path="interval"
component={ScheduleItem}
@ -104,7 +107,7 @@ const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({
minimumValue: 1,
}}
/>
</Form>
</StyledForm>
</StepContentWrapper>
{!isUpdateView && (

View file

@ -14,6 +14,7 @@ import { IndexPatternsForm } from './forms/index_patterns_form';
import { TagsForm } from './forms/tags_form';
import { TimelineTemplateForm } from './forms/timeline_template_form';
import { RuleActionsForm } from './forms/rule_actions_form';
import { ScheduleForm } from './forms/schedule_form';
interface BulkEditFlyoutProps {
onClose: () => void;
@ -41,6 +42,8 @@ const BulkEditFlyoutComponent = ({ editAction, tags, ...props }: BulkEditFlyoutP
case BulkActionEditType.add_rule_actions:
case BulkActionEditType.set_rule_actions:
return <RuleActionsForm {...props} />;
case BulkActionEditType.set_schedule:
return <ScheduleForm {...props} />;
default:
return null;

View file

@ -0,0 +1,103 @@
/*
* 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 type { BulkActionEditPayload } from '../../../../../../../../common/detection_engine/schemas/request';
import { BulkActionEditType } from '../../../../../../../../common/detection_engine/schemas/request';
import { useForm, UseField } from '../../../../../../../shared_imports';
import type { FormSchema } from '../../../../../../../shared_imports';
import { BulkEditFormWrapper } from './bulk_edit_form_wrapper';
import { ScheduleItem } from '../../../../../../components/rules/schedule_item_form';
import { bulkSetSchedule as i18n } from '../translations';
export interface ScheduleFormData {
interval: string;
lookback: string;
}
export const formSchema: FormSchema<ScheduleFormData> = {
interval: {
label: i18n.INTERVAL_LABEL,
helpText: i18n.INTERVAL_HELP_TEXT,
},
lookback: {
label: i18n.LOOKBACK_LABEL,
helpText: i18n.LOOKBACK_HELP_TEXT,
},
};
const defaultFormData: ScheduleFormData = {
interval: '5m',
lookback: '1m',
};
interface ScheduleFormComponentProps {
rulesCount: number;
onClose: () => void;
onConfirm: (bulkActionEditPayload: BulkActionEditPayload) => void;
}
export const ScheduleForm = ({ rulesCount, onClose, onConfirm }: ScheduleFormComponentProps) => {
const { form } = useForm({
schema: formSchema,
defaultValue: defaultFormData,
});
const handleSubmit = useCallback(async () => {
const { data, isValid } = await form.submit();
if (!isValid) {
return;
}
onConfirm({
type: BulkActionEditType.set_schedule,
value: {
interval: data.interval,
lookback: data.lookback,
},
});
}, [form, onConfirm]);
const warningCallout = (
<EuiCallOut color="warning" data-test-subj="bulkEditRulesSchedulesWarning">
{i18n.warningCalloutMessage(rulesCount)}
</EuiCallOut>
);
return (
<BulkEditFormWrapper
form={form}
title={i18n.FORM_TITLE}
banner={warningCallout}
onClose={onClose}
onSubmit={handleSubmit}
>
<UseField
path="interval"
component={ScheduleItem}
componentProps={{
idAria: 'bulkEditRulesScheduleIntervalSelector',
dataTestSubj: 'bulkEditRulesScheduleIntervalSelector',
fullWidth: true,
minimumValue: 1,
}}
/>
<UseField
path="lookback"
component={ScheduleItem}
componentProps={{
idAria: 'bulkEditRulesScheduleLookbackSelector',
dataTestSubj: 'bulkEditRulesScheduleLookbackSelector',
fullWidth: true,
minimumValue: 1,
}}
/>
</BulkEditFormWrapper>
);
};

View file

@ -94,3 +94,43 @@ export const bulkAddRuleActions = {
}
),
};
export const bulkSetSchedule = {
FORM_TITLE: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.setSchedule.formTitle',
{
defaultMessage: 'Update rule schedules',
}
),
INTERVAL_LABEL: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.setSchedule.intervalLabel',
{
defaultMessage: 'Runs every',
}
),
INTERVAL_HELP_TEXT: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.setSchedule.intervalHelpText',
{
defaultMessage: 'Rules run periodically and detect alerts within the specified time frame.',
}
),
LOOKBACK_LABEL: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.setSchedule.lookbackLabel',
{
defaultMessage: 'Additional look-back time',
}
),
LOOKBACK_HELP_TEXT: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.setSchedule.lookbackHelpText',
{
defaultMessage: 'Adds time to the look-back period to prevent missed alerts.',
}
),
warningCalloutMessage: (rulesCount: number): JSX.Element => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.setSchedule.warningCalloutMessage"
defaultMessage="You're about to apply changes to {rulesCount, plural, one {# selected rule} other {# selected rules}}. The changes you made will be overwritten to the existing Rule schedules and additional look-back time (if any)."
values={{ rulesCount }}
/>
),
};

View file

@ -370,6 +370,16 @@ export const useBulkActions = ({
toolTipPosition: 'right',
icon: undefined,
},
{
key: i18n.BULK_ACTION_SET_SCHEDULE,
name: i18n.BULK_ACTION_SET_SCHEDULE,
'data-test-subj': 'setScheduleBulk',
disabled: isEditDisabled,
onClick: handleBulkEdit(BulkActionEditType.set_schedule),
toolTipContent: missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined,
toolTipPosition: 'right',
icon: undefined,
},
{
key: i18n.BULK_ACTION_APPLY_TIMELINE_TEMPLATE,
name: i18n.BULK_ACTION_APPLY_TIMELINE_TEMPLATE,

View file

@ -63,6 +63,13 @@ export const computeDryRunPayload = (
value: { throttle: '1h', actions: [] },
},
];
case BulkActionEditType.set_schedule:
return [
{
type: editAction,
value: { interval: '5m', lookback: '1m' },
},
];
default:
assertUnreachable(editAction);

View file

@ -274,6 +274,7 @@ export const RulesTables = React.memo<RulesTableProps>(
const shouldShowLinearProgress = isFetched && isRefetching;
const shouldShowLoadingOverlay = (!isFetched && isRefetching) || isActionInProgress;
return (
<>
{shouldShowLinearProgress && (

View file

@ -187,6 +187,13 @@ export const BULK_ACTION_ADD_RULE_ACTIONS = i18n.translate(
}
);
export const BULK_ACTION_SET_SCHEDULE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.setScheduleTitle',
{
defaultMessage: 'Update rule schedules',
}
);
export const BULK_ACTION_MENU_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.contextMenuTitle',
{

View file

@ -41,4 +41,22 @@ describe('bulkEditActionToRulesClientOperation', () => {
value: ['test'],
},
]);
test('should transform schedule bulk edit correctly', () => {
expect(
bulkEditActionToRulesClientOperation({
type: BulkActionEditType.set_schedule,
value: {
interval: '100m',
lookback: '10m',
},
})
).toEqual([
{
field: 'schedule',
operation: 'set',
value: { interval: '100m' },
},
]);
});
});

View file

@ -87,6 +87,20 @@ export const bulkEditActionToRulesClientOperation = (
getNotifyWhenOperation(action.value.throttle),
];
// schedule actions
case BulkActionEditType.set_schedule:
return [
{
field: 'schedule',
operation: 'set',
// We need to pass a pure Interval object
// i.e. get rid of the meta property
value: {
interval: action.value.interval,
},
},
];
default:
return assertUnreachable(action);
}

View file

@ -351,4 +351,28 @@ describe('ruleParamsModifier', () => {
expect(editedRuleParams.timelineTitle).toBe('Test timeline');
});
});
describe('schedule', () => {
test('should set timeline', () => {
const INTERVAL_IN_MINUTES = 5;
const LOOKBACK_IN_MINUTES = 1;
const FROM_IN_SECONDS = (INTERVAL_IN_MINUTES + LOOKBACK_IN_MINUTES) * 60;
const editedRuleParams = ruleParamsModifier(ruleParamsMock, [
{
type: BulkActionEditType.set_schedule,
value: {
interval: `${INTERVAL_IN_MINUTES}m`,
lookback: `${LOOKBACK_IN_MINUTES}m`,
},
},
]);
// @ts-expect-error
expect(editedRuleParams.interval).toBeUndefined();
expect(editedRuleParams.meta).toStrictEqual({
from: '1m',
});
expect(editedRuleParams.from).toBe(`now-${FROM_IN_SECONDS}s`);
});
});
});

View file

@ -5,11 +5,13 @@
* 2.0.
*/
import type { RuleAlertType } from '../types';
/* eslint-disable complexity */
import moment from 'moment';
import { parseInterval } from '@kbn/data-plugin/common/search/aggs/utils/date_interval_utils';
import type { RuleAlertType } from '../types';
import type { BulkActionEditForRuleParams } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema';
import { BulkActionEditType } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema';
import { invariant } from '../../../../../common/utils/invariant';
export const addItemsToArray = <T>(arr: T[], items: T[]): T[] =>
@ -90,6 +92,23 @@ const applyBulkActionEditToRuleParams = (
timelineTitle: action.value.timeline_title || undefined,
};
break;
// update look-back period in from and meta.from fields
case BulkActionEditType.set_schedule: {
const interval = parseInterval(action.value.interval) ?? moment.duration(0);
const parsedFrom = parseInterval(action.value.lookback) ?? moment.duration(0);
const from = parsedFrom.asSeconds() + interval.asSeconds();
ruleParams = {
...ruleParams,
meta: {
...ruleParams.meta,
from: action.value.lookback,
},
from: `now-${from}s`,
};
}
}
return ruleParams;

View file

@ -29,6 +29,10 @@ export const splitBulkEditActions = (actions: BulkActionEditPayload[]) => {
return actions.reduce((acc, action) => {
switch (action.type) {
case BulkActionEditType.set_schedule:
acc.attributesActions.push(action);
acc.paramsActions.push(action);
break;
case BulkActionEditType.add_tags:
case BulkActionEditType.set_tags:
case BulkActionEditType.delete_tags:

View file

@ -49,10 +49,10 @@ export const bulkEditRules = async ({
mlAuthz,
}: BulkEditRulesArguments) => {
const { attributesActions, paramsActions } = splitBulkEditActions(actions);
const operations = attributesActions.map(bulkEditActionToRulesClientOperation).flat();
const result = await rulesClient.bulkEdit({
...(ids ? { ids } : { filter: enrichFilterWithRuleTypeMapping(filter) }),
operations: attributesActions.map(bulkEditActionToRulesClientOperation).flat(),
operations,
paramsModifier: async (ruleParams: RuleAlertType['params']) => {
await validateBulkEditRule({
mlAuthz,

View file

@ -1050,6 +1050,10 @@ export default ({ getService }: FtrProviderContext): void => {
type: BulkActionEditType.set_timeline,
value: { timeline_id: 'mock-id', timeline_title: 'mock-title' },
},
{
type: BulkActionEditType.set_schedule,
value: { interval: '1m', lookback: '1m' },
},
];
cases.forEach(({ type, value }) => {
it(`should return error when trying to apply "${type}" edit action to prebuilt rule`, async () => {
@ -1655,6 +1659,71 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
});
describe('schedule actions', () => {
it('should return bad request error if payload is invalid', async () => {
const ruleId = 'ruleId';
const intervalMinutes = 0;
const interval = `${intervalMinutes}m`;
const lookbackMinutes = -1;
const lookback = `${lookbackMinutes}m`;
await createRule(supertest, log, getSimpleRule(ruleId));
const { body } = await postBulkAction()
.send({
query: '',
action: BulkAction.edit,
[BulkAction.edit]: [
{
type: BulkActionEditType.set_schedule,
value: {
interval,
lookback,
},
},
],
})
.expect(400);
expect(body.statusCode).to.eql(400);
expect(body.error).to.eql('Bad Request');
expect(body.message).to.contain('Invalid value "0m" supplied to "edit,value,interval"');
expect(body.message).to.contain('Invalid value "-1m" supplied to "edit,value,lookback"');
});
it('should update schedule values in rules with a valid payload', async () => {
const ruleId = 'ruleId';
const intervalMinutes = 15;
const interval = `${intervalMinutes}m`;
const lookbackMinutes = 10;
const lookback = `${lookbackMinutes}m`;
await createRule(supertest, log, getSimpleRule(ruleId));
const { body } = await postBulkAction()
.send({
query: '',
action: BulkAction.edit,
[BulkAction.edit]: [
{
type: BulkActionEditType.set_schedule,
value: {
interval,
lookback,
},
},
],
})
.expect(200);
expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 });
expect(body.attributes.results.updated[0].interval).to.eql(interval);
expect(body.attributes.results.updated[0].meta).to.eql({ from: `${lookbackMinutes}m` });
expect(body.attributes.results.updated[0].from).to.eql(
`now-${(intervalMinutes + lookbackMinutes) * 60}s`
);
});
});
});
describe('overwrite_data_views', () => {