[RAM] [FE] Add conditional actions UI for timeframe (#153944)

## Summary

Adds the conditional timeframe to the actions UI. **Currently only
enabled in the Security Solution alerts UI using the
`showActionAlertsFilter` prop**

Part of https://github.com/elastic/kibana/issues/152026 and
https://github.com/elastic/kibana/issues/152611

<img width="880" alt="Screenshot 2023-03-29 at 4 15 24 PM"
src="https://user-images.githubusercontent.com/1445834/228567116-a0fa80ac-7664-411f-9757-41aa81b52857.png">

### UPDATED UI
<img width="857" alt="Screenshot 2023-03-31 at 3 58 12 PM"
src="https://user-images.githubusercontent.com/1445834/229140790-11aeea9b-6db9-46bc-8f35-47f9afabb606.png">


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

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Zacqary Adam Xeper 2023-04-11 14:13:54 -05:00 committed by GitHub
parent e469ece932
commit 56796db2c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 574 additions and 35 deletions

View file

@ -29,6 +29,39 @@ export const RuleActionUuid = NonEmptyString;
export type RuleActionParams = t.TypeOf<typeof RuleActionParams>; export type RuleActionParams = t.TypeOf<typeof RuleActionParams>;
export const RuleActionParams = saved_object_attributes; export const RuleActionParams = saved_object_attributes;
export const RuleActionAlertsFilter = t.strict({
query: t.union([
t.null,
t.intersection([
t.strict({
kql: t.string,
}),
t.partial({ dsl: t.string }),
]),
]),
timeframe: t.union([
t.null,
t.strict({
timezone: t.string,
days: t.array(
t.union([
t.literal(1),
t.literal(2),
t.literal(3),
t.literal(4),
t.literal(5),
t.literal(6),
t.literal(7),
])
),
hours: t.strict({
start: t.string,
end: t.string,
}),
}),
]),
});
export type RuleAction = t.TypeOf<typeof RuleAction>; export type RuleAction = t.TypeOf<typeof RuleAction>;
export const RuleAction = t.exact( export const RuleAction = t.exact(
t.intersection([ t.intersection([
@ -38,7 +71,7 @@ export const RuleAction = t.exact(
action_type_id: RuleActionTypeId, action_type_id: RuleActionTypeId,
params: RuleActionParams, params: RuleActionParams,
}), }),
t.partial({ uuid: RuleActionUuid }), t.partial({ uuid: RuleActionUuid, alerts_filter: RuleActionAlertsFilter }),
]) ])
); );
@ -54,7 +87,7 @@ export const RuleActionCamel = t.exact(
actionTypeId: RuleActionTypeId, actionTypeId: RuleActionTypeId,
params: RuleActionParams, params: RuleActionParams,
}), }),
t.partial({ uuid: RuleActionUuid }), t.partial({ uuid: RuleActionUuid, alertsFilter: RuleActionAlertsFilter }),
]) ])
); );

View file

@ -77,8 +77,9 @@ export interface RuleExecutionStatus {
export type RuleActionParams = SavedObjectAttributes; export type RuleActionParams = SavedObjectAttributes;
export type RuleActionParam = SavedObjectAttribute; export type RuleActionParam = SavedObjectAttribute;
export type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
export interface AlertsFilterTimeframe extends SavedObjectAttributes { export interface AlertsFilterTimeframe extends SavedObjectAttributes {
days: Array<1 | 2 | 3 | 4 | 5 | 6 | 7>; days: IsoWeekday[];
timezone: string; timezone: string;
hours: { hours: {
start: string; start: string;
@ -94,6 +95,8 @@ export interface AlertsFilter extends SavedObjectAttributes {
timeframe: null | AlertsFilterTimeframe; timeframe: null | AlertsFilterTimeframe;
} }
export type RuleActionAlertsFilterProperty = AlertsFilterTimeframe | RuleActionParam;
export interface RuleAction { export interface RuleAction {
uuid?: string; uuid?: string;
group: string; group: string;

View file

@ -9,7 +9,12 @@ import { omit } from 'lodash';
import { schema } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema';
import { IRouter } from '@kbn/core/server'; import { IRouter } from '@kbn/core/server';
import { ILicenseState } from '../lib'; import { ILicenseState } from '../lib';
import { verifyAccessAndContext, RewriteResponseCase, rewriteRuleLastRun } from './lib'; import {
verifyAccessAndContext,
RewriteResponseCase,
rewriteRuleLastRun,
rewriteActionsRes,
} from './lib';
import { import {
RuleTypeParams, RuleTypeParams,
AlertingRequestHandlerContext, AlertingRequestHandlerContext,
@ -61,21 +66,7 @@ const rewriteBodyRes: RewriteResponseCase<SanitizedRule<RuleTypeParams>> = ({
last_execution_date: executionStatus.lastExecutionDate, last_execution_date: executionStatus.lastExecutionDate,
last_duration: executionStatus.lastDuration, last_duration: executionStatus.lastDuration,
}, },
actions: actions.map(({ group, id, actionTypeId, params, frequency, uuid, alertsFilter }) => ({ actions: rewriteActionsRes(actions),
group,
id,
params,
connector_type_id: actionTypeId,
frequency: frequency
? {
summary: frequency.summary,
notify_when: frequency.notifyWhen,
throttle: frequency.throttle,
}
: undefined,
...(uuid && { uuid }),
...(alertsFilter && { alerts_filter: alertsFilter }),
})),
...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}),
...(nextRun ? { next_run: nextRun } : {}), ...(nextRun ? { next_run: nextRun } : {}),
...(viewInAppRelativeUrl ? { view_in_app_relative_url: viewInAppRelativeUrl } : {}), ...(viewInAppRelativeUrl ? { view_in_app_relative_url: viewInAppRelativeUrl } : {}),

View file

@ -47,6 +47,10 @@ jest.mock('@kbn/triggers-actions-ui-plugin/public/application/lib/rule_api', ()
loadAlertTypes: jest.fn(), loadAlertTypes: jest.fn(),
})); }));
jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({
useUiSetting: jest.fn().mockImplementation((_, defaultValue) => defaultValue),
}));
const initLegacyShims = () => { const initLegacyShims = () => {
const triggersActionsUi = { const triggersActionsUi = {
actionTypeRegistry: actionTypeRegistryMock.create(), actionTypeRegistry: actionTypeRegistryMock.create(),
@ -237,10 +241,13 @@ describe('alert_form', () => {
initialAlert.actions[index].id = id; initialAlert.actions[index].id = id;
}} }}
setActions={(_updatedActions: AlertAction[]) => {}} setActions={(_updatedActions: AlertAction[]) => {}}
setActionParamsProperty={(key: string, value: any, index: number) => setActionParamsProperty={(key: string, value: unknown, index: number) =>
(initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value })
} }
setActionFrequencyProperty={(key: string, value: any, index: number) => setActionFrequencyProperty={(key: string, value: unknown, index: number) =>
(initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value })
}
setActionAlertsFilterProperty={(key: string, value: unknown, index: number) =>
(initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value })
} }
actionTypeRegistry={actionTypeRegistry} actionTypeRegistry={actionTypeRegistry}

