[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:
parent
9d498b962c
commit
e32dd1c493
21 changed files with 822 additions and 136 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -21,6 +21,8 @@ export interface AlertType<
|
|||
producer: string;
|
||||
minimumLicenseRequired: LicenseType;
|
||||
isExportable: boolean;
|
||||
defaultScheduleInterval?: string;
|
||||
minimumScheduleInterval?: string;
|
||||
}
|
||||
|
||||
export interface ActionGroup<ActionGroupIds extends string> {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -36,3 +36,5 @@ export enum SORT_ORDERS {
|
|||
}
|
||||
|
||||
export const DEFAULT_SEARCH_PAGE_SIZE: number = 10;
|
||||
|
||||
export const DEFAULT_ALERT_INTERVAL = '1m';
|
||||
|
|
|
@ -171,6 +171,7 @@ export const AlertDetails: React.FunctionComponent<AlertDetailsProps> = ({
|
|||
}}
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
ruleTypeRegistry={ruleTypeRegistry}
|
||||
ruleType={alertType}
|
||||
onSave={setAlert}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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))
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue