[ResponseOps][FE] Alert creation delay based on user definition (#176346)

Resolves https://github.com/elastic/kibana/issues/173009

## Summary

Adds a new input for the user to define the `alertDelay`. This input is
available for life-cycled alerts (stack and o11y) rule types.

### Checklist

Delete any items that are not applicable to this PR.

- [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)
- [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


### To verify

- Using the UI create a rule with the `alertDelay` field set.
- Verify that the field is saved properly and that you can edit the
`alertDelay`
- Verify that you can add the alert delay to existing rules. Create a
rule in a different branch and switch to this one. Edit the rule and set
the `alertDelay`. Verify that the rule saves and works as expected.

---------

Co-authored-by: Lisa Cawley <lcawley@elastic.co>
This commit is contained in:
Alexi Doak 2024-02-15 09:13:06 -08:00 committed by GitHub
parent 0d787a01f3
commit 68d6ab2135
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 232 additions and 51 deletions

View file

@ -62,6 +62,9 @@ const sampleRule: SanitizedRule<RuleTypeParams> & { activeSnoozes?: string[] } =
},
nextRun: DATE_2020,
revision: 0,
alertDelay: {
active: 10,
},
};
describe('rewriteRule', () => {

View file

@ -37,6 +37,7 @@ export const rewriteRule = ({
activeSnoozes,
lastRun,
nextRun,
alertDelay,
...rest
}: SanitizedRule<RuleTypeParams> & { activeSnoozes?: string[] }) => ({
...rest,
@ -78,4 +79,5 @@ export const rewriteRule = ({
...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}),
...(nextRun ? { next_run: nextRun } : {}),
...(apiKeyCreatedByUser !== undefined ? { api_key_created_by_user: apiKeyCreatedByUser } : {}),
...(alertDelay !== undefined ? { alert_delay: alertDelay } : {}),
});

View file

@ -59,6 +59,9 @@ describe('updateRuleRoute', () => {
},
],
notifyWhen: RuleNotifyWhen.CHANGE,
alertDelay: {
active: 10,
},
};
const updateRequest: AsApiContract<UpdateOptions<{ otherField: boolean }>['data']> = {
@ -73,6 +76,9 @@ describe('updateRuleRoute', () => {
alerts_filter: mockedAlert.actions[0].alertsFilter,
},
],
alert_delay: {
active: 10,
},
};
const updateResult: AsApiContract<PartialRule<{ otherField: boolean }>> = {
@ -86,6 +92,7 @@ describe('updateRuleRoute', () => {
connector_type_id: actionTypeId,
alerts_filter: alertsFilter,
})),
alert_delay: mockedAlert.alertDelay,
};
it('updates a rule with proper parameters', async () => {
@ -135,6 +142,9 @@ describe('updateRuleRoute', () => {
"uuid": "1234-5678",
},
],
"alertDelay": Object {
"active": 10,
},
"name": "abc",
"notifyWhen": "onActionGroupChange",
"params": Object {

View file

@ -52,16 +52,22 @@ const bodySchema = schema.object({
)
)
),
alert_delay: schema.maybe(
schema.object({
active: schema.number(),
})
),
});
const rewriteBodyReq: RewriteRequestCase<UpdateOptions<RuleTypeParams>> = (result) => {
const { notify_when: notifyWhen, actions, ...rest } = result.data;
const { notify_when: notifyWhen, alert_delay: alertDelay, actions, ...rest } = result.data;
return {
...result,
data: {
...rest,
notifyWhen,
actions: rewriteActionsReq(actions),
alertDelay,
},
};
};
@ -83,6 +89,7 @@ const rewriteBodyRes: RewriteResponseCase<PartialRule<RuleTypeParams>> = ({
isSnoozedUntil,
lastRun,
nextRun,
alertDelay,
...rest
}) => ({
...rest,
@ -115,6 +122,7 @@ const rewriteBodyRes: RewriteResponseCase<PartialRule<RuleTypeParams>> = ({
...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}),
...(nextRun ? { next_run: nextRun } : {}),
...(apiKeyCreatedByUser !== undefined ? { api_key_created_by_user: apiKeyCreatedByUser } : {}),
...(alertDelay ? { alert_delay: alertDelay } : {}),
});
export const updateRuleRoute = (

View file

@ -17,7 +17,7 @@ import {
} from '../../types';
import { validateRuleTypeParams, getRuleNotifyWhenType } from '../../lib';
import { WriteOperations, AlertingAuthorizationEntity } from '../../authorization';
import { parseDuration, getRuleCircuitBreakerErrorMessage } from '../../../common';
import { parseDuration, getRuleCircuitBreakerErrorMessage, AlertDelay } from '../../../common';
import { retryIfConflicts } from '../../lib/retry_if_conflicts';
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
import { ruleAuditEvent, RuleAuditAction } from '../common/audit_events';
@ -51,6 +51,7 @@ export interface UpdateOptions<Params extends RuleTypeParams> {
params: Params;
throttle?: string | null;
notifyWhen?: RuleNotifyWhenType | null;
alertDelay?: AlertDelay;
};
allowMissingConnectorSecrets?: boolean;
shouldIncrementRevision?: ShouldIncrementRevision;

View file

@ -279,6 +279,9 @@ describe('update()', () => {
scheduledTaskId: 'task-123',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
alertDelay: {
active: 5,
},
},
references: [
{
@ -334,6 +337,9 @@ describe('update()', () => {
},
},
],
alertDelay: {
active: 10,
},
},
});
expect(result).toMatchInlineSnapshot(`
@ -364,6 +370,9 @@ describe('update()', () => {
},
},
],
"alertDelay": Object {
"active": 5,
},
"createdAt": 2019-02-12T21:01:22.479Z,
"enabled": true,
"id": "1",
@ -422,6 +431,9 @@ describe('update()', () => {
"uuid": "102",
},
],
"alertDelay": Object {
"active": 10,
},
"alertTypeId": "myType",
"apiKey": null,
"apiKeyCreatedByUser": null,

View file

@ -77,6 +77,7 @@ export const transformRule: RewriteRequestCase<Rule> = ({
active_snoozes: activeSnoozes,
last_run: lastRun,
next_run: nextRun,
alert_delay: alertDelay,
...rest
}: any) => ({
ruleTypeId,
@ -99,6 +100,7 @@ export const transformRule: RewriteRequestCase<Rule> = ({
...(lastRun ? { lastRun: transformLastRun(lastRun) } : {}),
...(nextRun ? { nextRun } : {}),
...(apiKeyCreatedByUser !== undefined ? { apiKeyCreatedByUser } : {}),
...(alertDelay ? { alertDelay } : {}),
...rest,
});

View file

@ -52,6 +52,9 @@ describe('createRule', () => {
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',
alert_delay: {
active: 10,
},
};
const ruleToCreate: Omit<
RuleUpdates,
@ -96,6 +99,9 @@ describe('createRule', () => {
updatedAt: new Date('2021-04-01T21:33:13.247Z'),
apiKeyOwner: '',
revision: 0,
alertDelay: {
active: 10,
},
};
http.post.mockResolvedValueOnce(resolvedValue);
@ -148,6 +154,9 @@ describe('createRule', () => {
tags: [],
updatedAt: '2021-04-01T21:33:13.247Z',
updatedBy: undefined,
alertDelay: {
active: 10,
},
});
});
});

View file

@ -23,6 +23,7 @@ type RuleCreateBody = Omit<
const rewriteBodyRequest: RewriteResponseCase<RuleCreateBody> = ({
ruleTypeId,
actions,
alertDelay,
...res
}): any => ({
...res,
@ -43,6 +44,7 @@ const rewriteBodyRequest: RewriteResponseCase<RuleCreateBody> = ({
: {}),
})
),
...(alertDelay ? { alert_delay: alertDelay } : {}),
});
export async function createRule({

View file

@ -27,6 +27,9 @@ describe('updateRule', () => {
apiKey: null,
apiKeyOwner: null,
revision: 0,
alertDelay: {
active: 10,
},
};
const resolvedValue: Rule = {
...ruleToUpdate,
@ -51,7 +54,7 @@ describe('updateRule', () => {
Array [
"/api/alerting/rule/12%2F3",
Object {
"body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[]}",
"body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[],\\"alert_delay\\":{\\"active\\":10}}",
},
]
`);