View file

@ -16,11 +16,13 @@ export const transformRuleToAlertAction = ({
action_type_id: actionTypeId, action_type_id: actionTypeId,
params, params,
uuid, uuid,
alerts_filter: alertsFilter,
}: RuleAlertAction): RuleAction => ({ }: RuleAlertAction): RuleAction => ({
group, group,
id, id,
params, params,
actionTypeId, actionTypeId,
...(alertsFilter && { alertsFilter }),
...(uuid && { uuid }), ...(uuid && { uuid }),
}); });
@ -30,11 +32,13 @@ export const transformAlertToRuleAction = ({
actionTypeId, actionTypeId,
params, params,
uuid, uuid,
alertsFilter,
}: RuleAction): RuleAlertAction => ({ }: RuleAction): RuleAlertAction => ({
group, group,
id, id,
params, params,
action_type_id: actionTypeId, action_type_id: actionTypeId,
...(alertsFilter && { alerts_filter: alertsFilter }),
...(uuid && { uuid }), ...(uuid && { uuid }),
}); });

View file

@ -7,8 +7,9 @@
import type { RuleAction } from '@kbn/alerting-plugin/common'; import type { RuleAction } from '@kbn/alerting-plugin/common';
export type RuleAlertAction = Omit<RuleAction, 'actionTypeId'> & { export type RuleAlertAction = Omit<RuleAction, 'actionTypeId' | 'alertsFilter'> & {
action_type_id: string; action_type_id: string;
alerts_filter?: RuleAction['alertsFilter'];
}; };
/** /**

View file

@ -13,7 +13,11 @@ import ReactMarkdown from 'react-markdown';
import styled from 'styled-components'; import styled from 'styled-components';
import type { ActionVariables } from '@kbn/triggers-actions-ui-plugin/public'; import type { ActionVariables } from '@kbn/triggers-actions-ui-plugin/public';
import type { RuleAction, RuleActionParam } from '@kbn/alerting-plugin/common'; import type {
RuleAction,
RuleActionAlertsFilterProperty,
RuleActionParam,
} from '@kbn/alerting-plugin/common';
import { SecurityConnectorFeatureId } from '@kbn/actions-plugin/common'; import { SecurityConnectorFeatureId } from '@kbn/actions-plugin/common';
import type { FieldHook } from '../../../../shared_imports'; import type { FieldHook } from '../../../../shared_imports';
import { useFormContext } from '../../../../shared_imports'; import { useFormContext } from '../../../../shared_imports';
@ -131,6 +135,23 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) =
[field, isInitializingAction] [field, isInitializingAction]
); );
const setActionAlertsFilterProperty = useCallback(
(key: string, value: RuleActionAlertsFilterProperty, index: number) => {
field.setValue((prevValue: RuleAction[]) => {
const updatedActions = [...prevValue];
updatedActions[index] = {
...updatedActions[index],
alertsFilter: {
...(updatedActions[index].alertsFilter ?? { query: null, timeframe: null }),
[key]: value,
},
};
return updatedActions;
});
},
[field]
);
const actionForm = useMemo( const actionForm = useMemo(
() => () =>
getActionForm({ getActionForm({
@ -141,10 +162,12 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) =
setActions: setAlertActionsProperty, setActions: setAlertActionsProperty,
setActionParamsProperty, setActionParamsProperty,
setActionFrequencyProperty: () => {}, setActionFrequencyProperty: () => {},
setActionAlertsFilterProperty,
featureId: SecurityConnectorFeatureId, featureId: SecurityConnectorFeatureId,
defaultActionMessage: DEFAULT_ACTION_MESSAGE, defaultActionMessage: DEFAULT_ACTION_MESSAGE,
hideActionHeader: true, hideActionHeader: true,
hideNotifyWhen: true, hideNotifyWhen: true,
showActionAlertsFilter: true,
}), }),
[ [
actions, actions,
@ -153,6 +176,7 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) =
setActionIdByIndex, setActionIdByIndex,
setActionParamsProperty, setActionParamsProperty,
setAlertActionsProperty, setAlertActionsProperty,
setActionAlertsFilterProperty,
] ]
); );

View file

@ -15,6 +15,7 @@ const transformAction: RewriteRequestCase<RuleAction> = ({
connector_type_id: actionTypeId, connector_type_id: actionTypeId,
params, params,
frequency, frequency,
alerts_filter: alertsFilter,
}) => ({ }) => ({
group, group,
id, id,
@ -29,6 +30,7 @@ const transformAction: RewriteRequestCase<RuleAction> = ({
}, },
} }
: {}), : {}),
...(alertsFilter ? { alertsFilter } : {}),
...(uuid && { uuid }), ...(uuid && { uuid }),
}); });

View file

@ -27,7 +27,7 @@ const rewriteBodyRequest: RewriteResponseCase<RuleCreateBody> = ({
}): any => ({ }): any => ({
...res, ...res,
rule_type_id: ruleTypeId, rule_type_id: ruleTypeId,
actions: actions.map(({ group, id, params, frequency }) => ({ actions: actions.map(({ group, id, params, frequency, alertsFilter }) => ({
group, group,
id, id,
params, params,
@ -36,6 +36,7 @@ const rewriteBodyRequest: RewriteResponseCase<RuleCreateBody> = ({
throttle: frequency!.throttle, throttle: frequency!.throttle,
summary: frequency!.summary, summary: frequency!.summary,
}, },
alertsFilter,
})), })),
}); });

View file

@ -17,7 +17,7 @@ type RuleUpdatesBody = Pick<
>; >;
const rewriteBodyRequest: RewriteResponseCase<RuleUpdatesBody> = ({ actions, ...res }): any => ({ const rewriteBodyRequest: RewriteResponseCase<RuleUpdatesBody> = ({ actions, ...res }): any => ({
...res, ...res,
actions: actions.map(({ group, id, params, frequency, uuid }) => ({ actions: actions.map(({ group, id, params, frequency, uuid, alertsFilter }) => ({
group, group,
id, id,
params, params,
@ -26,6 +26,7 @@ const rewriteBodyRequest: RewriteResponseCase<RuleUpdatesBody> = ({ actions, ...
throttle: frequency!.throttle, throttle: frequency!.throttle,
summary: frequency!.summary, summary: frequency!.summary,
}, },
alertsFilter,
...(uuid && { uuid }), ...(uuid && { uuid }),
})), })),
}); });

View file

@ -0,0 +1,135 @@
/*
* 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 { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { act } from 'react-dom/test-utils';
import { ActionAlertsFilterTimeframe } from './action_alerts_filter_timeframe';
import { AlertsFilterTimeframe } from '@kbn/alerting-plugin/common';
import { Moment } from 'moment';
jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({
useUiSetting: jest.fn().mockImplementation((_, defaultValue) => defaultValue),
}));
describe('action_alerts_filter_timeframe', () => {
async function setup(timeframe: AlertsFilterTimeframe | null) {
const wrapper = mountWithIntl(
<ActionAlertsFilterTimeframe state={timeframe} onChange={() => {}} />
);
// Wait for active space to resolve before requesting the component to update
await act(async () => {
await nextTick();
wrapper.update();
});
return wrapper;
}
it('renders an unchecked switch when passed a null timeframe', async () => {
const wrapper = await setup(null);
const alertsFilterTimeframeToggle = wrapper.find(
'[data-test-subj="alertsFilterTimeframeToggle"]'
);
expect(alertsFilterTimeframeToggle.first().props().checked).toBeFalsy();
});
it('renders the passed in timeframe', async () => {
const wrapper = await setup({
days: [6, 7],
timezone: 'America/Chicago',
hours: { start: '10:00', end: '20:00' },
});
const alertsFilterTimeframeToggle = wrapper.find(
'[data-test-subj="alertsFilterTimeframeToggle"]'
);
expect(alertsFilterTimeframeToggle.first().props().checked).toBeTruthy();
const alertsFilterTimeframeWeekdayButtons = wrapper.find(
'[data-test-subj="alertsFilterTimeframeWeekdayButtons"]'
);
expect(alertsFilterTimeframeWeekdayButtons.exists()).toBeTruthy();
// Use Reflect.get to avoid typescript errors
expect(
Reflect.get(
alertsFilterTimeframeWeekdayButtons.find('[data-test-subj="1"]').first().props(),
'isSelected'
)
).toBeFalsy();
expect(
Reflect.get(
alertsFilterTimeframeWeekdayButtons.find('[data-test-subj="2"]').first().props(),
'isSelected'
)
).toBeFalsy();
expect(
Reflect.get(
alertsFilterTimeframeWeekdayButtons.find('[data-test-subj="3"]').first().props(),
'isSelected'
)
).toBeFalsy();
expect(
Reflect.get(
alertsFilterTimeframeWeekdayButtons.find('[data-test-subj="4"]').first().props(),
'isSelected'
)
).toBeFalsy();
expect(
Reflect.get(
alertsFilterTimeframeWeekdayButtons.find('[data-test-subj="5"]').first().props(),
'isSelected'
)
).toBeFalsy();
expect(
Reflect.get(
alertsFilterTimeframeWeekdayButtons.find('[data-test-subj="6"]').first().props(),
'isSelected'
)
).toBeTruthy();
expect(
Reflect.get(
alertsFilterTimeframeWeekdayButtons.find('[data-test-subj="6"]').first().props(),
'isSelected'
)
).toBeTruthy();
const alertsFilterTimeframeStart = wrapper.find(
'[data-test-subj="alertsFilterTimeframeStart"]'
);
expect(alertsFilterTimeframeStart.exists()).toBeTruthy();
{
const selectedDate: Moment = Reflect.get(
alertsFilterTimeframeStart.first().props(),
'selected'
);
expect(selectedDate.format('HH:mm')).toEqual('10:00');
}
const alertsFilterTimeframeEnd = wrapper.find('[data-test-subj="alertsFilterTimeframeEnd"]');
expect(alertsFilterTimeframeEnd.exists()).toBeTruthy();
{
const selectedDate: Moment = Reflect.get(
alertsFilterTimeframeEnd.first().props(),
'selected'
);
expect(selectedDate.format('HH:mm')).toEqual('20:00');
}
const alertsFilterTimeframeTimezone = wrapper.find(
'[data-test-subj="alertsFilterTimeframeTimezone"]'
);
expect(alertsFilterTimeframeTimezone.exists()).toBeTruthy();
expect(
Reflect.get(alertsFilterTimeframeTimezone.first().props(), 'selectedOptions')[0].label
).toEqual('America/Chicago');
});
});

View file

@ -0,0 +1,221 @@
/*
* 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 moment, { Moment } from 'moment';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useUiSetting } from '@kbn/kibana-react-plugin/public';
import { i18n } from '@kbn/i18n';
import {
EuiFlexGroup,
EuiFlexItem,
EuiSwitch,
EuiButtonGroup,
EuiSpacer,
EuiDatePickerRange,
EuiDatePicker,
EuiComboBox,
} from '@elastic/eui';
import deepEqual from 'fast-deep-equal';
import { AlertsFilterTimeframe, IsoWeekday } from '@kbn/alerting-plugin/common';
import { I18N_WEEKDAY_OPTIONS_DDD, ISO_WEEKDAYS } from '../../../common/constants';
interface ActionAlertsFilterTimeframeProps {
state: AlertsFilterTimeframe | null;
onChange: (update: AlertsFilterTimeframe | null) => void;
}
const TIMEZONE_OPTIONS = moment.tz?.names().map((n) => ({ label: n })) ?? [{ label: 'UTC' }];
const useSortedWeekdayOptions = () => {
const kibanaDow: string = useUiSetting('dateFormat:dow');
const startDow = kibanaDow ?? 'Sunday';
const startDowIndex = I18N_WEEKDAY_OPTIONS_DDD.findIndex((o) => o.label.startsWith(startDow));
return [
...I18N_WEEKDAY_OPTIONS_DDD.slice(startDowIndex),
...I18N_WEEKDAY_OPTIONS_DDD.slice(0, startDowIndex),
];
};
const useDefaultTimezone = () => {
const kibanaTz: string = useUiSetting('dateFormat:tz');
if (!kibanaTz || kibanaTz === 'Browser') return moment.tz?.guess() ?? 'UTC';
return kibanaTz;
};
const useTimeframe = (initialTimeframe: AlertsFilterTimeframe | null) => {
const timezone = useDefaultTimezone();
const DEFAULT_TIMEFRAME = {
days: [],
timezone,
hours: {
start: '00:00',
end: '24:00',
},
};
return useState<AlertsFilterTimeframe>(initialTimeframe || DEFAULT_TIMEFRAME);
};
const useTimeFormat = () => {
const dateFormatScaled: Array<[string, string]> = useUiSetting('dateFormat:scaled') ?? [
['PT1M', 'HH:mm'],
];
const [, PT1M] = dateFormatScaled.find(([key]) => key === 'PT1M') ?? ['', 'HH:mm'];
return PT1M;
};
export const ActionAlertsFilterTimeframe: React.FC<ActionAlertsFilterTimeframeProps> = ({
state,
onChange,
}) => {
const timeFormat = useTimeFormat();
const [timeframe, setTimeframe] = useTimeframe(state);
const [selectedTimezone, setSelectedTimezone] = useState([{ label: timeframe.timezone }]);
const timeframeEnabled = useMemo(() => Boolean(state), [state]);
const weekdayOptions = useSortedWeekdayOptions();
useEffect(() => {
const nextState = timeframeEnabled ? timeframe : null;
if (!deepEqual(state, nextState)) onChange(nextState);
}, [timeframeEnabled, timeframe, state, onChange]);
const toggleTimeframe = useCallback(
() => onChange(state ? null : timeframe),
[state, timeframe, onChange]
);
const updateTimeframe = useCallback(
(update: Partial<AlertsFilterTimeframe>) => {
setTimeframe({
...timeframe,
...update,
});
},
[timeframe, setTimeframe]
);
const onChangeHours = useCallback(
(startOrEnd: 'start' | 'end') => (date: Moment) => {
updateTimeframe({
hours: { ...timeframe.hours, [startOrEnd]: date.format('HH:mm') },
});
},
[updateTimeframe, timeframe]
);
const onToggleWeekday = useCallback(
(id: string) => {
if (!timeframe) return;
const day = Number(id) as IsoWeekday;
const previouslyHasDay = timeframe.days.includes(day);
const newDays = previouslyHasDay
? timeframe.days.filter((d) => d !== day)
: [...timeframe.days, day];
updateTimeframe({ days: newDays });
},
[timeframe, updateTimeframe]
);
const selectedWeekdays = useMemo(
() =>
ISO_WEEKDAYS.reduce(
(result, day) => ({ ...result, [day]: timeframe.days.includes(day) }),
{}
),
[timeframe]
);
const onChangeTimezone = useCallback(
(value) => {
setSelectedTimezone(value);
if (value[0].label) updateTimeframe({ timezone: value[0].label });
},
[updateTimeframe, setSelectedTimezone]
);
const [startH, startM] = useMemo(() => timeframe.hours.start.split(':').map(Number), [timeframe]);
const [endH, endM] = useMemo(() => timeframe.hours.end.split(':').map(Number), [timeframe]);
return (
<>
<EuiSwitch
label={i18n.translate(
'xpack.triggersActionsUI.sections.actionTypeForm.ActionAlertsFilterTimeframeToggleLabel',
{
defaultMessage: 'Send alert notification within the selected time frame only',
}
)}
checked={timeframeEnabled}
onChange={toggleTimeframe}
data-test-subj="alertsFilterTimeframeToggle"
/>
{timeframeEnabled && (
<>
<EuiSpacer size="s" />
<EuiFlexItem>
<EuiButtonGroup
isFullWidth
legend={i18n.translate(
'xpack.triggersActionsUI.sections.actionTypeForm.ActionAlertsFilterTimeframeWeekdays',
{
defaultMessage: 'Days of week',
}
)}
options={weekdayOptions}
idToSelectedMap={selectedWeekdays}
type="multi"
onChange={onToggleWeekday}
data-test-subj="alertsFilterTimeframeWeekdayButtons"
/>
</EuiFlexItem>
<EuiSpacer size="s" />
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={2}>
<EuiDatePickerRange
fullWidth
startDateControl={
<EuiDatePicker
showTimeSelect
showTimeSelectOnly
dateFormat={timeFormat}
timeFormat={timeFormat}
selected={moment().set('hour', startH).set('minute', startM)}
onChange={onChangeHours('start')}
data-test-subj="alertsFilterTimeframeStart"
/>
}
endDateControl={
<EuiDatePicker
showTimeSelect
showTimeSelectOnly
dateFormat={timeFormat}
timeFormat={timeFormat}
selected={moment().set('hour', endH).set('minute', endM)}
onChange={onChangeHours('end')}
data-test-subj="alertsFilterTimeframeEnd"
/>
}
/>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiComboBox
prepend={i18n.translate(
'xpack.triggersActionsUI.sections.actionTypeForm.ActionAlertsFilterTimeframeTimezoneLabel',
{ defaultMessage: 'Timezone' }
)}
singleSelection={{ asPlainText: true }}
options={TIMEZONE_OPTIONS}
selectedOptions={selectedTimezone}
onChange={onChangeTimezone}
isClearable={false}
data-test-subj="alertsFilterTimeframeTimezone"
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</>
);
};

View file

@ -347,6 +347,15 @@ describe('action_form', () => {
frequency: { ...initialAlert.actions[index].frequency!, [key]: value }, frequency: { ...initialAlert.actions[index].frequency!, [key]: value },
}) })
} }
setActionAlertsFilterProperty={(key: string, value: any, index: number) =>
(initialAlert.actions[index] = {
...initialAlert.actions[index],
alertsFilter: {
...(initialAlert.actions[index].alertsFilter ?? { query: null, timeframe: null }),
[key]: value,
},
})
}
actionTypeRegistry={actionTypeRegistry} actionTypeRegistry={actionTypeRegistry}
setHasActionsWithBrokenConnector={setHasActionsWithBrokenConnector} setHasActionsWithBrokenConnector={setHasActionsWithBrokenConnector}
/> />

View file

@ -19,7 +19,11 @@ import {
EuiToolTip, EuiToolTip,
EuiLink, EuiLink,
} from '@elastic/eui'; } from '@elastic/eui';
import { ActionGroup, RuleActionParam } from '@kbn/alerting-plugin/common'; import {
ActionGroup,
RuleActionAlertsFilterProperty,
RuleActionParam,
} from '@kbn/alerting-plugin/common';
import { betaBadgeProps } from './beta_badge_props'; import { betaBadgeProps } from './beta_badge_props';
import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api'; import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api';
import { import {
@ -56,6 +60,11 @@ export interface ActionAccordionFormProps {
setActions: (actions: RuleAction[]) => void; setActions: (actions: RuleAction[]) => void;
setActionParamsProperty: (key: string, value: RuleActionParam, index: number) => void; setActionParamsProperty: (key: string, value: RuleActionParam, index: number) => void;
setActionFrequencyProperty: (key: string, value: RuleActionParam, index: number) => void; setActionFrequencyProperty: (key: string, value: RuleActionParam, index: number) => void;
setActionAlertsFilterProperty: (
key: string,
value: RuleActionAlertsFilterProperty,
index: number
) => void;
featureId: string; featureId: string;
messageVariables?: ActionVariables; messageVariables?: ActionVariables;
setHasActionsDisabled?: (value: boolean) => void; setHasActionsDisabled?: (value: boolean) => void;
@ -68,6 +77,7 @@ export interface ActionAccordionFormProps {
defaultSummaryMessage?: string; defaultSummaryMessage?: string;
hasSummary?: boolean; hasSummary?: boolean;
minimumThrottleInterval?: [number | undefined, string]; minimumThrottleInterval?: [number | undefined, string];
showActionAlertsFilter?: boolean;
} }
interface ActiveActionConnectorState { interface ActiveActionConnectorState {
@ -83,6 +93,7 @@ export const ActionForm = ({
setActions, setActions,
setActionParamsProperty, setActionParamsProperty,
setActionFrequencyProperty, setActionFrequencyProperty,
setActionAlertsFilterProperty,
featureId, featureId,
messageVariables, messageVariables,
actionGroups, actionGroups,
@ -97,6 +108,7 @@ export const ActionForm = ({
defaultSummaryMessage, defaultSummaryMessage,
hasSummary, hasSummary,
minimumThrottleInterval, minimumThrottleInterval,
showActionAlertsFilter,
}: ActionAccordionFormProps) => { }: ActionAccordionFormProps) => {
const { const {
http, http,
@ -373,6 +385,7 @@ export const ActionForm = ({
key={`action-form-action-at-${index}`} key={`action-form-action-at-${index}`}
setActionParamsProperty={setActionParamsProperty} setActionParamsProperty={setActionParamsProperty}
setActionFrequencyProperty={setActionFrequencyProperty} setActionFrequencyProperty={setActionFrequencyProperty}
setActionAlertsFilterProperty={setActionAlertsFilterProperty}
actionTypesIndex={actionTypesIndex} actionTypesIndex={actionTypesIndex}
connectors={connectors} connectors={connectors}
defaultActionGroupId={defaultActionGroupId} defaultActionGroupId={defaultActionGroupId}
@ -402,6 +415,7 @@ export const ActionForm = ({
defaultSummaryMessage={defaultSummaryMessage} defaultSummaryMessage={defaultSummaryMessage}
hasSummary={hasSummary} hasSummary={hasSummary}
minimumThrottleInterval={minimumThrottleInterval} minimumThrottleInterval={minimumThrottleInterval}
showActionAlertsFilter={showActionAlertsFilter}
/> />
); );
})} })}

View file

@ -36,6 +36,10 @@ jest.mock('../../lib/action_variables', () => {
}; };
}); });
jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({
useUiSetting: jest.fn().mockImplementation((_, defaultValue) => defaultValue),
}));
describe('action_type_form', () => { describe('action_type_form', () => {
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@ -376,6 +380,7 @@ function getActionTypeForm({
onDeleteAction, onDeleteAction,
onConnectorSelected, onConnectorSelected,
setActionFrequencyProperty, setActionFrequencyProperty,
setActionAlertsFilterProperty,
hasSummary = true, hasSummary = true,
messageVariables = { context: [], state: [], params: [] }, messageVariables = { context: [], state: [], params: [] },
}: { }: {
@ -389,6 +394,7 @@ function getActionTypeForm({
onDeleteAction?: () => void; onDeleteAction?: () => void;
onConnectorSelected?: (id: string) => void; onConnectorSelected?: (id: string) => void;
setActionFrequencyProperty?: () => void; setActionFrequencyProperty?: () => void;
setActionAlertsFilterProperty?: () => void;
hasSummary?: boolean; hasSummary?: boolean;
messageVariables?: ActionVariables; messageVariables?: ActionVariables;
}) { }) {
@ -469,6 +475,7 @@ function getActionTypeForm({
defaultActionGroupId={defaultActionGroupId ?? 'default'} defaultActionGroupId={defaultActionGroupId ?? 'default'}
setActionParamsProperty={jest.fn()} setActionParamsProperty={jest.fn()}
setActionFrequencyProperty={setActionFrequencyProperty ?? jest.fn()} setActionFrequencyProperty={setActionFrequencyProperty ?? jest.fn()}
setActionAlertsFilterProperty={setActionAlertsFilterProperty ?? jest.fn()}
index={index ?? 1} index={index ?? 1}
actionTypesIndex={actionTypeIndex ?? actionTypeIndexDefault} actionTypesIndex={actionTypeIndex ?? actionTypeIndexDefault}
actionTypeRegistry={actionTypeRegistry} actionTypeRegistry={actionTypeRegistry}

View file

@ -30,7 +30,11 @@ import {
EuiCallOut, EuiCallOut,
} from '@elastic/eui'; } from '@elastic/eui';
import { isEmpty, partition, some } from 'lodash'; import { isEmpty, partition, some } from 'lodash';
import { ActionVariable, RuleActionParam } from '@kbn/alerting-plugin/common'; import {
ActionVariable,
RuleActionAlertsFilterProperty,
RuleActionParam,
} from '@kbn/alerting-plugin/common';
import { import {
getDurationNumberInItsUnit, getDurationNumberInItsUnit,
getDurationUnitValue, getDurationUnitValue,
@ -54,6 +58,7 @@ import { useKibana } from '../../../common/lib/kibana';
import { ConnectorsSelection } from './connectors_selection'; import { ConnectorsSelection } from './connectors_selection';
import { ActionNotifyWhen } from './action_notify_when'; import { ActionNotifyWhen } from './action_notify_when';
import { validateParamsForWarnings } from '../../lib/validate_params_for_warnings'; import { validateParamsForWarnings } from '../../lib/validate_params_for_warnings';
import { ActionAlertsFilterTimeframe } from './action_alerts_filter_timeframe';
export type ActionTypeFormProps = { export type ActionTypeFormProps = {
actionItem: RuleAction; actionItem: RuleAction;
@ -64,6 +69,11 @@ export type ActionTypeFormProps = {
onDeleteAction: () => void; onDeleteAction: () => void;
setActionParamsProperty: (key: string, value: RuleActionParam, index: number) => void; setActionParamsProperty: (key: string, value: RuleActionParam, index: number) => void;
setActionFrequencyProperty: (key: string, value: RuleActionParam, index: number) => void; setActionFrequencyProperty: (key: string, value: RuleActionParam, index: number) => void;
setActionAlertsFilterProperty: (
key: string,
value: RuleActionAlertsFilterProperty,
index: number
) => void;
actionTypesIndex: ActionTypeIndex; actionTypesIndex: ActionTypeIndex;
connectors: ActionConnector[]; connectors: ActionConnector[];
actionTypeRegistry: ActionTypeRegistryContract; actionTypeRegistry: ActionTypeRegistryContract;
@ -72,6 +82,7 @@ export type ActionTypeFormProps = {
hideNotifyWhen?: boolean; hideNotifyWhen?: boolean;
hasSummary?: boolean; hasSummary?: boolean;
minimumThrottleInterval?: [number | undefined, string]; minimumThrottleInterval?: [number | undefined, string];
showActionAlertsFilter?: boolean;
} & Pick< } & Pick<
ActionAccordionFormProps, ActionAccordionFormProps,
| 'defaultActionGroupId' | 'defaultActionGroupId'
@ -99,6 +110,7 @@ export const ActionTypeForm = ({
onDeleteAction, onDeleteAction,
setActionParamsProperty, setActionParamsProperty,
setActionFrequencyProperty, setActionFrequencyProperty,
setActionAlertsFilterProperty,
actionTypesIndex, actionTypesIndex,
connectors, connectors,
defaultActionGroupId, defaultActionGroupId,
@ -113,6 +125,7 @@ export const ActionTypeForm = ({
defaultSummaryMessage, defaultSummaryMessage,
hasSummary, hasSummary,
minimumThrottleInterval, minimumThrottleInterval,
showActionAlertsFilter,
}: ActionTypeFormProps) => { }: ActionTypeFormProps) => {
const { const {
application: { capabilities }, application: { capabilities },
@ -143,6 +156,7 @@ export const ActionTypeForm = ({
const [warning, setWarning] = useState<string | null>(null); const [warning, setWarning] = useState<string | null>(null);
const [useDefaultMessage, setUseDefaultMessage] = useState(false); const [useDefaultMessage, setUseDefaultMessage] = useState(false);
const isSummaryAction = actionItem.frequency?.summary; const isSummaryAction = actionItem.frequency?.summary;
const getDefaultParams = async () => { const getDefaultParams = async () => {
@ -380,6 +394,15 @@ export const ActionTypeForm = ({
/> />
</> </>
)} )}
{showActionAlertsFilter && (
<>
{!hideNotifyWhen && <EuiSpacer size="xl" />}
<ActionAlertsFilterTimeframe
state={actionItem.alertsFilter?.timeframe ?? null}
onChange={(timeframe) => setActionAlertsFilterProperty('timeframe', timeframe, index)}
/>
</>
)}
</EuiSplitPanel.Inner> </EuiSplitPanel.Inner>
<EuiSplitPanel.Inner color="plain"> <EuiSplitPanel.Inner color="plain">
{ParamsFieldsComponent ? ( {ParamsFieldsComponent ? (

View file

@ -48,6 +48,7 @@ import {
ALERTS_FEATURE_ID, ALERTS_FEATURE_ID,
RecoveredActionGroup, RecoveredActionGroup,
isActionGroupDisabledForActionTypeId, isActionGroupDisabledForActionTypeId,
RuleActionAlertsFilterProperty,
} from '@kbn/alerting-plugin/common'; } from '@kbn/alerting-plugin/common';
import { AlertingConnectorFeatureId } from '@kbn/actions-plugin/common'; import { AlertingConnectorFeatureId } from '@kbn/actions-plugin/common';
import { RuleReducerAction, InitialRule } from './rule_reducer'; import { RuleReducerAction, InitialRule } from './rule_reducer';
@ -291,6 +292,13 @@ export const RuleForm = ({
[dispatch] [dispatch]
); );
const setActionAlertsFilterProperty = useCallback(
(key: string, value: RuleActionAlertsFilterProperty, index: number) => {
dispatch({ command: { type: 'setRuleActionAlertsFilter' }, payload: { key, value, index } });
},
[dispatch]
);
useEffect(() => { useEffect(() => {
const searchValue = searchText ? searchText.trim().toLocaleLowerCase() : null; const searchValue = searchText ? searchText.trim().toLocaleLowerCase() : null;
setFilteredRuleTypes( setFilteredRuleTypes(
@ -677,6 +685,7 @@ export const RuleForm = ({
setActionParamsProperty={setActionParamsProperty} setActionParamsProperty={setActionParamsProperty}
actionTypeRegistry={actionTypeRegistry} actionTypeRegistry={actionTypeRegistry}
setActionFrequencyProperty={setActionFrequencyProperty} setActionFrequencyProperty={setActionFrequencyProperty}
setActionAlertsFilterProperty={setActionAlertsFilterProperty}
defaultSummaryMessage={ruleTypeModel?.defaultSummaryMessage || summaryMessage} defaultSummaryMessage={ruleTypeModel?.defaultSummaryMessage || summaryMessage}
minimumThrottleInterval={[ruleInterval, ruleIntervalUnit]} minimumThrottleInterval={[ruleInterval, ruleIntervalUnit]}
/> />

View file

@ -8,7 +8,11 @@
import { SavedObjectAttribute } from '@kbn/core/public'; import { SavedObjectAttribute } from '@kbn/core/public';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { Reducer } from 'react'; import { Reducer } from 'react';
import { RuleActionParam, IntervalSchedule } from '@kbn/alerting-plugin/common'; import {
RuleActionParam,
IntervalSchedule,
RuleActionAlertsFilterProperty,
} from '@kbn/alerting-plugin/common';
import { Rule, RuleAction } from '../../../types'; import { Rule, RuleAction } from '../../../types';
import { DEFAULT_FREQUENCY } from '../../../common/constants'; import { DEFAULT_FREQUENCY } from '../../../common/constants';
@ -24,6 +28,7 @@ interface CommandType<
| 'setRuleActionParams' | 'setRuleActionParams'
| 'setRuleActionProperty' | 'setRuleActionProperty'
| 'setRuleActionFrequency' | 'setRuleActionFrequency'
| 'setRuleActionAlertsFilter'
> { > {
type: T; type: T;
} }
@ -84,6 +89,10 @@ export type RuleReducerAction =
| { | {
command: CommandType<'setRuleActionFrequency'>; command: CommandType<'setRuleActionFrequency'>;
payload: Payload<string, RuleActionParam>; payload: Payload<string, RuleActionParam>;
}
| {
command: CommandType<'setRuleActionAlertsFilter'>;
payload: Payload<string, RuleActionAlertsFilterProperty>;
}; };
export type InitialRuleReducer = Reducer<{ rule: InitialRule }, RuleReducerAction>; export type InitialRuleReducer = Reducer<{ rule: InitialRule }, RuleReducerAction>;
@ -215,6 +224,36 @@ export const ruleReducer = <RulePhase extends InitialRule | Rule>(
}; };
} }
} }
case 'setRuleActionAlertsFilter': {
const { key, value, index } = action.payload as Payload<
keyof RuleAction,
SavedObjectAttribute
>;
if (
index === undefined ||
rule.actions[index] == null ||
(!!rule.actions[index][key] && isEqual(rule.actions[index][key], value))
) {
return state;
} else {
const oldAction = rule.actions.splice(index, 1)[0];
const updatedAction = {
...oldAction,
alertsFilter: {
...(oldAction.alertsFilter ?? { timeframe: null, query: null }),
[key]: value,
},
};
rule.actions.splice(index, 0, updatedAction);
return {
...state,
rule: {
...rule,
actions: [...rule.actions],
},
};
}
}
case 'setRuleActionProperty': { case 'setRuleActionProperty': {
const { key, value, index } = action.payload as RuleActionPayload<keyof RuleAction>; const { key, value, index } = action.payload as RuleActionPayload<keyof RuleAction>;
if (index === undefined || isEqual(rule.actions[index][key], value)) { if (index === undefined || isEqual(rule.actions[index][key], value)) {

View file

@ -6,10 +6,9 @@
*/ */
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { invert, mapValues } from 'lodash'; import { invert, mapValues } from 'lodash';
import moment from 'moment';
import { RRuleFrequency } from '../../../../../../types'; import { RRuleFrequency } from '../../../../../../types';
export const ISO_WEEKDAYS = [1, 2, 3, 4, 5, 6, 7]; export { ISO_WEEKDAYS, I18N_WEEKDAY_OPTIONS } from '../../../../../../common/constants';
export const RECURRENCE_END_OPTIONS = [ export const RECURRENCE_END_OPTIONS = [
{ id: 'never', label: 'Never' }, { id: 'never', label: 'Never' },
@ -65,11 +64,6 @@ export const DEFAULT_RRULE_PRESETS = {
}, },
}; };
export const I18N_WEEKDAY_OPTIONS = ISO_WEEKDAYS.map((n) => ({
id: String(n),
label: moment().isoWeekday(n).format('dd'),
}));
export const ISO_WEEKDAYS_TO_RRULE: Record<number, string> = { export const ISO_WEEKDAYS_TO_RRULE: Record<number, string> = {
1: 'MO', 1: 'MO',
2: 'TU', 2: 'TU',

View file

@ -0,0 +1,20 @@
/*
* 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 { IsoWeekday } from '@kbn/alerting-plugin/common';
import moment from 'moment';
export const ISO_WEEKDAYS: IsoWeekday[] = [1, 2, 3, 4, 5, 6, 7];
export const I18N_WEEKDAY_OPTIONS = ISO_WEEKDAYS.map((n) => ({
id: String(n),
label: moment().isoWeekday(n).format('dd'),
}));
export const I18N_WEEKDAY_OPTIONS_DDD = ISO_WEEKDAYS.map((n) => ({
id: String(n),
label: moment().isoWeekday(n).format('ddd'),
}));

View file

@ -14,3 +14,4 @@ export const VIEW_LICENSE_OPTIONS_LINK = 'https://www.elastic.co/subscriptions';
export const PLUGIN_ID = 'triggersActions'; export const PLUGIN_ID = 'triggersActions';
export const CONNECTORS_PLUGIN_ID = 'triggersActionsConnectors'; export const CONNECTORS_PLUGIN_ID = 'triggersActionsConnectors';
export * from './i18n_weekdays';