[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 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 const RuleAction = t.exact(
t.intersection([
@ -38,7 +71,7 @@ export const RuleAction = t.exact(
action_type_id: RuleActionTypeId,
params: RuleActionParams,
}),
t.partial({ uuid: RuleActionUuid }),
t.partial({ uuid: RuleActionUuid, alerts_filter: RuleActionAlertsFilter }),
])
);
@ -54,7 +87,7 @@ export const RuleActionCamel = t.exact(
actionTypeId: RuleActionTypeId,
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 RuleActionParam = SavedObjectAttribute;
export type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
export interface AlertsFilterTimeframe extends SavedObjectAttributes {
days: Array<1 | 2 | 3 | 4 | 5 | 6 | 7>;
days: IsoWeekday[];
timezone: string;
hours: {
start: string;
@ -94,6 +95,8 @@ export interface AlertsFilter extends SavedObjectAttributes {
timeframe: null | AlertsFilterTimeframe;
}
export type RuleActionAlertsFilterProperty = AlertsFilterTimeframe | RuleActionParam;
export interface RuleAction {
uuid?: string;
group: string;

View file

@ -9,7 +9,12 @@ import { omit } from 'lodash';
import { schema } from '@kbn/config-schema';
import { IRouter } from '@kbn/core/server';
import { ILicenseState } from '../lib';
import { verifyAccessAndContext, RewriteResponseCase, rewriteRuleLastRun } from './lib';
import {
verifyAccessAndContext,
RewriteResponseCase,
rewriteRuleLastRun,
rewriteActionsRes,
} from './lib';
import {
RuleTypeParams,
AlertingRequestHandlerContext,
@ -61,21 +66,7 @@ const rewriteBodyRes: RewriteResponseCase<SanitizedRule<RuleTypeParams>> = ({
last_execution_date: executionStatus.lastExecutionDate,
last_duration: executionStatus.lastDuration,
},
actions: actions.map(({ group, id, actionTypeId, params, frequency, uuid, alertsFilter }) => ({
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 }),
})),
actions: rewriteActionsRes(actions),
...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}),
...(nextRun ? { next_run: nextRun } : {}),
...(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(),
}));
jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({
useUiSetting: jest.fn().mockImplementation((_, defaultValue) => defaultValue),
}));
const initLegacyShims = () => {
const triggersActionsUi = {
actionTypeRegistry: actionTypeRegistryMock.create(),
@ -237,10 +241,13 @@ describe('alert_form', () => {
initialAlert.actions[index].id = id;
}}
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 })
}
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 })
}
actionTypeRegistry={actionTypeRegistry}

View file

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

View file

