[Response Ops] Rule Specific Flapping - Create/Edit Rule Flyout Frontend Changes (#189341)

## Summary
Issue: https://github.com/elastic/kibana/issues/189135

Frontend changes for the rule specific flapping feature in the existing
rule flyout.

To test: Simply go to
`x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts`
and set line 89 `IS_RULE_SPECIFIC_FLAPPING_ENABLED = false` to `true`.
This acts as a feature flag. Then you may go to the create/edit rule
flyout to see this new input. This PR does not contain changes to allow
for saving/editing of this field. That will come with the backend PR.

### Flapping Enabled Without Override
<img width="648" alt="Screenshot 2024-07-29 at 12 11 02 AM"
src="https://github.com/user-attachments/assets/82b552c5-faf3-459f-a22d-69ea95292d89">

### Flapping Enabled With Override
<img width="652" alt="Screenshot 2024-07-29 at 12 11 07 AM"
src="https://github.com/user-attachments/assets/2d305570-8cd6-4488-af5b-8c78cb3c2b3a">

### Flapping Disabled With or Without Override
<img width="652" alt="image"
src="https://github.com/user-attachments/assets/5bb76e6a-85e5-4992-a37f-a737d083ec54">

### Checklist
- [x] [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

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Jiawei Wu 2024-08-21 17:55:00 -07:00 committed by GitHub
parent 01aae2331d
commit a2873c0c87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1159 additions and 321 deletions

View file

@ -17,4 +17,5 @@ export * from './r_rule_types';
export * from './rule_notify_when_type';
export * from './rule_type_types';
export * from './rule_types';
export * from './rule_flapping';
export * from './search_strategy_types';

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
export const MIN_LOOK_BACK_WINDOW = 2;
export const MAX_LOOK_BACK_WINDOW = 20;
export const MIN_STATUS_CHANGE_THRESHOLD = 2;
export const MAX_STATUS_CHANGE_THRESHOLD = 20;

View file

@ -239,6 +239,10 @@ export interface Rule<Params extends RuleTypeParams = never> {
running?: boolean | null;
viewInAppRelativeUrl?: string;
alertDelay?: AlertDelay | null;
flapping?: {
lookBackWindow: number;
statusChangeThreshold: number;
};
}
export type SanitizedRule<Params extends RuleTypeParams = never> = Omit<

View file

@ -62,6 +62,10 @@ describe('createRule', () => {
alert_delay: {
active: 10,
},
flapping: {
look_back_window: 10,
status_change_threshold: 10,
},
};
const ruleToCreate: CreateRuleBody<RuleTypeParams> = {
@ -109,10 +113,18 @@ describe('createRule', () => {
alertDelay: {
active: 10,
},
flapping: {
lookBackWindow: 10,
statusChangeThreshold: 10,
},
};
http.post.mockResolvedValueOnce(resolvedValue);
const result = await createRule({ http, rule: ruleToCreate as CreateRuleBody });
expect(http.post).toHaveBeenCalledWith('/api/alerting/rule', {
body: '{"params":{"aggType":"count","termSize":5,"thresholdComparator":">","timeWindowSize":5,"timeWindowUnit":"m","groupBy":"all","threshold":[1000],"index":[".kibana"],"timeField":"alert.executionStatus.lastExecutionDate"},"consumer":"alerts","schedule":{"interval":"1m"},"tags":[],"name":"test","enabled":true,"throttle":null,"notifyWhen":"onActionGroupChange","rule_type_id":".index-threshold","actions":[{"group":"threshold met","id":"83d4d860-9316-11eb-a145-93ab369a4461","params":{"level":"info","message":"Rule \'{{rule.name}}\' is active for group \'{{context.group}}\':\\n\\n- Value: {{context.value}}\\n- Conditions Met: {{context.conditions}} over {{rule.params.timeWindowSize}}{{rule.params.timeWindowUnit}}\\n- Timestamp: {{context.date}}"},"frequency":{"notify_when":"onActionGroupChange","throttle":null,"summary":false}},{"id":".test-system-action","params":{}}],"alert_delay":{"active":10},"flapping":{"look_back_window":10,"status_change_threshold":10}}',
});
expect(result).toEqual({
actions: [
{
@ -169,6 +181,10 @@ describe('createRule', () => {
alertDelay: {
active: 10,
},
flapping: {
lookBackWindow: 10,
statusChangeThreshold: 10,
},
});
});
});

View file

@ -55,6 +55,10 @@ const ruleToCreate: CreateRuleBody<RuleTypeParams> = {
alertDelay: {
active: 10,
},
flapping: {
lookBackWindow: 10,
statusChangeThreshold: 10,
},
};
describe('transformCreateRuleBody', () => {
@ -96,8 +100,11 @@ describe('transformCreateRuleBody', () => {
},
{ id: '.test-system-action', params: {} },
],
alert_delay: { active: 10 },
flapping: {
look_back_window: 10,
status_change_threshold: 10,
},
});
});
});

View file

@ -8,11 +8,26 @@
import { RewriteResponseCase } from '@kbn/actions-types';
import { CreateRuleBody } from './types';
import { Rule } from '../../types';
const transformCreateRuleFlapping = (flapping: Rule['flapping']) => {
if (!flapping) {
return flapping;
}
return {
flapping: {
look_back_window: flapping.lookBackWindow,
status_change_threshold: flapping.statusChangeThreshold,
},
};
};
export const transformCreateRuleBody: RewriteResponseCase<CreateRuleBody> = ({
ruleTypeId,
actions = [],
alertDelay,
flapping,
...res
}): any => ({
...res,
@ -44,4 +59,5 @@ export const transformCreateRuleBody: RewriteResponseCase<CreateRuleBody> = ({
};
}),
...(alertDelay ? { alert_delay: alertDelay } : {}),
...(flapping !== undefined ? transformCreateRuleFlapping(flapping) : {}),
});

View file

@ -20,4 +20,5 @@ export interface CreateRuleBody<Params extends RuleTypeParams = RuleTypeParams>
throttle?: Rule<Params>['throttle'];
notifyWhen?: Rule<Params>['notifyWhen'];
alertDelay?: Rule<Params>['alertDelay'];
flapping?: Rule<Params>['flapping'];
}

View file

@ -52,6 +52,10 @@ const ruleToUpdate: UpdateRuleBody<RuleTypeParams> = {
alertDelay: {
active: 10,
},
flapping: {
lookBackWindow: 10,
statusChangeThreshold: 10,
},
};
describe('transformUpdateRuleBody', () => {
@ -98,6 +102,10 @@ describe('transformUpdateRuleBody', () => {
},
tags: [],
throttle: null,
flapping: {
look_back_window: 10,
status_change_threshold: 10,
},
});
});
});

View file

@ -8,10 +8,25 @@
import { RewriteResponseCase } from '@kbn/actions-types';
import { UpdateRuleBody } from './types';
import { Rule } from '../../types';
const transformUpdateRuleFlapping = (flapping: Rule['flapping']) => {
if (!flapping) {
return flapping;
}
return {
flapping: {
look_back_window: flapping.lookBackWindow,
status_change_threshold: flapping.statusChangeThreshold,
},
};
};
export const transformUpdateRuleBody: RewriteResponseCase<UpdateRuleBody> = ({
actions = [],
alertDelay,
flapping,
...res
}): any => ({
...res,
@ -41,4 +56,5 @@ export const transformUpdateRuleBody: RewriteResponseCase<UpdateRuleBody> = ({
};
}),
...(alertDelay ? { alert_delay: alertDelay } : {}),
...(flapping !== undefined ? transformUpdateRuleFlapping(flapping) : {}),
});

View file

@ -17,4 +17,5 @@ export interface UpdateRuleBody<Params extends RuleTypeParams = RuleTypeParams>
throttle?: Rule<Params>['throttle'];
notifyWhen?: Rule<Params>['notifyWhen'];
alertDelay?: Rule<Params>['alertDelay'];
flapping?: Rule<Params>['flapping'];
}