View file

@ -13,9 +13,13 @@ import { transformRule } from './common_transformations';
type RuleUpdatesBody = Pick<
RuleUpdates,
'name' | 'tags' | 'schedule' | 'actions' | 'params' | 'throttle' | 'notifyWhen'
'name' | 'tags' | 'schedule' | 'actions' | 'params' | 'throttle' | 'notifyWhen' | 'alertDelay'
>;
const rewriteBodyRequest: RewriteResponseCase<RuleUpdatesBody> = ({ actions, ...res }): any => ({
const rewriteBodyRequest: RewriteResponseCase<RuleUpdatesBody> = ({
actions,
alertDelay,
...res
}): any => ({
...res,
actions: actions.map(
({ group, id, params, frequency, uuid, alertsFilter, useAlertDataForTemplate }) => ({
@ -34,6 +38,7 @@ const rewriteBodyRequest: RewriteResponseCase<RuleUpdatesBody> = ({ actions, ...
...(uuid && { uuid }),
})
),
...(alertDelay ? { alert_delay: alertDelay } : {}),
});
export async function updateRule({
@ -42,14 +47,16 @@ export async function updateRule({
id,
}: {
http: HttpSetup;
rule: Pick<RuleUpdates, 'name' | 'tags' | 'schedule' | 'params' | 'actions'>;
rule: Pick<RuleUpdates, 'name' | 'tags' | 'schedule' | 'params' | 'actions' | 'alertDelay'>;
id: string;
}): Promise<Rule> {
const res = await http.put<AsApiContract<Rule>>(
`${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}`,
{
body: JSON.stringify(
rewriteBodyRequest(pick(rule, ['name', 'tags', 'schedule', 'params', 'actions']))
rewriteBodyRequest(
pick(rule, ['name', 'tags', 'schedule', 'params', 'actions', 'alertDelay'])
)
),
}
);

View file

@ -375,6 +375,9 @@ describe('rule_form', () => {
enabled: false,
mutedInstanceIds: [],
...(!showRulesList ? { ruleTypeId: ruleType.id } : {}),
alertDelay: {
active: 1,
},
} as unknown as Rule;
wrapper = mountWithIntl(
@ -1034,6 +1037,24 @@ describe('rule_form', () => {
expect(wrapper.find(ActionForm).props().hasFieldsForAAD).toEqual(true);
});
it('renders rule alert delay', async () => {
const getAlertDelayInput = () => {
return wrapper.find('[data-test-subj="alertDelayInput"] input').first();
};
await setup();
expect(getAlertDelayInput().props().value).toEqual(1);
getAlertDelayInput().simulate('change', { target: { value: '2' } });
expect(getAlertDelayInput().props().value).toEqual(2);
getAlertDelayInput().simulate('change', { target: { value: '20' } });
expect(getAlertDelayInput().props().value).toEqual(20);
getAlertDelayInput().simulate('change', { target: { value: '999' } });
expect(getAlertDelayInput().props().value).toEqual(999);
});
});
describe('rule_form create rule non ruleing consumer and producer', () => {

View file

@ -215,6 +215,7 @@ export const RuleForm = ({
? getDurationUnitValue(rule.schedule.interval)
: defaultScheduleIntervalUnit
);
const [alertDelay, setAlertDelay] = useState<number | undefined>(rule.alertDelay?.active ?? 1);
const [defaultActionGroupId, setDefaultActionGroupId] = useState<string | undefined>(undefined);
const [availableRuleTypes, setAvailableRuleTypes] = useState<RuleTypeItems>([]);
@ -328,6 +329,12 @@ export const RuleForm = ({
}
}, [rule.schedule.interval, defaultScheduleInterval, defaultScheduleIntervalUnit]);
useEffect(() => {
if (rule.alertDelay) {
setAlertDelay(rule.alertDelay.active);
}
}, [rule.alertDelay]);
useEffect(() => {
if (!flyoutBodyOverflowRef.current) {
// We're using this as a reliable way to reset the scroll position
@ -393,6 +400,10 @@ export const RuleForm = ({
[dispatch]
);
const setAlertDelayProperty = (key: string, value: any) => {
dispatch({ command: { type: 'setAlertDelayProperty' }, payload: { key, value } });
};
useEffect(() => {
const searchValue = searchText ? searchText.trim().toLocaleLowerCase() : null;
setFilteredRuleTypes(
@ -766,51 +777,95 @@ export const RuleForm = ({
</EuiErrorBoundary>
) : null}
{hideInterval !== true && (
<EuiFlexItem>
<EuiFormRow
fullWidth
data-test-subj="intervalFormRow"
display="rowCompressed"
helpText={getHelpTextForInterval()}
isInvalid={errors['schedule.interval'].length > 0}
error={errors['schedule.interval']}
>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={2}>
<EuiFieldNumber
prepend={labelForRuleChecked}
fullWidth
min={1}
isInvalid={errors['schedule.interval'].length > 0}
value={ruleInterval || ''}
name="interval"
data-test-subj="intervalInput"
onChange={(e) => {
const value = e.target.value;
if (value === '' || INTEGER_REGEX.test(value)) {
const parsedValue = value === '' ? '' : parseInt(value, 10);
setRuleInterval(parsedValue || undefined);
setScheduleProperty('interval', `${parsedValue}${ruleIntervalUnit}`);
}
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={3}>
<EuiSelect
fullWidth
value={ruleIntervalUnit}
options={getTimeOptions(ruleInterval ?? 1)}
onChange={(e) => {
setRuleIntervalUnit(e.target.value);
setScheduleProperty('interval', `${ruleInterval}${e.target.value}`);
}}
data-test-subj="intervalInputUnit"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</EuiFlexItem>
<>
<EuiFlexItem>
<EuiFormRow
fullWidth
data-test-subj="intervalFormRow"
display="rowCompressed"
helpText={getHelpTextForInterval()}
isInvalid={errors['schedule.interval'].length > 0}
error={errors['schedule.interval']}
>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={2}>
<EuiFieldNumber
prepend={labelForRuleChecked}
fullWidth
min={1}
isInvalid={errors['schedule.interval'].length > 0}
value={ruleInterval || ''}
name="interval"
data-test-subj="intervalInput"
onChange={(e) => {
const value = e.target.value;
if (value === '' || INTEGER_REGEX.test(value)) {
const parsedValue = value === '' ? '' : parseInt(value, 10);
setRuleInterval(parsedValue || undefined);
setScheduleProperty('interval', `${parsedValue}${ruleIntervalUnit}`);
}
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={3}>
<EuiSelect
fullWidth
value={ruleIntervalUnit}
options={getTimeOptions(ruleInterval ?? 1)}
onChange={(e) => {
setRuleIntervalUnit(e.target.value);
setScheduleProperty('interval', `${ruleInterval}${e.target.value}`);
}}
data-test-subj="intervalInputUnit"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</EuiFlexItem>
<EuiSpacer size="m" />
</>
)}
<EuiFlexItem>
<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>
</EuiFlexItem>
{shouldShowConsumerSelect && (
<>
<EuiSpacer size="m" />

View file

@ -21,6 +21,9 @@ describe('rule reducer', () => {
actions: [],
tags: [],
notifyWhen: 'onActionGroupChange',
alertDelay: {
active: 5,
},
} as unknown as Rule;
});
@ -211,4 +214,18 @@ describe('rule reducer', () => {
);
expect(updatedRule.rule.actions[0].frequency?.notifyWhen).toBe('onThrottleInterval');
});
test('if initial alert delay property was updated', () => {
const updatedRule = ruleReducer(
{ rule: initialRule },
{
command: { type: 'setAlertDelayProperty' },
payload: {
key: 'active',
value: 10,
},
}
);
expect(updatedRule.rule.alertDelay?.active).toBe(10);
});
});

View file

@ -12,6 +12,7 @@ import {
RuleActionParam,
IntervalSchedule,
RuleActionAlertsFilterProperty,
AlertDelay,
} from '@kbn/alerting-plugin/common';
import { isEmpty } from 'lodash/fp';
import { Rule, RuleAction } from '../../../types';
@ -30,6 +31,7 @@ interface CommandType<
| 'setRuleActionProperty'
| 'setRuleActionFrequency'
| 'setRuleActionAlertsFilter'
| 'setAlertDelayProperty'
> {
type: T;
}
@ -62,6 +64,12 @@ interface RuleSchedulePayload<Key extends keyof IntervalSchedule> {
index?: number;
}
interface AlertDelayPayload<Key extends keyof AlertDelay> {
key: Key;
value: AlertDelay[Key] | null;
index?: number;
}
export type RuleReducerAction =
| {
command: CommandType<'setRule'>;
@ -94,6 +102,10 @@ export type RuleReducerAction =
| {
command: CommandType<'setRuleActionAlertsFilter'>;
payload: Payload<string, RuleActionAlertsFilterProperty>;
}
| {
command: CommandType<'setAlertDelayProperty'>;
payload: AlertDelayPayload<keyof AlertDelay>;
};
export type InitialRuleReducer = Reducer<{ rule: InitialRule }, RuleReducerAction>;
@ -281,5 +293,22 @@ export const ruleReducer = <RulePhase extends InitialRule | Rule>(
};
}
}
case 'setAlertDelayProperty': {
const { key, value } = action.payload as Payload<keyof AlertDelay, SavedObjectAttribute>;
if (rule.alertDelay && isEqual(rule.alertDelay[key], value)) {
return state;
} else {
return {
...state,
rule: {
...rule,
alertDelay: {
...rule.alertDelay,
[key]: value,
},
},
};
}
}
}
};