[Alerting] Allow rule types to specify a default and minimum interval (#113650)

* WIP

* Remove test defaults

* Fix types

* Add tests

* Add missing files

* Fix issue with using default interval after user manually changed it

* PR feedback

* Fix types

* Remove debug
This commit is contained in:
Chris Roberson 2021-10-11 14:19:28 -04:00 committed by GitHub
parent 9d498b962c
commit e32dd1c493
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 822 additions and 136 deletions

View file

@ -122,6 +122,8 @@ The following table describes the properties of the `options` object.
|useSavedObjectReferences.extractReferences|(Optional) When developing a rule type, you can choose to implement hooks for extracting saved object references from rule parameters. This hook will be invoked when a rule is created or updated. Implementing this hook is optional, but if an extract hook is implemented, an inject hook must also be implemented.|Function
|useSavedObjectReferences.injectReferences|(Optional) When developing a rule type, you can choose to implement hooks for injecting saved object references into rule parameters. This hook will be invoked when a rule is retrieved (get or find). Implementing this hook is optional, but if an inject hook is implemented, an extract hook must also be implemented.|Function
|isExportable|Whether the rule type is exportable from the Saved Objects Management UI.|boolean|
|defaultScheduleInterval|The default interval that will show up in the UI when creating a rule of this rule type.|boolean|
|minimumScheduleInterval|The minimum interval that will be allowed for all rules of this rule type.|boolean|
### Executor

View file

@ -21,6 +21,8 @@ export interface AlertType<
producer: string;
minimumLicenseRequired: LicenseType;
isExportable: boolean;
defaultScheduleInterval?: string;
minimumScheduleInterval?: string;
}
export interface ActionGroup<ActionGroupIds extends string> {

View file

@ -57,6 +57,8 @@ describe('ruleTypesRoute', () => {
},
producer: 'test',
enabledInLicense: true,
minimumScheduleInterval: '1m',
defaultScheduleInterval: '10m',
} as RegistryAlertTypeWithAuth,
];
const expectedResult: Array<AsApiContract<RegistryAlertTypeWithAuth>> = [
@ -70,7 +72,9 @@ describe('ruleTypesRoute', () => {
},
],
default_action_group_id: 'default',
default_schedule_interval: '10m',
minimum_license_required: 'basic',
minimum_schedule_interval: '1m',
is_exportable: true,
recovery_action_group: RecoveredActionGroup,
authorized_consumers: {},
@ -102,10 +106,12 @@ describe('ruleTypesRoute', () => {
},
"authorized_consumers": Object {},
"default_action_group_id": "default",
"default_schedule_interval": "10m",
"enabled_in_license": true,
"id": "1",
"is_exportable": true,
"minimum_license_required": "basic",
"minimum_schedule_interval": "1m",
"name": "name",
"producer": "test",
"recovery_action_group": Object {

View file

@ -22,6 +22,8 @@ const rewriteBodyRes: RewriteResponseCase<RegistryAlertTypeWithAuth[]> = (result
isExportable,
actionVariables,
authorizedConsumers,
minimumScheduleInterval,
defaultScheduleInterval,
...rest
}) => ({
...rest,
@ -33,6 +35,8 @@ const rewriteBodyRes: RewriteResponseCase<RegistryAlertTypeWithAuth[]> = (result
is_exportable: isExportable,
action_variables: actionVariables,
authorized_consumers: authorizedConsumers,
minimum_schedule_interval: minimumScheduleInterval,
default_schedule_interval: defaultScheduleInterval,
})
);
};

View file

@ -114,7 +114,7 @@ describe('register()', () => {
test('throws if AlertType ruleTaskTimeout is not a valid duration', () => {
const alertType: AlertType<never, never, never, never, never, 'default'> = {
id: 123 as unknown as string,
id: '123',
name: 'Test',
actionGroups: [
{
@ -138,6 +138,59 @@ describe('register()', () => {
);
});
test('throws if defaultScheduleInterval isnt valid', () => {
const alertType: AlertType<never, never, never, never, never, 'default'> = {
id: '123',
name: 'Test',
actionGroups: [
{
id: 'default',
name: 'Default',
},
],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
producer: 'alerts',
defaultScheduleInterval: 'foobar',
};
const registry = new RuleTypeRegistry(ruleTypeRegistryParams);
expect(() => registry.register(alertType)).toThrowError(
new Error(
`Rule type \"123\" has invalid default interval: string is not a valid duration: foobar.`
)
);
});
test('throws if minimumScheduleInterval isnt valid', () => {
const alertType: AlertType<never, never, never, never, never, 'default'> = {
id: '123',
name: 'Test',
actionGroups: [
{
id: 'default',
name: 'Default',
},
],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
producer: 'alerts',
minimumScheduleInterval: 'foobar',
};
const registry = new RuleTypeRegistry(ruleTypeRegistryParams);
expect(() => registry.register(alertType)).toThrowError(
new Error(
`Rule type \"123\" has invalid minimum interval: string is not a valid duration: foobar.`
)
);
});
test('throws if RuleType action groups contains reserved group id', () => {
const alertType: AlertType<never, never, never, never, never, 'default' | 'NotReserved'> = {
id: 'test',
@ -465,10 +518,12 @@ describe('list()', () => {
"state": Array [],
},
"defaultActionGroupId": "testActionGroup",
"defaultScheduleInterval": undefined,
"enabledInLicense": false,
"id": "test",
"isExportable": true,
"minimumLicenseRequired": "basic",
"minimumScheduleInterval": undefined,
"name": "Test",
"producer": "alerts",
"recoveryActionGroup": Object {

View file

@ -48,6 +48,8 @@ export interface RegistryRuleType
| 'producer'
| 'minimumLicenseRequired'
| 'isExportable'
| 'minimumScheduleInterval'
| 'defaultScheduleInterval'
> {
id: string;
enabledInLicense: boolean;
@ -188,6 +190,44 @@ export class RuleTypeRegistry {
}
alertType.actionVariables = normalizedActionVariables(alertType.actionVariables);
// validate defaultScheduleInterval here
if (alertType.defaultScheduleInterval) {
const invalidDefaultTimeout = validateDurationSchema(alertType.defaultScheduleInterval);
if (invalidDefaultTimeout) {
throw new Error(
i18n.translate(
'xpack.alerting.ruleTypeRegistry.register.invalidDefaultTimeoutAlertTypeError',
{
defaultMessage: 'Rule type "{id}" has invalid default interval: {errorMessage}.',
values: {
id: alertType.id,
errorMessage: invalidDefaultTimeout,
},
}
)
);
}
}
// validate minimumScheduleInterval here
if (alertType.minimumScheduleInterval) {
const invalidMinimumTimeout = validateDurationSchema(alertType.minimumScheduleInterval);
if (invalidMinimumTimeout) {
throw new Error(
i18n.translate(
'xpack.alerting.ruleTypeRegistry.register.invalidMinimumTimeoutAlertTypeError',
{
defaultMessage: 'Rule type "{id}" has invalid minimum interval: {errorMessage}.',
values: {
id: alertType.id,
errorMessage: invalidMinimumTimeout,
},
}
)
);
}
}
const normalizedAlertType = augmentActionGroupsWithReserved<
Params,
ExtractedParams,
@ -287,6 +327,8 @@ export class RuleTypeRegistry {
producer,
minimumLicenseRequired,
isExportable,
minimumScheduleInterval,
defaultScheduleInterval,
},
]: [string, UntypedNormalizedAlertType]) => ({
id,
@ -298,6 +340,8 @@ export class RuleTypeRegistry {
producer,
minimumLicenseRequired,
isExportable,
minimumScheduleInterval,
defaultScheduleInterval,
enabledInLicense: !!this.licenseState.getLicenseCheckForAlertType(
id,
name,

View file

@ -296,6 +296,17 @@ export class RulesClient {
await this.validateActions(ruleType, data.actions);
// Validate intervals, if configured
if (ruleType.minimumScheduleInterval) {
const intervalInMs = parseDuration(data.schedule.interval);
const minimumScheduleIntervalInMs = parseDuration(ruleType.minimumScheduleInterval);
if (intervalInMs < minimumScheduleIntervalInMs) {
throw Boom.badRequest(
`Error updating rule: the interval is less than the minimum interval of ${ruleType.minimumScheduleInterval}`
);
}
}
// Extract saved object references for this rule
const {
references,
@ -847,6 +858,17 @@ export class RulesClient {
);
await this.validateActions(ruleType, data.actions);
// Validate intervals, if configured
if (ruleType.minimumScheduleInterval) {
const intervalInMs = parseDuration(data.schedule.interval);
const minimumScheduleIntervalInMs = parseDuration(ruleType.minimumScheduleInterval);
if (intervalInMs < minimumScheduleIntervalInMs) {
throw Boom.badRequest(
`Error updating rule: the interval is less than the minimum interval of ${ruleType.minimumScheduleInterval}`
);
}
}
// Extract saved object references for this rule
const {
references,

View file

@ -2268,4 +2268,30 @@ describe('create()', () => {
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
expect(taskManager.schedule).not.toHaveBeenCalled();
});
test('throws error when updating with an interval less than the minimum configured one', async () => {
ruleTypeRegistry.get.mockImplementation(() => ({
id: '123',
name: 'Test',
actionGroups: [{ id: 'default', name: 'Default' }],
recoveryActionGroup: RecoveredActionGroup,
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
async executor() {},
producer: 'alerts',
minimumScheduleInterval: '5m',
useSavedObjectReferences: {
extractReferences: jest.fn(),
injectReferences: jest.fn(),
},
}));
const data = getMockData({ schedule: { interval: '1m' } });
await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Error updating rule: the interval is less than the minimum interval of 5m"`
);
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
expect(taskManager.schedule).not.toHaveBeenCalled();
});
});

View file

@ -140,6 +140,7 @@ describe('update()', () => {
recoveryActionGroup: RecoveredActionGroup,
async executor() {},
producer: 'alerts',
minimumScheduleInterval: '5s',
});
});
@ -1966,4 +1967,49 @@ describe('update()', () => {
);
});
});
test('throws error when updating with an interval less than the minimum configured one', async () => {
await expect(
rulesClient.update({
id: '1',
data: {
schedule: { interval: '1s' },
name: 'abc',
tags: ['foo'],
params: {
bar: true,
},
throttle: null,
notifyWhen: 'onActiveAlert',
actions: [
{
group: 'default',
id: '1',
params: {
foo: true,
},
},
{
group: 'default',
id: '1',
params: {
foo: true,
},
},
{
group: 'default',
id: '2',
params: {
foo: true,
},
},
],
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Error updating rule: the interval is less than the minimum interval of 5s"`
);
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
expect(taskManager.schedule).not.toHaveBeenCalled();
});
});

View file

@ -157,6 +157,8 @@ export interface AlertType<
injectReferences: (params: ExtractedParams, references: SavedObjectReference[]) => Params;
};
isExportable: boolean;
defaultScheduleInterval?: string;
minimumScheduleInterval?: string;
ruleTaskTimeout?: string;
}
export type UntypedAlertType = AlertType<

View file

@ -36,3 +36,5 @@ export enum SORT_ORDERS {
}
export const DEFAULT_SEARCH_PAGE_SIZE: number = 10;
export const DEFAULT_ALERT_INTERVAL = '1m';

View file

@ -171,6 +171,7 @@ export const AlertDetails: React.FunctionComponent<AlertDetailsProps> = ({
}}
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
ruleType={alertType}
onSave={setAlert}
/>
)}

View file

@ -69,7 +69,8 @@ describe('alert_add', () => {
async function setup(
initialValues?: Partial<Alert>,
onClose: AlertAddProps['onClose'] = jest.fn()
onClose: AlertAddProps['onClose'] = jest.fn(),
defaultScheduleInterval?: string
) {
const mocks = coreMock.createSetup();
const { loadAlertTypes } = jest.requireMock('../../lib/alert_api');
@ -84,6 +85,7 @@ describe('alert_add', () => {
},
],
defaultActionGroupId: 'testActionGroup',
defaultScheduleInterval,
minimumLicenseRequired: 'basic',
recoveryActionGroup: { id: 'recovered', name: 'Recovered' },
producer: ALERTS_FEATURE_ID,
@ -243,6 +245,26 @@ describe('alert_add', () => {
expect(onClose).toHaveBeenCalledWith(AlertFlyoutCloseReason.SAVED);
});
it('should enforce any default inteval', async () => {
await setup({ alertTypeId: 'my-alert-type' }, jest.fn(), '3h');
await delay(1000);
// Wait for handlers to fire
await act(async () => {
await nextTick();
wrapper.update();
});
const intervalInputUnit = wrapper
.find('[data-test-subj="intervalInputUnit"]')
.first()
.getElement().props.value;
const intervalInput = wrapper.find('[data-test-subj="intervalInput"]').first().getElement()
.props.value;
expect(intervalInputUnit).toBe('h');
expect(intervalInput).toBe(3);
});
});
function mockAlert(overloads: Partial<Alert> = {}): Alert {

View file

@ -17,10 +17,12 @@ import {
AlertFlyoutCloseReason,
IErrorObject,
AlertAddProps,
RuleTypeIndex,
} from '../../../types';
import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form';
import { AlertForm } from './alert_form';
import { getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_errors';
import { alertReducer, InitialAlert, InitialAlertReducer } from './alert_reducer';
import { createAlert } from '../../lib/alert_api';
import { createAlert, loadAlertTypes } from '../../lib/alert_api';
import { HealthCheck } from '../../components/health_check';
import { ConfirmAlertSave } from './confirm_alert_save';
import { ConfirmAlertClose } from './confirm_alert_close';
@ -30,6 +32,7 @@ import { HealthContextProvider } from '../../context/health_context';
import { useKibana } from '../../../common/lib/kibana';
import { hasAlertChanged, haveAlertParamsChanged } from './has_alert_changed';
import { getAlertWithInvalidatedFields } from '../../lib/value_validators';
import { DEFAULT_ALERT_INTERVAL } from '../../constants';
const AlertAdd = ({
consumer,
@ -39,26 +42,28 @@ const AlertAdd = ({
canChangeTrigger,
alertTypeId,
initialValues,
reloadAlerts,
onSave,
metadata,
...props
}: AlertAddProps) => {
const onSaveHandler = onSave ?? reloadAlerts;
const initialAlert: InitialAlert = useMemo(
() => ({
const initialAlert: InitialAlert = useMemo(() => {
return {
params: {},
consumer,
alertTypeId,
schedule: {
interval: '1m',
interval: DEFAULT_ALERT_INTERVAL,
},
actions: [],
tags: [],
notifyWhen: 'onActionGroupChange',
...(initialValues ? initialValues : {}),
}),
[alertTypeId, consumer, initialValues]
);
};
}, [alertTypeId, consumer, initialValues]);
const [{ alert }, dispatch] = useReducer(alertReducer as InitialAlertReducer, {
alert: initialAlert,
@ -67,6 +72,10 @@ const AlertAdd = ({
const [isSaving, setIsSaving] = useState<boolean>(false);
const [isConfirmAlertSaveModalOpen, setIsConfirmAlertSaveModalOpen] = useState<boolean>(false);
const [isConfirmAlertCloseModalOpen, setIsConfirmAlertCloseModalOpen] = useState<boolean>(false);
const [ruleTypeIndex, setRuleTypeIndex] = useState<RuleTypeIndex | undefined>(
props.ruleTypeIndex
);
const [changedFromDefaultInterval, setChangedFromDefaultInterval] = useState<boolean>(false);
const setAlert = (value: InitialAlert) => {
dispatch({ command: { type: 'setAlert' }, payload: { key: 'alert', value } });
@ -90,6 +99,19 @@ const AlertAdd = ({
}
}, [alertTypeId]);
useEffect(() => {
if (!props.ruleTypeIndex) {
(async () => {
const alertTypes = await loadAlertTypes({ http });
const index: RuleTypeIndex = new Map();
for (const alertType of alertTypes) {
index.set(alertType.id, alertType);
}
setRuleTypeIndex(index);
})();
}
}, [props.ruleTypeIndex, http]);
useEffect(() => {
if (isEmpty(alert.params) && !isEmpty(initialAlertParams)) {
// alert params are explicitly cleared when the alert type is cleared.
@ -115,6 +137,21 @@ const AlertAdd = ({
})();
}, [alert, actionTypeRegistry]);
useEffect(() => {
if (alert.alertTypeId && ruleTypeIndex) {
const type = ruleTypeIndex.get(alert.alertTypeId);
if (type?.defaultScheduleInterval && !changedFromDefaultInterval) {
setAlertProperty('schedule', { interval: type.defaultScheduleInterval });
}
}
}, [alert.alertTypeId, ruleTypeIndex, alert.schedule.interval, changedFromDefaultInterval]);
useEffect(() => {
if (alert.schedule.interval !== DEFAULT_ALERT_INTERVAL && !changedFromDefaultInterval) {
setChangedFromDefaultInterval(true);
}
}, [alert.schedule.interval, changedFromDefaultInterval]);
const checkForChangesAndCloseFlyout = () => {
if (
hasAlertChanged(alert, initialAlert, false) ||
@ -138,9 +175,11 @@ const AlertAdd = ({
};
const alertType = alert.alertTypeId ? ruleTypeRegistry.get(alert.alertTypeId) : null;
const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors(
alert as Alert,
alertType
alertType,
alert.alertTypeId ? ruleTypeIndex?.get(alert.alertTypeId) : undefined
);
// Confirm before saving if user is able to add actions but hasn't added any to this alert

View file

@ -35,6 +35,23 @@ jest.mock('../../lib/alert_api', () => ({
})),
}));
jest.mock('./alert_errors', () => ({
getAlertActionErrors: jest.fn().mockImplementation(() => {
return [];
}),
getAlertErrors: jest.fn().mockImplementation(() => ({
alertParamsErrors: {},
alertBaseErrors: {},
alertErrors: {
name: new Array<string>(),
interval: new Array<string>(),
alertTypeId: new Array<string>(),
actionConnectors: new Array<string>(),
},
})),
isValidAlert: jest.fn(),
}));
jest.mock('../../../common/lib/health_api', () => ({
triggersActionsUiHealth: jest.fn(() => ({ isAlertsAvailable: true })),
}));
@ -47,7 +64,7 @@ describe('alert_edit', () => {
mockedCoreSetup = coreMock.createSetup();
});
async function setup() {
async function setup(initialAlertFields = {}) {
const [
{
application: { capabilities },
@ -154,6 +171,7 @@ describe('alert_edit', () => {
status: 'unknown',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
},
...initialAlertFields,
};
actionTypeRegistry.get.mockReturnValueOnce(actionTypeModel);
actionTypeRegistry.has.mockReturnValue(true);
@ -188,7 +206,11 @@ describe('alert_edit', () => {
});
it('displays a toast message on save for server errors', async () => {
await setup();
const { isValidAlert } = jest.requireMock('./alert_errors');
(isValidAlert as jest.Mock).mockImplementation(() => {
return true;
});
await setup({ name: undefined });
await act(async () => {
wrapper.find('[data-test-subj="saveEditedAlertButton"]').first().simulate('click');
@ -197,4 +219,12 @@ describe('alert_edit', () => {
'Fail message'
);
});
it('should pass in the server alert type into `getAlertErrors`', async () => {
const { getAlertErrors } = jest.requireMock('./alert_errors');
await setup();
const lastCall = getAlertErrors.mock.calls[getAlertErrors.mock.calls.length - 1];
expect(lastCall[2]).toBeDefined();
expect(lastCall[2].id).toBe('my-alert-type');
});
});

View file

@ -24,10 +24,17 @@ import {
} from '@elastic/eui';
import { cloneDeep } from 'lodash';
import { i18n } from '@kbn/i18n';
import { Alert, AlertFlyoutCloseReason, AlertEditProps, IErrorObject } from '../../../types';
import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form';
import {
Alert,
AlertFlyoutCloseReason,
AlertEditProps,
IErrorObject,
AlertType,
} from '../../../types';
import { AlertForm } from './alert_form';
import { getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_errors';
import { alertReducer, ConcreteAlertReducer } from './alert_reducer';
import { updateAlert } from '../../lib/alert_api';
import { updateAlert, loadAlertTypes } from '../../lib/alert_api';
import { HealthCheck } from '../../components/health_check';
import { HealthContextProvider } from '../../context/health_context';
import { useKibana } from '../../../common/lib/kibana';
@ -43,6 +50,7 @@ export const AlertEdit = ({
ruleTypeRegistry,
actionTypeRegistry,
metadata,
...props
}: AlertEditProps) => {
const onSaveHandler = onSave ?? reloadAlerts;
const [{ alert }, dispatch] = useReducer(alertReducer as ConcreteAlertReducer, {
@ -55,6 +63,9 @@ export const AlertEdit = ({
const [isConfirmAlertCloseModalOpen, setIsConfirmAlertCloseModalOpen] = useState<boolean>(false);
const [alertActionsErrors, setAlertActionsErrors] = useState<IErrorObject[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [serverRuleType, setServerRuleType] = useState<AlertType<string, string> | undefined>(
props.ruleType
);
const {
http,
@ -75,9 +86,23 @@ export const AlertEdit = ({
})();
}, [alert, actionTypeRegistry]);
useEffect(() => {
if (!props.ruleType && !serverRuleType) {
(async () => {
const serverRuleTypes = await loadAlertTypes({ http });
for (const _serverRuleType of serverRuleTypes) {
if (alertType.id === _serverRuleType.id) {
setServerRuleType(_serverRuleType);
}
}
})();
}
}, [props.ruleType, alertType.id, serverRuleType, http]);
const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors(
alert as Alert,
alertType
alertType,
serverRuleType
);
const checkForChangesAndCloseFlyout = () => {

View file

@ -0,0 +1,284 @@
/*
* 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 uuid from 'uuid';
import React, { Fragment } from 'react';
import {
validateBaseProperties,
getAlertErrors,
getAlertActionErrors,
hasObjectErrors,
isValidAlert,
} from './alert_errors';
import { Alert, AlertType, AlertTypeModel } from '../../../types';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
describe('alert_errors', () => {
describe('validateBaseProperties()', () => {
it('should validate the name', () => {
const alert = mockAlert();
alert.name = '';
const result = validateBaseProperties(alert);
expect(result.errors).toStrictEqual({
name: ['Name is required.'],
interval: [],
alertTypeId: [],
actionConnectors: [],
});
});
it('should validate the interval', () => {
const alert = mockAlert();
alert.schedule.interval = '';
const result = validateBaseProperties(alert);
expect(result.errors).toStrictEqual({
name: [],
interval: ['Check interval is required.'],
alertTypeId: [],
actionConnectors: [],
});
});
it('should validate the minimumScheduleInterval', () => {
const alert = mockAlert();
alert.schedule.interval = '2m';
const result = validateBaseProperties(
alert,
mockserverRuleType({ minimumScheduleInterval: '5m' })
);
expect(result.errors).toStrictEqual({
name: [],
interval: ['Interval is below minimum (5m) for this rule type'],
alertTypeId: [],
actionConnectors: [],
});
});
it('should validate the alertTypeId', () => {
const alert = mockAlert();
alert.alertTypeId = '';
const result = validateBaseProperties(alert);
expect(result.errors).toStrictEqual({
name: [],
interval: [],
alertTypeId: ['Rule type is required.'],
actionConnectors: [],
});
});
it('should validate the connectors', () => {
const alert = mockAlert();
alert.actions = [
{
id: '1234',
actionTypeId: 'myActionType',
group: '',
params: {
name: 'yes',
},
},
];
const result = validateBaseProperties(alert);
expect(result.errors).toStrictEqual({
name: [],
interval: [],
alertTypeId: [],
actionConnectors: ['Action for myActionType connector is required.'],
});
});
});
describe('getAlertErrors()', () => {
it('should return all errors', () => {
const result = getAlertErrors(
mockAlert({
name: '',
}),
mockAlertTypeModel({
validate: () => ({
errors: {
field: ['This is wrong'],
},
}),
}),
mockserverRuleType()
);
expect(result).toStrictEqual({
alertParamsErrors: { field: ['This is wrong'] },
alertBaseErrors: {
name: ['Name is required.'],
interval: [],
alertTypeId: [],
actionConnectors: [],
},
alertErrors: {
name: ['Name is required.'],
field: ['This is wrong'],
interval: [],
alertTypeId: [],
actionConnectors: [],
},
});
});
});
describe('getAlertActionErrors()', () => {
it('should return an array of errors', async () => {
const actionTypeRegistry = actionTypeRegistryMock.create();
actionTypeRegistry.get.mockImplementation((actionTypeId: string) => ({
...actionTypeRegistryMock.createMockActionTypeModel(),
validateParams: jest.fn().mockImplementation(() => ({
errors: {
[actionTypeId]: ['Yes, this failed'],
},
})),
}));
const result = await getAlertActionErrors(
mockAlert({
actions: [
{
id: '1234',
actionTypeId: 'myActionType',
group: '',
params: {
name: 'yes',
},
},
{
id: '5678',
actionTypeId: 'myActionType2',
group: '',
params: {
name: 'yes',
},
},
],
}),
actionTypeRegistry
);
expect(result).toStrictEqual([
{
myActionType: ['Yes, this failed'],
},
{
myActionType2: ['Yes, this failed'],
},
]);
});
});
describe('hasObjectErrors()', () => {
it('should return true for any errors', () => {
expect(
hasObjectErrors({
foo: ['1'],
})
).toBe(true);
expect(
hasObjectErrors({
foo: {
foo: ['1'],
},
})
).toBe(true);
});
it('should return false for no errors', () => {
expect(hasObjectErrors({})).toBe(false);
});
});
describe('isValidAlert()', () => {
it('should return true for a valid alert', () => {
const result = isValidAlert(mockAlert(), {}, []);
expect(result).toBe(true);
});
it('should return false for an invalid alert', () => {
expect(
isValidAlert(
mockAlert(),
{
name: ['This is wrong'],
},
[]
)
).toBe(false);
expect(
isValidAlert(mockAlert(), {}, [
{
name: ['This is wrong'],
},
])
).toBe(false);
});
});
});
function mockserverRuleType(
overloads: Partial<AlertType<string, string>> = {}
): AlertType<string, string> {
return {
actionGroups: [],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
recoveryActionGroup: {
id: 'recovery',
name: 'doRecovery',
},
id: 'myAppAlertType',
name: 'myAppAlertType',
producer: 'myApp',
authorizedConsumers: {},
enabledInLicense: true,
actionVariables: {
context: [],
state: [],
params: [],
},
...overloads,
};
}
function mockAlertTypeModel(overloads: Partial<AlertTypeModel> = {}): AlertTypeModel {
return {
id: 'alertTypeModel',
description: 'some alert',
iconClass: 'something',
documentationUrl: null,
validate: () => ({ errors: {} }),
alertParamsExpression: () => <Fragment />,
requiresAppContext: false,
...overloads,
};
}
function mockAlert(overloads: Partial<Alert> = {}): Alert {
return {
id: uuid.v4(),
enabled: true,
name: `alert-${uuid.v4()}`,
tags: [],
alertTypeId: '.noop',
consumer: 'consumer',
schedule: { interval: '1m' },
actions: [],
params: {},
createdBy: null,
updatedBy: null,
createdAt: new Date(),
updatedAt: new Date(),
apiKeyOwner: null,
throttle: null,
notifyWhen: null,
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'unknown',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
},
...overloads,
};
}

View file

@ -0,0 +1,132 @@
/*
* 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 { isObject } from 'lodash';
import { i18n } from '@kbn/i18n';
import { parseDuration } from '../../../../../alerting/common/parse_duration';
import {
AlertTypeModel,
Alert,
IErrorObject,
AlertAction,
AlertType,
ValidationResult,
ActionTypeRegistryContract,
} from '../../../types';
import { InitialAlert } from './alert_reducer';
export function validateBaseProperties(
alertObject: InitialAlert,
serverRuleType?: AlertType<string, string>
): ValidationResult {
const validationResult = { errors: {} };
const errors = {
name: new Array<string>(),
interval: new Array<string>(),
alertTypeId: new Array<string>(),
actionConnectors: new Array<string>(),
};
validationResult.errors = errors;
if (!alertObject.name) {
errors.name.push(
i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredNameText', {
defaultMessage: 'Name is required.',
})
);
}
if (alertObject.schedule.interval.length < 2) {
errors.interval.push(
i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredIntervalText', {
defaultMessage: 'Check interval is required.',
})
);
} else if (serverRuleType?.minimumScheduleInterval) {
const duration = parseDuration(alertObject.schedule.interval);
const minimumDuration = parseDuration(serverRuleType.minimumScheduleInterval);
if (duration < minimumDuration) {
errors.interval.push(
i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.belowMinimumText', {
defaultMessage: 'Interval is below minimum ({minimum}) for this rule type',
values: {
minimum: serverRuleType.minimumScheduleInterval,
},
})
);
}
}
if (!alertObject.alertTypeId) {
errors.alertTypeId.push(
i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredRuleTypeIdText', {
defaultMessage: 'Rule type is required.',
})
);
}
const emptyConnectorActions = alertObject.actions.find(
(actionItem) => /^\d+$/.test(actionItem.id) && Object.keys(actionItem.params).length > 0
);
if (emptyConnectorActions !== undefined) {
errors.actionConnectors.push(
i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredActionConnector', {
defaultMessage: 'Action for {actionTypeId} connector is required.',
values: { actionTypeId: emptyConnectorActions.actionTypeId },
})
);
}
return validationResult;
}
export function getAlertErrors(
alert: Alert,
alertTypeModel: AlertTypeModel | null,
serverRuleType?: AlertType<string, string>
) {
const alertParamsErrors: IErrorObject = alertTypeModel
? alertTypeModel.validate(alert.params).errors
: [];
const alertBaseErrors = validateBaseProperties(alert, serverRuleType).errors as IErrorObject;
const alertErrors = {
...alertParamsErrors,
...alertBaseErrors,
} as IErrorObject;
return {
alertParamsErrors,
alertBaseErrors,
alertErrors,
};
}
export async function getAlertActionErrors(
alert: Alert,
actionTypeRegistry: ActionTypeRegistryContract
): Promise<IErrorObject[]> {
return await Promise.all(
alert.actions.map(
async (alertAction: AlertAction) =>
(
await actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params)
).errors
)
);
}
export const hasObjectErrors: (errors: IErrorObject) => boolean = (errors) =>
!!Object.values(errors).find((errorList) => {
if (isObject(errorList)) return hasObjectErrors(errorList as IErrorObject);
return errorList.length >= 1;
});
export function isValidAlert(
alertObject: InitialAlert | Alert,
validationResult: IErrorObject,
actionsErrors: IErrorObject[]
): alertObject is Alert {
return (
!hasObjectErrors(validationResult) &&
actionsErrors.every((error: IErrorObject) => !hasObjectErrors(error))
);
}

View file

@ -35,7 +35,7 @@ import {
EuiToolTip,
EuiCallOut,
} from '@elastic/eui';
import { capitalize, isObject } from 'lodash';
import { capitalize } from 'lodash';
import { KibanaFeature } from '../../../../../features/public';
import {
getDurationNumberInItsUnit,
@ -48,9 +48,8 @@ import {
Alert,
IErrorObject,
AlertAction,
AlertTypeIndex,
RuleTypeIndex,
AlertType,
ValidationResult,
RuleTypeRegistryContract,
ActionTypeRegistryContract,
} from '../../../types';
@ -74,101 +73,10 @@ import { checkAlertTypeEnabled } from '../../lib/check_alert_type_enabled';
import { alertTypeCompare, alertTypeGroupCompare } from '../../lib/alert_type_compare';
import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants';
import { SectionLoading } from '../../components/section_loading';
import { DEFAULT_ALERT_INTERVAL } from '../../constants';
const ENTER_KEY = 13;
export function validateBaseProperties(alertObject: InitialAlert): ValidationResult {
const validationResult = { errors: {} };
const errors = {
name: new Array<string>(),
interval: new Array<string>(),
alertTypeId: new Array<string>(),
actionConnectors: new Array<string>(),
};
validationResult.errors = errors;
if (!alertObject.name) {
errors.name.push(
i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredNameText', {
defaultMessage: 'Name is required.',
})
);
}
if (alertObject.schedule.interval.length < 2) {
errors.interval.push(
i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredIntervalText', {
defaultMessage: 'Check interval is required.',
})
);
}
if (!alertObject.alertTypeId) {
errors.alertTypeId.push(
i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredRuleTypeIdText', {
defaultMessage: 'Rule type is required.',
})
);
}
const emptyConnectorActions = alertObject.actions.find(
(actionItem) => /^\d+$/.test(actionItem.id) && Object.keys(actionItem.params).length > 0
);
if (emptyConnectorActions !== undefined) {
errors.actionConnectors.push(
i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredActionConnector', {
defaultMessage: 'Action for {actionTypeId} connector is required.',
values: { actionTypeId: emptyConnectorActions.actionTypeId },
})
);
}
return validationResult;
}
export function getAlertErrors(alert: Alert, alertTypeModel: AlertTypeModel | null) {
const alertParamsErrors: IErrorObject = alertTypeModel
? alertTypeModel.validate(alert.params).errors
: [];
const alertBaseErrors = validateBaseProperties(alert).errors as IErrorObject;
const alertErrors = {
...alertParamsErrors,
...alertBaseErrors,
} as IErrorObject;
return {
alertParamsErrors,
alertBaseErrors,
alertErrors,
};
}
export async function getAlertActionErrors(
alert: Alert,
actionTypeRegistry: ActionTypeRegistryContract
): Promise<IErrorObject[]> {
return await Promise.all(
alert.actions.map(
async (alertAction: AlertAction) =>
(
await actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params)
).errors
)
);
}
export const hasObjectErrors: (errors: IErrorObject) => boolean = (errors) =>
!!Object.values(errors).find((errorList) => {
if (isObject(errorList)) return hasObjectErrors(errorList as IErrorObject);
return errorList.length >= 1;
});
export function isValidAlert(
alertObject: InitialAlert | Alert,
validationResult: IErrorObject,
actionsErrors: IErrorObject[]
): alertObject is Alert {
return (
!hasObjectErrors(validationResult) &&
actionsErrors.every((error: IErrorObject) => !hasObjectErrors(error))
);
}
function getProducerFeatureName(producer: string, kibanaFeatures: KibanaFeature[]) {
return kibanaFeatures.find((featureItem) => featureItem.id === producer)?.name;
}
@ -186,6 +94,9 @@ interface AlertFormProps<MetaData = Record<string, any>> {
metadata?: MetaData;
}
const defaultScheduleInterval = getDurationNumberInItsUnit(DEFAULT_ALERT_INTERVAL);
const defaultScheduleIntervalUnit = getDurationUnitValue(DEFAULT_ALERT_INTERVAL);
export const AlertForm = ({
alert,
canChangeTrigger = true,
@ -212,10 +123,14 @@ export const AlertForm = ({
const [alertTypeModel, setAlertTypeModel] = useState<AlertTypeModel | null>(null);
const [alertInterval, setAlertInterval] = useState<number | undefined>(
alert.schedule.interval ? getDurationNumberInItsUnit(alert.schedule.interval) : undefined
alert.schedule.interval
? getDurationNumberInItsUnit(alert.schedule.interval)
: defaultScheduleInterval
);
const [alertIntervalUnit, setAlertIntervalUnit] = useState<string>(
alert.schedule.interval ? getDurationUnitValue(alert.schedule.interval) : 'm'
alert.schedule.interval
? getDurationUnitValue(alert.schedule.interval)
: defaultScheduleIntervalUnit
);
const [alertThrottle, setAlertThrottle] = useState<number | null>(
alert.throttle ? getDurationNumberInItsUnit(alert.throttle) : null
@ -224,7 +139,7 @@ export const AlertForm = ({
alert.throttle ? getDurationUnitValue(alert.throttle) : 'h'
);
const [defaultActionGroupId, setDefaultActionGroupId] = useState<string | undefined>(undefined);
const [alertTypesIndex, setAlertTypesIndex] = useState<AlertTypeIndex | null>(null);
const [ruleTypeIndex, setRuleTypeIndex] = useState<RuleTypeIndex | null>(null);
const [availableAlertTypes, setAvailableAlertTypes] = useState<
Array<{ alertTypeModel: AlertTypeModel; alertType: AlertType }>
@ -243,14 +158,14 @@ export const AlertForm = ({
(async () => {
try {
const alertTypesResult = await loadAlertTypes({ http });
const index: AlertTypeIndex = new Map();
const index: RuleTypeIndex = new Map();
for (const alertTypeItem of alertTypesResult) {
index.set(alertTypeItem.id, alertTypeItem);
}
if (alert.alertTypeId && index.has(alert.alertTypeId)) {
setDefaultActionGroupId(index.get(alert.alertTypeId)!.defaultActionGroupId);
}
setAlertTypesIndex(index);
setRuleTypeIndex(index);
const availableAlertTypesResult = getAvailableAlertTypes(alertTypesResult);
setAvailableAlertTypes(availableAlertTypesResult);
@ -287,10 +202,24 @@ export const AlertForm = ({
useEffect(() => {
setAlertTypeModel(alert.alertTypeId ? ruleTypeRegistry.get(alert.alertTypeId) : null);
if (alert.alertTypeId && alertTypesIndex && alertTypesIndex.has(alert.alertTypeId)) {
setDefaultActionGroupId(alertTypesIndex.get(alert.alertTypeId)!.defaultActionGroupId);
if (alert.alertTypeId && ruleTypeIndex && ruleTypeIndex.has(alert.alertTypeId)) {
setDefaultActionGroupId(ruleTypeIndex.get(alert.alertTypeId)!.defaultActionGroupId);
}
}, [alert, alert.alertTypeId, alertTypesIndex, ruleTypeRegistry]);
}, [alert, alert.alertTypeId, ruleTypeIndex, ruleTypeRegistry]);
useEffect(() => {
if (alert.schedule.interval) {
const interval = getDurationNumberInItsUnit(alert.schedule.interval);
const intervalUnit = getDurationUnitValue(alert.schedule.interval);
if (interval !== defaultScheduleInterval) {
setAlertInterval(interval);
}
if (intervalUnit !== defaultScheduleIntervalUnit) {
setAlertIntervalUnit(intervalUnit);
}
}
}, [alert.schedule.interval]);
const setAlertProperty = useCallback(
<Key extends keyof Alert>(key: Key, value: Alert[Key] | null) => {
@ -372,9 +301,7 @@ export const AlertForm = ({
? !item.alertTypeModel.requiresAppContext
: item.alertType!.producer === alert.consumer
);
const selectedAlertType = alert?.alertTypeId
? alertTypesIndex?.get(alert?.alertTypeId)
: undefined;
const selectedAlertType = alert?.alertTypeId ? ruleTypeIndex?.get(alert?.alertTypeId) : undefined;
const recoveryActionGroup = selectedAlertType?.recoveryActionGroup?.id;
const getDefaultActionParams = useCallback(
(actionTypeId: string, actionGroupId: string): Record<string, AlertActionParam> | undefined =>
@ -499,8 +426,8 @@ export const AlertForm = ({
setActions([]);
setAlertTypeModel(item.alertTypeItem);
setAlertProperty('params', {});
if (alertTypesIndex && alertTypesIndex.has(item.id)) {
setDefaultActionGroupId(alertTypesIndex.get(item.id)!.defaultActionGroupId);
if (ruleTypeIndex && ruleTypeIndex.has(item.id)) {
setDefaultActionGroupId(ruleTypeIndex.get(item.id)!.defaultActionGroupId);
}
}}
/>
@ -518,8 +445,8 @@ export const AlertForm = ({
<EuiFlexItem>
<EuiTitle size="s" data-test-subj="selectedAlertTypeTitle">
<h5 id="selectedAlertTypeTitle">
{alert.alertTypeId && alertTypesIndex && alertTypesIndex.has(alert.alertTypeId)
? alertTypesIndex.get(alert.alertTypeId)!.name
{alert.alertTypeId && ruleTypeIndex && ruleTypeIndex.has(alert.alertTypeId)
? ruleTypeIndex.get(alert.alertTypeId)!.name
: ''}
</h5>
</EuiTitle>
@ -870,7 +797,7 @@ export const AlertForm = ({
) : null}
{alertTypeNodes}
</>
) : alertTypesIndex ? (
) : ruleTypeIndex ? (
<NoAuthorizedAlertTypes operation={operation} />
) : (
<SectionLoading>

View file

@ -33,7 +33,14 @@ import {
import { useHistory } from 'react-router-dom';
import { isEmpty } from 'lodash';
import { ActionType, Alert, AlertTableItem, AlertTypeIndex, Pagination } from '../../../../types';
import {
ActionType,
Alert,
AlertTableItem,
AlertType,
RuleTypeIndex,
Pagination,
} from '../../../../types';
import { AlertAdd, AlertEdit } from '../../alert_form';
import { BulkOperationPopover } from '../../common/components/bulk_operation_popover';
import { AlertQuickEditButtonsWithApi as AlertQuickEditButtons } from '../../common/components/alert_quick_edit_buttons';
@ -74,7 +81,7 @@ const ENTER_KEY = 13;
interface AlertTypeState {
isLoading: boolean;
isInitialized: boolean;
data: AlertTypeIndex;
data: RuleTypeIndex;
}
interface AlertState {
isLoading: boolean;
@ -161,7 +168,7 @@ export const AlertsList: React.FunctionComponent = () => {
try {
setAlertTypesState({ ...alertTypesState, isLoading: true });
const alertTypes = await loadAlertTypes({ http });
const index: AlertTypeIndex = new Map();
const index: RuleTypeIndex = new Map();
for (const alertType of alertTypes) {
index.set(alertType.id, alertType);
}
@ -895,6 +902,7 @@ export const AlertsList: React.FunctionComponent = () => {
}}
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
ruleTypeIndex={alertTypesState.data}
onSave={loadAlertsData}
/>
)}
@ -906,6 +914,9 @@ export const AlertsList: React.FunctionComponent = () => {
}}
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
ruleType={
alertTypesState.data.get(currentRuleToEdit.alertTypeId) as AlertType<string, string>
}
onSave={loadAlertsData}
/>
)}
@ -944,17 +955,17 @@ function filterAlertsById(alerts: Alert[], ids: string[]): Alert[] {
function convertAlertsToTableItems(
alerts: Alert[],
alertTypesIndex: AlertTypeIndex,
ruleTypeIndex: RuleTypeIndex,
canExecuteActions: boolean
) {
return alerts.map((alert) => ({
...alert,
actionsCount: alert.actions.length,
tagsText: alert.tags.join(', '),
alertType: alertTypesIndex.get(alert.alertTypeId)?.name ?? alert.alertTypeId,
alertType: ruleTypeIndex.get(alert.alertTypeId)?.name ?? alert.alertTypeId,
isEditable:
hasAllPrivilege(alert, alertTypesIndex.get(alert.alertTypeId)) &&
hasAllPrivilege(alert, ruleTypeIndex.get(alert.alertTypeId)) &&
(canExecuteActions || (!canExecuteActions && !alert.actions.length)),
enabledInLicense: !!alertTypesIndex.get(alert.alertTypeId)?.enabledInLicense,
enabledInLicense: !!ruleTypeIndex.get(alert.alertTypeId)?.enabledInLicense,
}));
}

View file

@ -66,7 +66,7 @@ export {
};
export type ActionTypeIndex = Record<string, ActionType>;
export type AlertTypeIndex = Map<string, AlertType>;
export type RuleTypeIndex = Map<string, AlertType>;
export type ActionTypeRegistryContract<
ActionConnector = unknown,
ActionParams = unknown
@ -197,6 +197,8 @@ export interface AlertType<
| 'minimumLicenseRequired'
| 'recoveryActionGroup'
| 'defaultActionGroupId'
| 'defaultScheduleInterval'
| 'minimumScheduleInterval'
> {
actionVariables: ActionVariables;
authorizedConsumers: Record<string, { read: boolean; all: boolean }>;
@ -285,6 +287,7 @@ export interface AlertEditProps<MetaData = Record<string, any>> {
reloadAlerts?: () => Promise<void>;
onSave?: () => Promise<void>;
metadata?: MetaData;
ruleType?: AlertType<string, string>;
}
export interface AlertAddProps<MetaData = Record<string, any>> {
@ -299,4 +302,5 @@ export interface AlertAddProps<MetaData = Record<string, any>> {
reloadAlerts?: () => Promise<void>;
onSave?: () => Promise<void>;
metadata?: MetaData;
ruleTypeIndex?: RuleTypeIndex;
}