@ -7,8 +7,9 @@
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;
alerts_filter?: RuleAction['alertsFilter'];
};
/**

View file

@ -13,7 +13,11 @@ import ReactMarkdown from 'react-markdown';
import styled from 'styled-components';
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 type { FieldHook } from '../../../../shared_imports';
import { useFormContext } from '../../../../shared_imports';
@ -131,6 +135,23 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) =
[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(
() =>
getActionForm({
@ -141,10 +162,12 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) =
setActions: setAlertActionsProperty,
setActionParamsProperty,
setActionFrequencyProperty: () => {},
setActionAlertsFilterProperty,
featureId: SecurityConnectorFeatureId,
defaultActionMessage: DEFAULT_ACTION_MESSAGE,
hideActionHeader: true,
hideNotifyWhen: true,
showActionAlertsFilter: true,
}),
[
actions,
@ -153,6 +176,7 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) =
setActionIdByIndex,
setActionParamsProperty,
setAlertActionsProperty,
setActionAlertsFilterProperty,
]
);

View file

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

View file

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

View file

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

View file

@ -19,7 +19,11 @@ import {
EuiToolTip,
EuiLink,
} 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 { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api';
import {
@ -56,6 +60,11 @@ export interface ActionAccordionFormProps {
setActions: (actions: RuleAction[]) => void;
setActionParamsProperty: (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;
messageVariables?: ActionVariables;
setHasActionsDisabled?: (value: boolean) => void;
@ -68,6 +77,7 @@ export interface ActionAccordionFormProps {
defaultSummaryMessage?: string;
hasSummary?: boolean;
minimumThrottleInterval?: [number | undefined, string];
showActionAlertsFilter?: boolean;
}
interface ActiveActionConnectorState {
@ -83,6 +93,7 @@ export const ActionForm = ({
setActions,
setActionParamsProperty,
setActionFrequencyProperty,
setActionAlertsFilterProperty,
featureId,
messageVariables,
actionGroups,
@ -97,6 +108,7 @@ export const ActionForm = ({
defaultSummaryMessage,
hasSummary,
minimumThrottleInterval,
showActionAlertsFilter,
}: ActionAccordionFormProps) => {
const {
http,
@ -373,6 +385,7 @@ export const ActionForm = ({
key={`action-form-action-at-${index}`}
setActionParamsProperty={setActionParamsProperty}
setActionFrequencyProperty={setActionFrequencyProperty}
setActionAlertsFilterProperty={setActionAlertsFilterProperty}
actionTypesIndex={actionTypesIndex}
connectors={connectors}
defaultActionGroupId={defaultActionGroupId}
@ -402,6 +415,7 @@ export const ActionForm = ({
defaultSummaryMessage={defaultSummaryMessage}
hasSummary={hasSummary}
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', () => {
afterEach(() => {
jest.clearAllMocks();
@ -376,6 +380,7 @@ function getActionTypeForm({
onDeleteAction,
onConnectorSelected,
setActionFrequencyProperty,
setActionAlertsFilterProperty,
hasSummary = true,
messageVariables = { context: [], state: [], params: [] },
}: {
@ -389,6 +394,7 @@ function getActionTypeForm({
onDeleteAction?: () => void;
onConnectorSelected?: (id: string) => void;
setActionFrequencyProperty?: () => void;
setActionAlertsFilterProperty?: () => void;
hasSummary?: boolean;
messageVariables?: ActionVariables;
}) {
@ -469,6 +475,7 @@ function getActionTypeForm({
defaultActionGroupId={defaultActionGroupId ?? 'default'}
setActionParamsProperty={jest.fn()}
setActionFrequencyProperty={setActionFrequencyProperty ?? jest.fn()}
setActionAlertsFilterProperty={setActionAlertsFilterProperty ?? jest.fn()}
index={index ?? 1}
actionTypesIndex={actionTypeIndex ?? actionTypeIndexDefault}
actionTypeRegistry={actionTypeRegistry}

View file

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

View file

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

View file

@ -8,7 +8,11 @@
import { SavedObjectAttribute } from '@kbn/core/public';
import { isEqual } from 'lodash';
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 { DEFAULT_FREQUENCY } from '../../../common/constants';
@ -24,6 +28,7 @@ interface CommandType<
| 'setRuleActionParams'
| 'setRuleActionProperty'
| 'setRuleActionFrequency'
| 'setRuleActionAlertsFilter'
> {
type: T;
}
@ -84,6 +89,10 @@ export type RuleReducerAction =
| {
command: CommandType<'setRuleActionFrequency'>;
payload: Payload<string, RuleActionParam>;
}
| {
command: CommandType<'setRuleActionAlertsFilter'>;
payload: Payload<string, RuleActionAlertsFilterProperty>;
};
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': {
const { key, value, index } = action.payload as RuleActionPayload<keyof RuleAction>;
if (index === undefined || isEqual(rule.actions[index][key], value)) {

View file

@ -6,10 +6,9 @@
*/
import { i18n } from '@kbn/i18n';
import { invert, mapValues } from 'lodash';
import moment from 'moment';
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 = [
{ 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> = {
1: 'MO',
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 CONNECTORS_PLUGIN_ID = 'triggersActionsConnectors';
export * from './i18n_weekdays';