View file

@ -7,75 +7,38 @@
*/
import { httpServiceMock } from '@kbn/core/public/mocks';
import { Rule } from '../../types';
import { updateRule, UpdateRuleBody } from '.';
const http = httpServiceMock.createStartContract();
describe('updateRule', () => {
test('should call rule update API', async () => {
const ruleToUpdate = {
const updatedRule = {
params: {
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
groupBy: 'all',
threshold: [1000],
index: ['.kibana'],
timeField: 'alert.executionStatus.lastExecutionDate',
},
consumer: 'alerts',
schedule: { interval: '1m' },
tags: [],
name: 'test',
tags: ['foo'],
schedule: {
interval: '1m',
},
params: {},
createdAt: new Date('1970-01-01T00:00:00.000Z'),
updatedAt: new Date('1970-01-01T00:00:00.000Z'),
apiKey: null,
apiKeyOwner: null,
revision: 0,
alertDelay: {
active: 10,
},
rule_type_id: '.index-threshold',
actions: [
{
group: 'default',
id: '2',
actionTypeId: 'test',
params: {},
useAlertDataForTemplate: false,
frequency: {
notifyWhen: 'onActionGroupChange',
throttle: null,
summary: false,
group: 'threshold met',
id: '1',
params: {
level: 'info',
message: 'alert ',
},
},
{
id: '.test-system-action',
params: {},
actionTypeId: '.system-action',
},
],
};
const resolvedValue: Rule = {
...ruleToUpdate,
id: '12/3',
enabled: true,
ruleTypeId: 'test',
createdBy: null,
updatedBy: null,
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'unknown',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
},
revision: 1,
};
http.put.mockResolvedValueOnce({
...resolvedValue,
actions: [
{
group: 'default',
id: '2',
connector_type_id: 'test',
params: {},
use_alert_data_for_template: false,
connector_type_id: '.server-log',
frequency: {
notify_when: 'onActionGroupChange',
throttle: null,
@ -88,12 +51,31 @@ describe('updateRule', () => {
connector_type_id: '.system-action',
},
],
});
scheduled_task_id: '1',
execution_status: { status: 'pending', last_execution_date: '2021-04-01T21:33:13.250Z' },
create_at: '2021-04-01T21:33:13.247Z',
updated_at: '2021-04-01T21:33:13.247Z',
create_by: 'user',
updated_by: 'user',
alert_delay: {
active: 10,
},
flapping: {
look_back_window: 10,
status_change_threshold: 10,
},
};
const result = await updateRule({ http, id: '12/3', rule: ruleToUpdate as UpdateRuleBody });
expect(result).toEqual({
...resolvedValue,
const updateRuleBody = {
name: 'test-update',
tags: ['foo', 'bar'],
schedule: {
interval: '5m',
},
params: {},
alertDelay: {
active: 50,
},
actions: [
{
group: 'default',
@ -107,16 +89,88 @@ describe('updateRule', () => {
summary: false,
},
},
],
flapping: {
lookBackWindow: 10,
statusChangeThreshold: 10,
},
};
http.put.mockResolvedValueOnce({
...updatedRule,
name: 'test-update',
tags: ['foo', 'bar'],
schedule: {
interval: '5m',
},
params: {},
alert_delay: {
active: 50,
},
actions: [
{
id: '.test-system-action',
group: 'default',
id: '2',
action_type_dd: 'test',
params: {},
actionTypeId: '.system-action',
use_alert_data_for_template: false,
frequency: {
notify_when: 'onActionGroupChange',
throttle: null,
summary: false,
},
},
],
flapping: {
look_back_window: 10,
status_change_threshold: 10,
},
});
const result = await updateRule({ http, id: '12/3', rule: updateRuleBody as UpdateRuleBody });
expect(result).toEqual({
actions: [
{
frequency: {
notifyWhen: 'onActionGroupChange',
summary: false,
throttle: null,
},
group: 'default',
id: '2',
params: {},
useAlertDataForTemplate: false,
},
],
alertDelay: {
active: 50,
},
consumer: 'alerts',
create_at: '2021-04-01T21:33:13.247Z',
create_by: 'user',
executionStatus: {
lastExecutionDate: '2021-04-01T21:33:13.250Z',
status: 'pending',
},
flapping: {
lookBackWindow: 10,
statusChangeThreshold: 10,
},
name: 'test-update',
params: {},
ruleTypeId: '.index-threshold',
schedule: {
interval: '5m',
},
scheduledTaskId: '1',
tags: ['foo', 'bar'],
updatedAt: '2021-04-01T21:33:13.247Z',
updatedBy: 'user',
});
expect(http.put).toHaveBeenCalledWith('/api/alerting/rule/12%2F3', {
body: '{"name":"test","tags":["foo"],"schedule":{"interval":"1m"},"params":{},"actions":[{"group":"default","id":"2","params":{},"frequency":{"notify_when":"onActionGroupChange","throttle":null,"summary":false},"use_alert_data_for_template":false},{"id":".test-system-action","params":{}}],"alert_delay":{"active":10}}',
body: '{"name":"test-update","tags":["foo","bar"],"schedule":{"interval":"5m"},"params":{},"actions":[{"group":"default","id":"2","params":{},"frequency":{"notify_when":"onActionGroupChange","throttle":null,"summary":false},"use_alert_data_for_template":false}],"alert_delay":{"active":50},"flapping":{"look_back_window":10,"status_change_threshold":10}}',
});
});
});

View file

@ -21,6 +21,7 @@ export const UPDATE_FIELDS: Array<keyof UpdateRuleBody> = [
'schedule',
'params',
'alertDelay',
'flapping',
];
export const UPDATE_FIELDS_WITH_ACTIONS: Array<keyof UpdateRuleBody> = [
@ -30,6 +31,7 @@ export const UPDATE_FIELDS_WITH_ACTIONS: Array<keyof UpdateRuleBody> = [
'params',
'alertDelay',
'actions',
'flapping',
];
export async function updateRule({

View file

@ -33,6 +33,19 @@ const transformLastRun: RewriteRequestCase<RuleLastRun> = ({
...rest,
});
const transformFlapping = (flapping: AsApiContract<Rule['flapping']>) => {
if (!flapping) {
return flapping;
}
return {
flapping: {
lookBackWindow: flapping.look_back_window,
statusChangeThreshold: flapping.status_change_threshold,
},
};
};
export const transformRule: RewriteRequestCase<Rule> = ({
rule_type_id: ruleTypeId,
created_by: createdBy,
@ -53,6 +66,7 @@ export const transformRule: RewriteRequestCase<Rule> = ({
last_run: lastRun,
next_run: nextRun,
alert_delay: alertDelay,
flapping,
...rest
}: any) => ({
ruleTypeId,
@ -76,6 +90,7 @@ export const transformRule: RewriteRequestCase<Rule> = ({
...(nextRun ? { nextRun } : {}),
...(apiKeyCreatedByUser !== undefined ? { apiKeyCreatedByUser } : {}),
...(alertDelay ? { alertDelay } : {}),
...(flapping !== undefined ? transformFlapping(flapping) : {}),
...rest,
});

View file

@ -0,0 +1,110 @@
/*
* 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 React, { useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import {
MIN_LOOK_BACK_WINDOW,
MAX_LOOK_BACK_WINDOW,
MIN_STATUS_CHANGE_THRESHOLD,
MAX_STATUS_CHANGE_THRESHOLD,
} from '@kbn/alerting-types';
import { i18n } from '@kbn/i18n';
import { RuleSettingsRangeInput } from './rule_settings_range_input';
const lookBackWindowLabel = i18n.translate(
'alertsUIShared.ruleSettingsFlappingInputsProps.lookBackWindowLabel',
{
defaultMessage: 'Rule run look back window',
}
);
const lookBackWindowHelp = i18n.translate(
'alertsUIShared.ruleSettingsFlappingInputsProps.lookBackWindowHelp',
{
defaultMessage: 'The minimum number of runs in which the threshold must be met.',
}
);
const statusChangeThresholdLabel = i18n.translate(
'alertsUIShared.ruleSettingsFlappingInputsProps.statusChangeThresholdLabel',
{
defaultMessage: 'Alert status change threshold',
}
);
const statusChangeThresholdHelp = i18n.translate(
'alertsUIShared.ruleSettingsFlappingInputsProps.statusChangeThresholdHelp',
{
defaultMessage:
'The minimum number of times an alert must switch states in the look back window.',
}
);
export interface RuleSettingsFlappingInputsProps {
lookBackWindow: number;
statusChangeThreshold: number;
isDisabled?: boolean;
onLookBackWindowChange: (value: number) => void;
onStatusChangeThresholdChange: (value: number) => void;
}
export const RuleSettingsFlappingInputs = (props: RuleSettingsFlappingInputsProps) => {
const {
lookBackWindow,
statusChangeThreshold,
isDisabled = false,
onLookBackWindowChange,
onStatusChangeThresholdChange,
} = props;
const internalOnLookBackWindowChange = useCallback(
(e) => {
onLookBackWindowChange(parseInt(e.currentTarget.value, 10));
},
[onLookBackWindowChange]
);
const internalOnStatusChangeThresholdChange = useCallback(
(e) => {
onStatusChangeThresholdChange(parseInt(e.currentTarget.value, 10));
},
[onStatusChangeThresholdChange]
);
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<RuleSettingsRangeInput
fullWidth
data-test-subj="lookBackWindowRangeInput"
min={MIN_LOOK_BACK_WINDOW}
max={MAX_LOOK_BACK_WINDOW}
value={lookBackWindow}
onChange={internalOnLookBackWindowChange}
label={lookBackWindowLabel}
labelPopoverText={lookBackWindowHelp}
disabled={isDisabled}
/>
</EuiFlexItem>
<EuiFlexItem>
<RuleSettingsRangeInput
fullWidth
data-test-subj="statusChangeThresholdRangeInput"
min={MIN_STATUS_CHANGE_THRESHOLD}
max={MAX_STATUS_CHANGE_THRESHOLD}
value={statusChangeThreshold}
onChange={internalOnStatusChangeThresholdChange}
label={statusChangeThresholdLabel}
labelPopoverText={statusChangeThresholdHelp}
disabled={isDisabled}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,56 @@
/*
* 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 { EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
const getLookBackWindowLabelRuleRuns = (amount: number) => {
return i18n.translate('alertsUIShared.ruleSettingsFlappingMessage.lookBackWindowLabelRuleRuns', {
defaultMessage: '{amount, number} rule {amount, plural, one {run} other {runs}}',
values: { amount },
});
};
const getStatusChangeThresholdRuleRuns = (amount: number) => {
return i18n.translate('alertsUIShared.ruleSettingsFlappingMessage.statusChangeThresholdTimes', {
defaultMessage: '{amount, number} {amount, plural, one {time} other {times}}',
values: { amount },
});
};
export const flappingOffMessage = i18n.translate(
'alertsUIShared.ruleSettingsFlappingMessage.flappingOffMessage',
{
defaultMessage:
'Alert flapping detection is off. Alerts will be generated based on the rule interval, which might result in higher alert volumes.',
}
);
export interface RuleSettingsFlappingMessageProps {
lookBackWindow: number;
statusChangeThreshold: number;
}
export const RuleSettingsFlappingMessage = (props: RuleSettingsFlappingMessageProps) => {
const { lookBackWindow, statusChangeThreshold } = props;
return (
<EuiText size="s" data-test-subj="ruleSettingsFlappingMessage">
<FormattedMessage
id="alertsUIShared.ruleSettingsFlappingMessage.flappingSettingsDescription"
defaultMessage="An alert is flapping if it changes status at least {statusChangeThreshold} in the last {lookBackWindow}."
values={{
lookBackWindow: <b>{getLookBackWindowLabelRuleRuns(lookBackWindow)}</b>,
statusChangeThreshold: <b>{getStatusChangeThresholdRuleRuns(statusChangeThreshold)}</b>,
}}
/>
</EuiText>
);
};

View file

@ -1,25 +1,28 @@
/*
* 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.
* 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 React, { memo } from 'react';
import { EuiFormRow, EuiFormRowProps, EuiIconTip, EuiRange, EuiRangeProps } from '@elastic/eui';
export interface RulesSettingsRangeProps {
export interface RuleSettingsRangeInputProps {
label: EuiFormRowProps['label'];
labelPopoverText?: string;
min: number;
max: number;
value: number;
fullWidth?: EuiRangeProps['fullWidth'];
disabled?: EuiRangeProps['disabled'];
onChange?: EuiRangeProps['onChange'];
}
export const RulesSettingsRange = memo((props: RulesSettingsRangeProps) => {
const { label, labelPopoverText, min, max, value, disabled, onChange, ...rest } = props;
export const RuleSettingsRangeInput = memo((props: RuleSettingsRangeInputProps) => {
const { label, labelPopoverText, min, max, value, fullWidth, disabled, onChange, ...rest } =
props;
const renderLabel = () => {
return (
@ -34,8 +37,9 @@ export const RulesSettingsRange = memo((props: RulesSettingsRangeProps) => {
};
return (
<EuiFormRow label={renderLabel()}>
<EuiFormRow label={renderLabel()} fullWidth={fullWidth}>
<EuiRange
fullWidth={fullWidth}
min={min}
max={max}
step={1}

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface RulesSettingsModificationMetadata {
createdBy: string | null;
updatedBy: string | null;
@ -17,6 +18,11 @@ export interface RulesSettingsFlappingProperties {
statusChangeThreshold: number;
}
export interface RuleSpecificFlappingProperties {
lookBackWindow: number;
statusChangeThreshold: number;
}
export type RulesSettingsFlapping = RulesSettingsFlappingProperties &
RulesSettingsModificationMetadata;
@ -37,10 +43,13 @@ export interface RulesSettings {
queryDelay?: RulesSettingsQueryDelay;
}
export const MIN_LOOK_BACK_WINDOW = 2;
export const MAX_LOOK_BACK_WINDOW = 20;
export const MIN_STATUS_CHANGE_THRESHOLD = 2;
export const MAX_STATUS_CHANGE_THRESHOLD = 20;
export {
MIN_LOOK_BACK_WINDOW,
MAX_LOOK_BACK_WINDOW,
MIN_STATUS_CHANGE_THRESHOLD,
MAX_STATUS_CHANGE_THRESHOLD,
} from '@kbn/alerting-types';
export const MIN_QUERY_DELAY = 0;
export const MAX_QUERY_DELAY = 60;

View file

@ -46133,14 +46133,6 @@
"xpack.triggersActionsUI.ruleSnoozeScheduler.untilDateSummary": "jusqu'au {date}",
"xpack.triggersActionsUI.rulesSettings.flapping.alertFlappingDetection": "Détection de bagotement d'alerte",
"xpack.triggersActionsUI.rulesSettings.flapping.alertFlappingDetectionDescription": "Modifiez la fréquence à laquelle une alerte peut passer de l'état actif à l'état récupéré au cours d'une période d'exécution de règle.",
"xpack.triggersActionsUI.rulesSettings.flapping.flappingSettingsDescription": "Une alerte est instable si son statut change au moins {statusChangeThreshold} au cours des derniers/dernières {lookBackWindow}.",
"xpack.triggersActionsUI.rulesSettings.flapping.flappingSettingsOffDescription": "La détection de bagotement d'alerte est désactivée. Les alertes seront générées en fonction de l'intervalle de la règle, ce qui peut entraîner des volumes d'alertes plus importants.",
"xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowHelp": "Nombre minimal d'exécutions pour lesquelles le seuil doit être atteint.",
"xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabel": "Fenêtre d'historique d'exécution de la règle",
"xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabelRuleRuns": "{amount, number} règle {amount, plural, one {exécute} other {exécutent}}",
"xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdHelp": "Nombre minimal de fois où une alerte doit changer d'état dans la fenêtre d'historique.",
"xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdLabel": "Seuil de modification du statut d'alerte",
"xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdTimes": "{amount, number} {amount, plural, other {fois}}",
"xpack.triggersActionsUI.rulesSettings.link.title": "Paramètres",
"xpack.triggersActionsUI.rulesSettings.modal.calloutMessage": "Appliquer à toutes les règles dans l'espace actuel.",
"xpack.triggersActionsUI.rulesSettings.modal.cancelButton": "Annuler",

View file

@ -46114,14 +46114,6 @@
"xpack.triggersActionsUI.ruleSnoozeScheduler.untilDateSummary": "{date}まで",
"xpack.triggersActionsUI.rulesSettings.flapping.alertFlappingDetection": "アラートフラップ検出",
"xpack.triggersActionsUI.rulesSettings.flapping.alertFlappingDetectionDescription": "ルールの実行期間中、アラートがアクティブと回復済みの間を遷移できる頻度を変更します。",
"xpack.triggersActionsUI.rulesSettings.flapping.flappingSettingsDescription": "過去{lookBackWindow}に少なくとも{statusChangeThreshold}ステータスが変更された場合、アラートが作動しています。",
"xpack.triggersActionsUI.rulesSettings.flapping.flappingSettingsOffDescription": "アラートフラップ検出がオフです。アラートはルール間隔に基づいて生成されるため、アラート量が多くなる可能性があります。",
"xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowHelp": "しきい値を満たす必要がある最小実行回数。",
"xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabel": "ルール実行ルックバック期間",
"xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabelRuleRuns": "{amount, number}個のルール{amount, plural, other {が実行されます}}",
"xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdHelp": "ルックバック期間でアラートの状態が切り替わる必要がある最小回数。",
"xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdLabel": "アラートステータス変更しきい値",
"xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdTimes": "{amount, number} {amount, plural, other {回}}",
"xpack.triggersActionsUI.rulesSettings.link.title": "設定",
"xpack.triggersActionsUI.rulesSettings.modal.calloutMessage": "現在のスペース内のすべてのルールに適用",
"xpack.triggersActionsUI.rulesSettings.modal.cancelButton": "キャンセル",

View file

@ -46164,14 +46164,6 @@
"xpack.triggersActionsUI.ruleSnoozeScheduler.untilDateSummary": "直到 {date}",
"xpack.triggersActionsUI.rulesSettings.flapping.alertFlappingDetection": "告警摆动检测",
"xpack.triggersActionsUI.rulesSettings.flapping.alertFlappingDetectionDescription": "修改在规则运行期间内告警可在“活动”和“已恢复”之间切换的频率。",
"xpack.triggersActionsUI.rulesSettings.flapping.flappingSettingsDescription": "如果告警在过去 {lookBackWindow} 中更改状态至少 {statusChangeThreshold} 次,则表示它正在摆动。",
"xpack.triggersActionsUI.rulesSettings.flapping.flappingSettingsOffDescription": "告警摆动检测已关闭。将根据规则时间间隔生成告警,这可能导致更高的告警量。",
"xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowHelp": "必须在其间达到阈值的最小运行次数。",
"xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabel": "规则运行回顾窗口",
"xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabelRuleRuns": "{amount, number} 次规则{amount, plural, other {运行}}",
"xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdHelp": "告警必须在回顾窗口中切换状态的最小次数。",
"xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdLabel": "告警状态更改阈值",
"xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdTimes": "{amount, number} {amount, plural, other {次}}",
"xpack.triggersActionsUI.rulesSettings.link.title": "设置",
"xpack.triggersActionsUI.rulesSettings.modal.calloutMessage": "应用于当前工作区内的所有规则。",
"xpack.triggersActionsUI.rulesSettings.modal.cancelButton": "取消",

View file

@ -6,69 +6,14 @@
*/
import React, { memo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiText, EuiPanel } from '@elastic/eui';
import {
RulesSettingsFlappingProperties,
MIN_LOOK_BACK_WINDOW,
MIN_STATUS_CHANGE_THRESHOLD,
MAX_LOOK_BACK_WINDOW,
MAX_STATUS_CHANGE_THRESHOLD,
} from '@kbn/alerting-plugin/common';
import { RulesSettingsRange } from '../rules_settings_range';
import { RuleSettingsFlappingInputs } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_flapping_inputs';
import { RuleSettingsFlappingMessage } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_flapping_message';
import { RulesSettingsFlappingProperties } from '@kbn/alerting-plugin/common';
type OnChangeKey = keyof Omit<RulesSettingsFlappingProperties, 'enabled'>;
const lookBackWindowLabel = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabel',
{
defaultMessage: 'Rule run look back window',
}
);
const lookBackWindowHelp = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowHelp',
{
defaultMessage: 'The minimum number of runs in which the threshold must be met.',
}
);
const statusChangeThresholdLabel = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdLabel',
{
defaultMessage: 'Alert status change threshold',
}
);
const statusChangeThresholdHelp = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdHelp',
{
defaultMessage:
'The minimum number of times an alert must switch states in the look back window.',
}
);
const getLookBackWindowLabelRuleRuns = (amount: number) => {
return i18n.translate(
'xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabelRuleRuns',
{
defaultMessage: '{amount, number} rule {amount, plural, one {run} other {runs}}',
values: { amount },
}
);
};
const getStatusChangeThresholdRuleRuns = (amount: number) => {
return i18n.translate(
'xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdTimes',
{
defaultMessage: '{amount, number} {amount, plural, one {time} other {times}}',
values: { amount },
}
);
};
export const RulesSettingsFlappingTitle = () => {
return (
<EuiTitle size="xs">
@ -123,44 +68,21 @@ export const RulesSettingsFlappingFormSection = memo(
</EuiFlexItem>
</>
)}
<EuiFlexItem grow={false}>
<RulesSettingsRange
data-test-subj="lookBackWindowRangeInput"
min={MIN_LOOK_BACK_WINDOW}
max={MAX_LOOK_BACK_WINDOW}
value={lookBackWindow}
onChange={(e) => onChange('lookBackWindow', parseInt(e.currentTarget.value, 10))}
label={lookBackWindowLabel}
labelPopoverText={lookBackWindowHelp}
disabled={!canWrite}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RulesSettingsRange
data-test-subj="statusChangeThresholdRangeInput"
min={MIN_STATUS_CHANGE_THRESHOLD}
max={MAX_STATUS_CHANGE_THRESHOLD}
value={statusChangeThreshold}
onChange={(e) => onChange('statusChangeThreshold', parseInt(e.currentTarget.value, 10))}
label={statusChangeThresholdLabel}
labelPopoverText={statusChangeThresholdHelp}
disabled={!canWrite}
<EuiFlexItem>
<RuleSettingsFlappingInputs
lookBackWindow={lookBackWindow}
statusChangeThreshold={statusChangeThreshold}
isDisabled={!canWrite}
onLookBackWindowChange={(value) => onChange('lookBackWindow', value)}
onStatusChangeThresholdChange={(value) => onChange('statusChangeThreshold', value)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel borderRadius="none" color="subdued">
<EuiText size="s">
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.flapping.flappingSettingsDescription"
defaultMessage="An alert is flapping if it changes status at least {statusChangeThreshold} in the last {lookBackWindow}."
values={{
lookBackWindow: <b>{getLookBackWindowLabelRuleRuns(lookBackWindow)}</b>,
statusChangeThreshold: (
<b>{getStatusChangeThresholdRuleRuns(statusChangeThreshold)}</b>
),
}}
/>
</EuiText>
<RuleSettingsFlappingMessage
lookBackWindow={lookBackWindow}
statusChangeThreshold={statusChangeThreshold}
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -20,6 +20,7 @@ import {
EuiText,
EuiEmptyPrompt,
} from '@elastic/eui';
import { flappingOffMessage } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_flapping_message';
import {
RulesSettingsFlappingFormSection,
RulesSettingsFlappingFormSectionProps,
@ -121,12 +122,7 @@ export const RulesSettingsFlappingFormRight = memo((props: RulesSettingsFlapping
return (
<EuiFlexItem data-test-subj="rulesSettingsFlappingOffPrompt">
<EuiPanel borderRadius="none" color="subdued" grow={false}>
<EuiText size="s">
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.flapping.flappingSettingsOffDescription"
defaultMessage="Alert flapping detection is off. Alerts will be generated based on the rule interval, which might result in higher alert volumes."
/>
</EuiText>
<EuiText size="s">{flappingOffMessage}</EuiText>
</EuiPanel>
</EuiFlexItem>
);

View file

@ -22,7 +22,7 @@ import {
EuiEmptyPrompt,
EuiTitle,
} from '@elastic/eui';
import { RulesSettingsRange } from '../rules_settings_range';
import { RuleSettingsRangeInput } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_range_input';
const queryDelayDescription = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.modal.queryDelayDescription',
@ -107,7 +107,7 @@ export const RulesSettingsQueryDelaySection = memo((props: RulesSettingsQueryDel
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow>
<RulesSettingsRange
<RuleSettingsRangeInput
data-test-subj="queryDelayRangeInput"
min={MIN_QUERY_DELAY}
max={MAX_QUERY_DELAY}

View file

@ -12,7 +12,7 @@ import { getFlappingSettings } from '../lib/rule_api/get_flapping_settings';
interface UseGetFlappingSettingsProps {
enabled: boolean;
onSuccess: (settings: RulesSettingsFlapping) => void;
onSuccess?: (settings: RulesSettingsFlapping) => void;
}
export const useGetFlappingSettings = (props: UseGetFlappingSettingsProps) => {
@ -23,7 +23,7 @@ export const useGetFlappingSettings = (props: UseGetFlappingSettingsProps) => {
return getFlappingSettings({ http });
};
const { data, isFetching, isError, isLoadingError, isLoading } = useQuery({
const { data, isFetching, isError, isLoadingError, isLoading, isInitialLoading } = useQuery({
queryKey: ['getFlappingSettings'],
queryFn,
onSuccess,
@ -33,6 +33,7 @@ export const useGetFlappingSettings = (props: UseGetFlappingSettingsProps) => {
});
return {
isInitialLoading,
isLoading: isLoading || isFetching,
isError: isError || isLoadingError,
data,

View file

@ -37,6 +37,7 @@ import { hasShowActionsCapability } from '../../lib/capabilities';
import RuleAddFooter from './rule_add_footer';
import { HealthContextProvider } from '../../context/health_context';
import { useKibana } from '../../../common/lib/kibana';
import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '../../../common/constants';
import { hasRuleChanged, haveRuleParamsChanged } from './has_rule_changed';
import { getRuleWithInvalidatedFields } from '../../lib/value_validators';
import { DEFAULT_RULE_INTERVAL, MULTI_CONSUMER_RULE_TYPE_IDS } from '../../constants';
@ -253,11 +254,13 @@ const RuleAdd = <
async function onSaveRule(): Promise<Rule | undefined> {
try {
const { flapping, ...restRule } = rule;
const newRule = await createRule({
http,
rule: {
...rule,
...restRule,
...(selectableConsumer && selectedConsumer ? { consumer: selectedConsumer } : {}),
...(IS_RULE_SPECIFIC_FLAPPING_ENABLED ? { flapping } : {}),
} as CreateRuleBody,
});
toasts.addSuccess(

View file

@ -30,6 +30,7 @@ import { toMountPoint } from '@kbn/react-kibana-mount';
import { parseRuleCircuitBreakerErrorMessage } from '@kbn/alerting-plugin/common';
import { updateRule } from '@kbn/alerts-ui-shared/src/common/apis/update_rule';
import { fetchUiConfig as triggersActionsUiConfig } from '@kbn/alerts-ui-shared/src/common/apis/fetch_ui_config';
import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '../../../common/constants';
import {
Rule,
RuleFlyoutCloseReason,
@ -204,7 +205,15 @@ export const RuleEdit = <
isValidRule(rule, ruleErrors, ruleActionsErrors) &&
!hasActionsWithBrokenConnector
) {
const newRule = await updateRule({ http, rule, id: rule.id });
const { flapping, ...restRule } = rule;
const newRule = await updateRule({
http,
rule: {
...restRule,
...(IS_RULE_SPECIFIC_FLAPPING_ENABLED ? { flapping } : {}),
},
id: rule.id,
});
toasts.addSuccess(
i18n.translate('xpack.triggersActionsUI.sections.ruleEdit.saveSuccessNotificationText', {
defaultMessage: "Updated ''{ruleName}''",

View file

@ -26,9 +26,19 @@ import {
} from '../../../types';
import { RuleForm } from './rule_form';
import { coreMock } from '@kbn/core/public/mocks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ALERTING_FEATURE_ID, RecoveredActionGroup } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../../common/lib/kibana';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const toMapById = [
(acc: Map<unknown, unknown>, val: { id: unknown }) => acc.set(val.id, val),
new Map(),
@ -225,19 +235,21 @@ describe('rule_form', () => {
} as unknown as Rule;
wrapper = mountWithIntl(
<RuleForm
rule={initialRule}
config={{
isUsingSecurity: true,
minimumScheduleInterval: { value: '1m', enforce: enforceMinimum },
}}
dispatch={() => {}}
errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }}
operation="create"
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
onChangeMetaData={jest.fn()}
/>
<QueryClientProvider client={queryClient}>
<RuleForm
rule={initialRule}
config={{
isUsingSecurity: true,
minimumScheduleInterval: { value: '1m', enforce: enforceMinimum },
}}
dispatch={() => {}}
errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }}
operation="create"
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
onChangeMetaData={jest.fn()}
/>
</QueryClientProvider>
);
await act(async () => {
@ -381,28 +393,30 @@ describe('rule_form', () => {
} as unknown as Rule;
wrapper = mountWithIntl(
<RuleForm
canShowConsumerSelection
rule={{
...initialRule,
...initialRuleOverwrite,
}}
config={{
isUsingSecurity: true,
minimumScheduleInterval: { value: '1m', enforce: enforceMinimum },
}}
dispatch={() => {}}
errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }}
operation="create"
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
connectorFeatureId={featureId}
onChangeMetaData={jest.fn()}
validConsumers={validConsumers}
setConsumer={mockSetConsumer}
useRuleProducer={useRuleProducer}
selectedConsumer={selectedConsumer}
/>
<QueryClientProvider client={queryClient}>
<RuleForm
canShowConsumerSelection
rule={{
...initialRule,
...initialRuleOverwrite,
}}
config={{
isUsingSecurity: true,
minimumScheduleInterval: { value: '1m', enforce: enforceMinimum },
}}
dispatch={() => {}}
errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }}
operation="create"
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
connectorFeatureId={featureId}
onChangeMetaData={jest.fn()}
validConsumers={validConsumers}
setConsumer={mockSetConsumer}
useRuleProducer={useRuleProducer}
selectedConsumer={selectedConsumer}
/>
</QueryClientProvider>
);
await act(async () => {
@ -1105,19 +1119,21 @@ describe('rule_form', () => {
} as unknown as Rule;
wrapper = mountWithIntl(
<RuleForm
rule={initialRule}
config={{
isUsingSecurity: true,
minimumScheduleInterval: { value: '1m', enforce: false },
}}
dispatch={() => {}}
errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }}
operation="create"
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
onChangeMetaData={jest.fn()}
/>
<QueryClientProvider client={queryClient}>
<RuleForm
rule={initialRule}
config={{
isUsingSecurity: true,
minimumScheduleInterval: { value: '1m', enforce: false },
}}
dispatch={() => {}}
errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }}
operation="create"
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
onChangeMetaData={jest.fn()}
/>
</QueryClientProvider>
);
await act(async () => {
@ -1164,19 +1180,21 @@ describe('rule_form', () => {
} as unknown as Rule;
wrapper = mountWithIntl(
<RuleForm
rule={initialRule}
config={{
isUsingSecurity: true,
minimumScheduleInterval: { value: '1m', enforce: false },
}}
dispatch={() => {}}
errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }}
operation="create"
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
onChangeMetaData={jest.fn()}
/>
<QueryClientProvider client={queryClient}>
<RuleForm
rule={initialRule}
config={{
isUsingSecurity: true,
minimumScheduleInterval: { value: '1m', enforce: false },
}}
dispatch={() => {}}
errors={{ name: [], 'schedule.interval': [], ruleTypeId: [], actionConnectors: [] }}
operation="create"
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
onChangeMetaData={jest.fn()}
/>
</QueryClientProvider>
);
await act(async () => {

View file

@ -62,6 +62,7 @@ import {
isActionGroupDisabledForActionTypeId,
RuleActionAlertsFilterProperty,
RuleActionKey,
RuleSpecificFlappingProperties,
} from '@kbn/alerting-plugin/common';
import { AlertingConnectorFeatureId } from '@kbn/actions-plugin/common';
import { AlertConsumers } from '@kbn/rule-data-utils';
@ -91,12 +92,16 @@ import {
ruleTypeGroupCompare,
ruleTypeUngroupedCompare,
} from '../../lib/rule_type_compare';
import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants';
import {
IS_RULE_SPECIFIC_FLAPPING_ENABLED,
VIEW_LICENSE_OPTIONS_LINK,
} from '../../../common/constants';
import { MULTI_CONSUMER_RULE_TYPE_IDS } from '../../constants';
import { SectionLoading } from '../../components/section_loading';
import { RuleFormConsumerSelection, VALID_CONSUMERS } from './rule_form_consumer_selection';
import { getInitialInterval } from './get_initial_interval';
import { useLoadRuleTypesQuery } from '../../hooks/use_load_rule_types_query';
import { RuleFormAdvancedOptions } from './rule_form_advanced_options';
const ENTER_KEY = 13;
@ -410,6 +415,16 @@ export const RuleForm = ({
dispatch({ command: { type: 'setAlertDelayProperty' }, payload: { key, value } });
};
const setFlapping = (flapping: RuleSpecificFlappingProperties | null) => {
dispatch({ command: { type: 'setProperty' }, payload: { key: 'flapping', value: flapping } });
};
const onAlertDelayChange = (value: string) => {
const parsedValue = value === '' ? '' : parseInt(value, 10);
setAlertDelayProperty('active', parsedValue || 1);
setAlertDelay(parsedValue || undefined);
};
useEffect(() => {
const searchValue = searchText ? searchText.trim().toLocaleLowerCase() : null;
setFilteredRuleTypes(
@ -617,20 +632,6 @@ export const RuleForm = ({
</Fragment>
));
const labelForRuleChecked = [
i18n.translate('xpack.triggersActionsUI.sections.ruleForm.checkFieldLabel', {
defaultMessage: 'Check every',
}),
<EuiIconTip
position="right"
type="questionInCircle"
content={i18n.translate('xpack.triggersActionsUI.sections.ruleForm.checkWithTooltip', {
defaultMessage:
'Define how often to evaluate the condition. Checks are queued; they run as close to the defined value as capacity allows.',
})}
/>,
];
const getHelpTextForInterval = () => {
if (!config || !config.minimumScheduleInterval) {
return '';
@ -797,6 +798,27 @@ export const RuleForm = ({
<EuiFlexItem>
<EuiFormRow
fullWidth
label={
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem>
{i18n.translate('xpack.triggersActionsUI.sections.ruleForm.ruleScheduleLabel', {
defaultMessage: 'Rule schedule',
})}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIconTip
content={i18n.translate(
'xpack.triggersActionsUI.sections.ruleForm.checkWithTooltip',
{
defaultMessage:
'Define how often to evaluate the condition. Checks are queued; they run as close to the defined value as capacity allows.',
}
)}
position="top"
/>
</EuiFlexItem>
</EuiFlexGroup>
}
data-test-subj="intervalFormRow"
display="rowCompressed"
helpText={getHelpTextForInterval()}
@ -806,7 +828,12 @@ export const RuleForm = ({
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={2}>
<EuiFieldNumber
prepend={labelForRuleChecked}
prepend={i18n.translate(
'xpack.triggersActionsUI.sections.ruleForm.checkFieldLabel',
{
defaultMessage: 'Check every',
}
)}
fullWidth
min={1}
isInvalid={!!errors['schedule.interval'].length}
@ -855,45 +882,14 @@ export const RuleForm = ({
</EuiText>
}
>
<EuiSpacer size="s" />
<EuiFormRow fullWidth data-test-subj="alertDelayFormRow" display="rowCompressed">
<EuiFieldNumber
fullWidth
min={1}
value={alertDelay || ''}
name="alertDelay"
data-test-subj="alertDelayInput"
prepend={[
i18n.translate('xpack.triggersActionsUI.sections.ruleForm.alertDelayFieldLabel', {
defaultMessage: 'Alert after',
}),
<EuiIconTip
position="right"
type="questionInCircle"
content={
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleForm.alertDelayFieldHelp"
defaultMessage="An alert occurs only when the specified number of consecutive runs meet the rule conditions."
/>
}
/>,
]}
append={i18n.translate(
'xpack.triggersActionsUI.sections.ruleForm.alertDelayFieldAppendLabel',
{
defaultMessage: 'consecutive matches',
}
)}
onChange={(e) => {
const value = e.target.value;
if (value === '' || INTEGER_REGEX.test(value)) {
const parsedValue = value === '' ? '' : parseInt(value, 10);
setAlertDelayProperty('active', parsedValue || 1);
setAlertDelay(parsedValue || undefined);
}
}}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<RuleFormAdvancedOptions
alertDelay={alertDelay}
flappingSettings={rule.flapping}
onAlertDelayChange={onAlertDelayChange}
onFlappingChange={setFlapping}
enabledFlapping={IS_RULE_SPECIFIC_FLAPPING_ENABLED}
/>
</EuiAccordion>
</EuiFlexItem>
{shouldShowConsumerSelect && (

View file

@ -0,0 +1,222 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpServiceMock } from '@kbn/core/public/mocks';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { RuleFormAdvancedOptions } from './rule_form_advanced_options';
import { useKibana } from '../../../common/lib/kibana';
import userEvent from '@testing-library/user-event';
jest.mock('../../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const queryClient = new QueryClient();
const http = httpServiceMock.createStartContract();
const mockFlappingSettings = {
lookBackWindow: 5,
statusChangeThreshold: 5,
};
const mockOnflappingChange = jest.fn();
const mockAlertDelayChange = jest.fn();
describe('ruleFormAdvancedOptions', () => {
beforeEach(() => {
http.get.mockResolvedValue({
look_back_window: 10,
status_change_threshold: 3,
enabled: true,
});
useKibanaMock().services.http = http;
});
afterEach(() => {
jest.clearAllMocks();
});
test('should render correctly', async () => {
render(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<RuleFormAdvancedOptions
enabledFlapping
alertDelay={5}
flappingSettings={mockFlappingSettings}
onAlertDelayChange={mockAlertDelayChange}
onFlappingChange={mockOnflappingChange}
/>
</QueryClientProvider>
</IntlProvider>
);
expect(await screen.findByTestId('ruleFormAdvancedOptions')).toBeInTheDocument();
expect(await screen.findByTestId('alertDelayFormRow')).toBeInTheDocument();
expect(await screen.findByTestId('alertFlappingFormRow')).toBeInTheDocument();
});
test('should initialize correctly when global flapping is on and override is not applied', async () => {
render(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<RuleFormAdvancedOptions
enabledFlapping
alertDelay={5}
onAlertDelayChange={mockAlertDelayChange}
onFlappingChange={mockOnflappingChange}
/>
</QueryClientProvider>
</IntlProvider>
);
expect(await screen.findByText('ON')).toBeInTheDocument();
expect(screen.getByTestId('ruleFormAdvancedOptionsOverrideSwitch')).not.toBeChecked();
expect(screen.queryByText('Override')).not.toBeInTheDocument();
expect(screen.getByTestId('ruleSettingsFlappingMessage')).toHaveTextContent(
'An alert is flapping if it changes status at least 3 times in the last 10 rule runs.'
);
userEvent.click(screen.getByTestId('ruleFormAdvancedOptionsOverrideSwitch'));
expect(mockOnflappingChange).toHaveBeenCalledWith({
lookBackWindow: 10,
statusChangeThreshold: 3,
});
});
test('should initialize correctly when global flapping is on and override is appplied', async () => {
render(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<RuleFormAdvancedOptions
enabledFlapping
alertDelay={5}
flappingSettings={{
lookBackWindow: 6,
statusChangeThreshold: 4,
}}
onAlertDelayChange={mockAlertDelayChange}
onFlappingChange={mockOnflappingChange}
/>
</QueryClientProvider>
</IntlProvider>
);
expect(await screen.findByTestId('ruleFormAdvancedOptionsOverrideSwitch')).toBeChecked();
expect(screen.getByText('Override')).toBeInTheDocument();
expect(screen.getByTestId('lookBackWindowRangeInput')).toHaveValue('6');
expect(screen.getByTestId('statusChangeThresholdRangeInput')).toHaveValue('4');
expect(screen.getByTestId('ruleSettingsFlappingMessage')).toHaveTextContent(
'An alert is flapping if it changes status at least 4 times in the last 6 rule runs.'
);
userEvent.click(screen.getByTestId('ruleFormAdvancedOptionsOverrideSwitch'));
expect(mockOnflappingChange).toHaveBeenCalledWith(null);
});
test('should not allow override when global flapping is off', async () => {
http.get.mockResolvedValue({
look_back_window: 10,
status_change_threshold: 3,
enabled: false,
});
useKibanaMock().services.http = http;
render(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<RuleFormAdvancedOptions
enabledFlapping
alertDelay={5}
flappingSettings={{
lookBackWindow: 6,
statusChangeThreshold: 4,
}}
onAlertDelayChange={mockAlertDelayChange}
onFlappingChange={mockOnflappingChange}
/>
</QueryClientProvider>
</IntlProvider>
);
expect(await screen.findByText('OFF')).toBeInTheDocument();
expect(screen.queryByText('Override')).not.toBeInTheDocument();
expect(screen.queryByTestId('ruleFormAdvancedOptionsOverrideSwitch')).not.toBeInTheDocument();
expect(screen.queryByTestId('ruleSettingsFlappingMessage')).not.toBeInTheDocument();
});
test('should allow for flapping inputs to be modified', async () => {
render(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<RuleFormAdvancedOptions
enabledFlapping
alertDelay={5}
flappingSettings={{
lookBackWindow: 10,
statusChangeThreshold: 10,
}}
onAlertDelayChange={mockAlertDelayChange}
onFlappingChange={mockOnflappingChange}
/>
</QueryClientProvider>
</IntlProvider>
);
expect(await screen.findByTestId('lookBackWindowRangeInput')).toBeInTheDocument();
const lookBackWindowInput = screen.getByTestId('lookBackWindowRangeInput');
const statusChangeThresholdInput = screen.getByTestId('statusChangeThresholdRangeInput');
// Change lookBackWindow to a smaller value
fireEvent.change(lookBackWindowInput, { target: { value: 5 } });
// statusChangeThresholdInput gets pinned to be 5
expect(mockOnflappingChange).toHaveBeenLastCalledWith({
lookBackWindow: 5,
statusChangeThreshold: 5,
});
// Try making statusChangeThreshold bigger
fireEvent.change(statusChangeThresholdInput, { target: { value: 20 } });
// Still pinned
expect(mockOnflappingChange).toHaveBeenLastCalledWith({
lookBackWindow: 10,
statusChangeThreshold: 10,
});
fireEvent.change(statusChangeThresholdInput, { target: { value: 3 } });
expect(mockOnflappingChange).toHaveBeenLastCalledWith({
lookBackWindow: 10,
statusChangeThreshold: 3,
});
});
test('should not render flapping if enableFlapping is false', () => {
render(
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<RuleFormAdvancedOptions
enabledFlapping={false}
alertDelay={5}
flappingSettings={{
lookBackWindow: 10,
statusChangeThreshold: 10,
}}
onAlertDelayChange={mockAlertDelayChange}
onFlappingChange={mockOnflappingChange}
/>
</QueryClientProvider>
</IntlProvider>
);
expect(screen.queryByTestId('alertFlappingFormRow')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,360 @@
/*
* 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, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiBadge,
EuiFieldNumber,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
EuiPanel,
EuiSwitch,
EuiText,
useIsWithinMinBreakpoint,
useEuiTheme,
EuiHorizontalRule,
EuiSpacer,
EuiSplitPanel,
EuiLoadingSpinner,
EuiLink,
} from '@elastic/eui';
import { RuleSettingsFlappingInputs } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_flapping_inputs';
import { RuleSettingsFlappingMessage } from '@kbn/alerts-ui-shared/src/rule_settings/rule_settings_flapping_message';
import { RuleSpecificFlappingProperties } from '@kbn/alerting-plugin/common';
import { useGetFlappingSettings } from '../../hooks/use_get_flapping_settings';
const alertDelayFormRowLabel = i18n.translate(
'xpack.triggersActionsUI.sections.ruleForm.alertDelayLabel',
{
defaultMessage: 'Alert delay',
}
);
const alertDelayIconTipDescription = i18n.translate(
'xpack.triggersActionsUI.sections.ruleForm.alertDelayFieldHelp',
{
defaultMessage:
'An alert occurs only when the specified number of consecutive runs meet the rule conditions.',
}
);
const alertDelayPrependLabel = i18n.translate(
'xpack.triggersActionsUI.sections.ruleForm.alertDelayFieldLabel',
{
defaultMessage: 'Alert after',
}
);
const alertDelayAppendLabel = i18n.translate(
'xpack.triggersActionsUI.sections.ruleForm.alertDelayFieldAppendLabel',
{
defaultMessage: 'consecutive matches',
}
);
const flappingLabel = i18n.translate(
'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingLabel',
{
defaultMessage: 'Flapping Detection',
}
);
const flappingOnLabel = i18n.translate('xpack.triggersActionsUI.ruleFormAdvancedOptions.onLabel', {
defaultMessage: 'ON',
});
const flappingOffLabel = i18n.translate(
'xpack.triggersActionsUI.ruleFormAdvancedOptions.offLabel',
{
defaultMessage: 'OFF',
}
);
const flappingOverrideLabel = i18n.translate(
'xpack.triggersActionsUI.ruleFormAdvancedOptions.overrideLabel',
{
defaultMessage: 'Override',
}
);
const flappingOverrideConfiguration = i18n.translate(
'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingOverrideConfiguration',
{
defaultMessage: 'Override Configuration',
}
);
const flappingExternalLinkLabel = i18n.translate(
'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingExternalLinkLabel',
{
defaultMessage: "What's this?",
}
);
const flappingFormRowLabel = i18n.translate(
'xpack.triggersActionsUI.sections.ruleForm.flappingLabel',
{
defaultMessage: 'Alert flapping detection',
}
);
const flappingIconTipDescription = i18n.translate(
'xpack.triggersActionsUI.ruleFormAdvancedOptions.flappingIconTipDescription',
{
defaultMessage:
'Detect alerts that switch quickly between active and recovered states and reduce unwanted noise for these flapping alerts.',
}
);
const clampFlappingValues = (flapping: RuleSpecificFlappingProperties) => {
return {
...flapping,
statusChangeThreshold: Math.min(flapping.lookBackWindow, flapping.statusChangeThreshold),
};
};
const INTEGER_REGEX = /^[1-9][0-9]*$/;
export interface RuleFormAdvancedOptionsProps {
alertDelay?: number;
flappingSettings?: RuleSpecificFlappingProperties;
onAlertDelayChange: (value: string) => void;
onFlappingChange: (value: RuleSpecificFlappingProperties | null) => void;
enabledFlapping?: boolean;
}
export const RuleFormAdvancedOptions = (props: RuleFormAdvancedOptionsProps) => {
const {
alertDelay,
flappingSettings,
enabledFlapping = true,
onAlertDelayChange,
onFlappingChange,
} = props;
const cachedFlappingSettings = useRef<RuleSpecificFlappingProperties>();
const isDesktop = useIsWithinMinBreakpoint('xl');
const { euiTheme } = useEuiTheme();
const { data: spaceFlappingSettings, isInitialLoading } = useGetFlappingSettings({
enabled: enabledFlapping,
});
const internalOnAlertDelayChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value === '' || INTEGER_REGEX.test(value)) {
onAlertDelayChange(value);
}
},
[onAlertDelayChange]
);
const internalOnFlappingChange = useCallback(
(flapping: RuleSpecificFlappingProperties) => {
const clampedValue = clampFlappingValues(flapping);
onFlappingChange(clampedValue);
cachedFlappingSettings.current = clampedValue;
},
[onFlappingChange]
);
const onLookBackWindowChange = useCallback(
(value: number) => {
if (!flappingSettings) {
return;
}
internalOnFlappingChange({
...flappingSettings,
lookBackWindow: value,
});
},
[flappingSettings, internalOnFlappingChange]
);
const onStatusChangeThresholdChange = useCallback(
(value: number) => {
if (!flappingSettings) {
return;
}
internalOnFlappingChange({
...flappingSettings,
statusChangeThreshold: value,
});
},
[flappingSettings, internalOnFlappingChange]
);
const onFlappingToggle = useCallback(() => {
if (!spaceFlappingSettings) {
return;
}
if (flappingSettings) {
cachedFlappingSettings.current = flappingSettings;
return onFlappingChange(null);
}
const initialFlappingSettings = cachedFlappingSettings.current || spaceFlappingSettings;
onFlappingChange({
lookBackWindow: initialFlappingSettings.lookBackWindow,
statusChangeThreshold: initialFlappingSettings.statusChangeThreshold,
});
}, [spaceFlappingSettings, flappingSettings, onFlappingChange]);
const flappingFormHeader = useMemo(() => {
if (!spaceFlappingSettings) {
return null;
}
const { enabled } = spaceFlappingSettings;
return (
<EuiFlexItem>
<EuiFlexGroup
gutterSize="s"
direction={isDesktop ? 'row' : 'column'}
alignItems={isDesktop ? 'center' : undefined}
>
<EuiFlexItem style={{ flexDirection: 'row' }}>
<EuiText size="s" style={{ marginRight: euiTheme.size.xs }}>
{flappingLabel}
</EuiText>
<EuiBadge color={enabled ? 'success' : 'default'}>
{enabled ? flappingOnLabel : flappingOffLabel}
</EuiBadge>
{flappingSettings && enabled && (
<EuiBadge color="primary">{flappingOverrideLabel}</EuiBadge>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{enabled && (
<EuiSwitch
data-test-subj="ruleFormAdvancedOptionsOverrideSwitch"
compressed
checked={!!flappingSettings}
label={flappingOverrideConfiguration}
onChange={onFlappingToggle}
/>
)}
{!enabled && (
// TODO: Add the help link here
<EuiLink href="" target="_blank">
{flappingExternalLinkLabel}
</EuiLink>
)}
</EuiFlexItem>
</EuiFlexGroup>
{flappingSettings && (
<>
<EuiSpacer size="m" />
<EuiHorizontalRule margin="none" />
</>
)}
</EuiFlexItem>
);
}, [isDesktop, euiTheme, spaceFlappingSettings, flappingSettings, onFlappingToggle]);
const flappingFormBody = useMemo(() => {
if (!flappingSettings) {
return null;
}
return (
<EuiFlexItem>
<RuleSettingsFlappingInputs
lookBackWindow={flappingSettings.lookBackWindow}
statusChangeThreshold={flappingSettings.statusChangeThreshold}
onLookBackWindowChange={onLookBackWindowChange}
onStatusChangeThresholdChange={onStatusChangeThresholdChange}
/>
</EuiFlexItem>
);
}, [flappingSettings, onLookBackWindowChange, onStatusChangeThresholdChange]);
const flappingFormMessage = useMemo(() => {
if (!spaceFlappingSettings || !spaceFlappingSettings.enabled) {
return null;
}
const settingsToUse = flappingSettings || spaceFlappingSettings;
return (
<EuiSplitPanel.Inner
color="subdued"
style={{
borderTop: euiTheme.border.thin,
}}
>
<RuleSettingsFlappingMessage
lookBackWindow={settingsToUse.lookBackWindow}
statusChangeThreshold={settingsToUse.statusChangeThreshold}
/>
</EuiSplitPanel.Inner>
);
}, [spaceFlappingSettings, flappingSettings, euiTheme]);
return (
<EuiPanel color="subdued" hasShadow={false} data-test-subj="ruleFormAdvancedOptions">
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiFormRow
fullWidth
label={
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem>{alertDelayFormRowLabel}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIconTip content={alertDelayIconTipDescription} position="top" />
</EuiFlexItem>
</EuiFlexGroup>
}
data-test-subj="alertDelayFormRow"
display="rowCompressed"
>
<EuiFieldNumber
fullWidth
min={1}
value={alertDelay || ''}
name="alertDelay"
data-test-subj="alertDelayInput"
prepend={alertDelayPrependLabel}
append={alertDelayAppendLabel}
onChange={internalOnAlertDelayChange}
/>
</EuiFormRow>
</EuiFlexItem>
{isInitialLoading && <EuiLoadingSpinner />}
{spaceFlappingSettings && enabledFlapping && (
<EuiFlexItem grow={false}>
<EuiFormRow
fullWidth
label={
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem>{flappingFormRowLabel}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIconTip content={flappingIconTipDescription} position="top" />
</EuiFlexItem>
</EuiFlexGroup>
}
data-test-subj="alertFlappingFormRow"
display="rowCompressed"
>
<EuiSplitPanel.Outer hasBorder>
<EuiSplitPanel.Inner>
<EuiFlexGroup direction="column">
{flappingFormHeader}
{flappingFormBody}
</EuiFlexGroup>
</EuiSplitPanel.Inner>
{flappingFormMessage}
</EuiSplitPanel.Outer>
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -26,6 +26,9 @@ export {
I18N_WEEKDAY_OPTIONS_DDD,
} from '@kbn/alerts-ui-shared/src/common/constants/i18n_weekdays';
// Feature flag for frontend rule specific flapping in rule flyout
export const IS_RULE_SPECIFIC_FLAPPING_ENABLED = false;
export const builtInComparators: { [key: string]: Comparator } = {
[COMPARATORS.GREATER_THAN]: {
text: i18n.translate('xpack.triggersActionsUI.common.constants.comparators.isAboveLabel', {