[RAM] Improve rule interval circuit breaker error message (#168173)

## Summary
Improve rule interval circuit breaker error message to the following
endpoints:

- create
- update
- bulk edit
- bulk enable

### Bulk modification
![Screenshot from 2023-10-06
10-11-24](11271221-4d92-41a4-9c0a-f2f8972c452e)

### Modifying a single rule
![Screenshot from 2023-10-06
10-12-16](4ad5f482-6b68-4eef-8989-3f0013c218b2)


### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
This commit is contained in:
Jiawei Wu 2023-10-14 02:22:25 +09:00 committed by GitHub
parent e76e589cb4
commit c66a86b07e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 536 additions and 119 deletions

View file

@ -18,18 +18,24 @@ export const RuleStatusDropdownSandbox = ({ triggersActionsUi }: SandboxProps) =
const [isSnoozedUntil, setIsSnoozedUntil] = useState<Date | null>(null);
const [muteAll, setMuteAll] = useState(false);
const onEnableRule: any = async () => {
setEnabled(true);
setMuteAll(false);
setIsSnoozedUntil(null);
};
const onDisableRule: any = async () => {
setEnabled(false);
};
return triggersActionsUi.getRuleStatusDropdown({
rule: {
enabled,
isSnoozedUntil,
muteAll,
},
enableRule: async () => {
setEnabled(true);
setMuteAll(false);
setIsSnoozedUntil(null);
},
disableRule: async () => setEnabled(false),
enableRule: onEnableRule,
disableRule: onDisableRule,
snoozeRule: async (schedule) => {
if (schedule.duration === -1) {
setIsSnoozedUntil(null);

View file

@ -37,6 +37,7 @@ export * from './rrule_type';
export * from './rule_tags_aggregation';
export * from './iso_weekdays';
export * from './saved_objects/rules/mappings';
export * from './rule_circuit_breaker_error_message';
export type {
MaintenanceWindowModificationMetadata,

View file

@ -0,0 +1,70 @@
/*
* 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 {
getRuleCircuitBreakerErrorMessage,
parseRuleCircuitBreakerErrorMessage,
} from './rule_circuit_breaker_error_message';
describe('getRuleCircuitBreakerErrorMessage', () => {
it('should return the correct message', () => {
expect(
getRuleCircuitBreakerErrorMessage({
name: 'test rule',
action: 'create',
interval: 5,
intervalAvailable: 4,
})
).toMatchInlineSnapshot(
`"Error validating circuit breaker - Rule 'test rule' cannot be created. The maximum number of runs per minute would be exceeded. - The rule has 5 runs per minute; there are only 4 runs per minute available. Before you can modify this rule, you must increase its check interval so that it runs less frequently. Alternatively, disable other rules or change their check intervals."`
);
expect(
getRuleCircuitBreakerErrorMessage({
name: 'test rule',
action: 'update',
interval: 1,
intervalAvailable: 1,
})
).toMatchInlineSnapshot(
`"Error validating circuit breaker - Rule 'test rule' cannot be updated. The maximum number of runs per minute would be exceeded. - The rule has 1 run per minute; there is only 1 run per minute available. Before you can modify this rule, you must increase its check interval so that it runs less frequently. Alternatively, disable other rules or change their check intervals."`
);
expect(
getRuleCircuitBreakerErrorMessage({
name: 'test rule',
action: 'bulkEdit',
interval: 1,
intervalAvailable: 1,
rules: 5,
})
).toMatchInlineSnapshot(
`"Error validating circuit breaker - Rules cannot be bulk edited. The maximum number of runs per minute would be exceeded. - The rules have 1 run per minute; there is only 1 run per minute available. Before you can modify these rules, you must disable other rules or change their check intervals so they run less frequently."`
);
});
it('should parse the error message', () => {
const message = getRuleCircuitBreakerErrorMessage({
name: 'test rule',
action: 'create',
interval: 5,
intervalAvailable: 4,
});
const parsedMessage = parseRuleCircuitBreakerErrorMessage(message);
expect(parsedMessage.summary).toContain("Rule 'test rule' cannot be created");
expect(parsedMessage.details).toContain('The rule has 5 runs per minute');
});
it('should passthrough the message if it is not related to circuit breakers', () => {
const parsedMessage = parseRuleCircuitBreakerErrorMessage('random message');
expect(parsedMessage.summary).toEqual('random message');
expect(parsedMessage.details).toBeUndefined();
});
});

View file

@ -0,0 +1,136 @@
/*
* 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 { i18n } from '@kbn/i18n';
const errorMessageHeader = 'Error validating circuit breaker';
const getCreateRuleErrorSummary = (name: string) => {
return i18n.translate('xpack.alerting.ruleCircuitBreaker.error.createSummary', {
defaultMessage: `Rule '{name}' cannot be created. The maximum number of runs per minute would be exceeded.`,
values: {
name,
},
});
};
const getUpdateRuleErrorSummary = (name: string) => {
return i18n.translate('xpack.alerting.ruleCircuitBreaker.error.updateSummary', {
defaultMessage: `Rule '{name}' cannot be updated. The maximum number of runs per minute would be exceeded.`,
values: {
name,
},
});
};
const getEnableRuleErrorSummary = (name: string) => {
return i18n.translate('xpack.alerting.ruleCircuitBreaker.error.enableSummary', {
defaultMessage: `Rule '{name}' cannot be enabled. The maximum number of runs per minute would be exceeded.`,
values: {
name,
},
});
};
const getBulkEditRuleErrorSummary = () => {
return i18n.translate('xpack.alerting.ruleCircuitBreaker.error.bulkEditSummary', {
defaultMessage: `Rules cannot be bulk edited. The maximum number of runs per minute would be exceeded.`,
});
};
const getBulkEnableRuleErrorSummary = () => {
return i18n.translate('xpack.alerting.ruleCircuitBreaker.error.bulkEnableSummary', {
defaultMessage: `Rules cannot be bulk enabled. The maximum number of runs per minute would be exceeded.`,
});
};
const getRuleCircuitBreakerErrorDetail = ({
interval,
intervalAvailable,
rules,
}: {
interval: number;
intervalAvailable: number;
rules: number;
}) => {
if (rules === 1) {
return i18n.translate('xpack.alerting.ruleCircuitBreaker.error.ruleDetail', {
defaultMessage: `The rule has {interval, plural, one {{interval} run} other {{interval} runs}} per minute; there {intervalAvailable, plural, one {is only {intervalAvailable} run} other {are only {intervalAvailable} runs}} per minute available. Before you can modify this rule, you must increase its check interval so that it runs less frequently. Alternatively, disable other rules or change their check intervals.`,
values: {
interval,
intervalAvailable,
},
});
}
return i18n.translate('xpack.alerting.ruleCircuitBreaker.error.multipleRuleDetail', {
defaultMessage: `The rules have {interval, plural, one {{interval} run} other {{interval} runs}} per minute; there {intervalAvailable, plural, one {is only {intervalAvailable} run} other {are only {intervalAvailable} runs}} per minute available. Before you can modify these rules, you must disable other rules or change their check intervals so they run less frequently.`,
values: {
interval,
intervalAvailable,
},
});
};
export const getRuleCircuitBreakerErrorMessage = ({
name = '',
interval,
intervalAvailable,
action,
rules = 1,
}: {
name?: string;
interval: number;
intervalAvailable: number;
action: 'update' | 'create' | 'enable' | 'bulkEdit' | 'bulkEnable';
rules?: number;
}) => {
let errorMessageSummary: string;
switch (action) {
case 'update':
errorMessageSummary = getUpdateRuleErrorSummary(name);
break;
case 'create':
errorMessageSummary = getCreateRuleErrorSummary(name);
break;
case 'enable':
errorMessageSummary = getEnableRuleErrorSummary(name);
break;
case 'bulkEdit':
errorMessageSummary = getBulkEditRuleErrorSummary();
break;
case 'bulkEnable':
errorMessageSummary = getBulkEnableRuleErrorSummary();
break;
}
return `Error validating circuit breaker - ${errorMessageSummary} - ${getRuleCircuitBreakerErrorDetail(
{
interval,
intervalAvailable,
rules,
}
)}`;
};
export const parseRuleCircuitBreakerErrorMessage = (
message: string
): {
summary: string;
details?: string;
} => {
if (!message.includes(errorMessageHeader)) {
return {
summary: message,
};
}
const segments = message.split(' - ');
return {
summary: segments[1],
details: segments[2],
};
};

View file

@ -25,7 +25,7 @@ import {
convertRuleIdsToKueryNode,
} from '../../../../lib';
import { WriteOperations, AlertingAuthorizationEntity } from '../../../../authorization';
import { parseDuration } from '../../../../../common/parse_duration';
import { parseDuration, getRuleCircuitBreakerErrorMessage } from '../../../../../common';
import { bulkMarkApiKeysForInvalidation } from '../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
import { ruleAuditEvent, RuleAuditAction } from '../../../../rules_client/common/audit_events';
import {
@ -77,7 +77,7 @@ import {
transformRuleDomainToRuleAttributes,
transformRuleDomainToRule,
} from '../../transforms';
import { validateScheduleLimit } from '../get_schedule_frequency';
import { validateScheduleLimit, ValidateScheduleLimitResult } from '../get_schedule_frequency';
const isValidInterval = (interval: string | undefined): interval is string => {
return interval !== undefined;
@ -326,15 +326,16 @@ async function bulkEditRulesOcc<Params extends RuleParams>(
.map((rule) => rule.attributes.schedule?.interval)
.filter(isValidInterval);
try {
if (operations.some((operation) => operation.field === 'schedule')) {
await validateScheduleLimit({
context,
prevInterval,
updatedInterval,
});
}
} catch (error) {
let validationPayload: ValidateScheduleLimitResult = null;
if (operations.some((operation) => operation.field === 'schedule')) {
validationPayload = await validateScheduleLimit({
context,
prevInterval,
updatedInterval,
});
}
if (validationPayload) {
return {
apiKeysToInvalidate: Array.from(apiKeysMap.values())
.filter((value) => value.newApiKey)
@ -342,7 +343,13 @@ async function bulkEditRulesOcc<Params extends RuleParams>(
resultSavedObjects: [],
rules: [],
errors: rules.map((rule) => ({
message: `Failed to bulk edit rule - ${error.message}`,
message: getRuleCircuitBreakerErrorMessage({
name: rule.attributes.name || 'n/a',
interval: validationPayload!.interval,
intervalAvailable: validationPayload!.intervalAvailable,
action: 'bulkEdit',
rules: updatedInterval.length,
}),
rule: {
id: rule.id,
name: rule.attributes.name || 'n/a',

View file

@ -8,7 +8,7 @@ import Semver from 'semver';
import Boom from '@hapi/boom';
import { SavedObject, SavedObjectsUtils } from '@kbn/core/server';
import { withSpan } from '@kbn/apm-utils';
import { parseDuration } from '../../../../../common/parse_duration';
import { parseDuration, getRuleCircuitBreakerErrorMessage } from '../../../../../common';
import { WriteOperations, AlertingAuthorizationEntity } from '../../../../authorization';
import {
validateRuleTypeParams,
@ -36,7 +36,7 @@ import { RuleAttributes } from '../../../../data/rule/types';
import type { CreateRuleData } from './types';
import { createRuleDataSchema } from './schemas';
import { createRuleSavedObject } from '../../../../rules_client/lib';
import { validateScheduleLimit } from '../get_schedule_frequency';
import { validateScheduleLimit, ValidateScheduleLimitResult } from '../get_schedule_frequency';
export interface CreateRuleOptions {
id?: string;
@ -61,16 +61,29 @@ export async function createRule<Params extends RuleParams = never>(
try {
createRuleDataSchema.validate(data);
if (data.enabled) {
await validateScheduleLimit({
context,
updatedInterval: data.schedule.interval,
});
}
} catch (error) {
throw Boom.badRequest(`Error validating create data - ${error.message}`);
}
let validationPayload: ValidateScheduleLimitResult = null;
if (data.enabled) {
validationPayload = await validateScheduleLimit({
context,
updatedInterval: data.schedule.interval,
});
}
if (validationPayload) {
throw Boom.badRequest(
getRuleCircuitBreakerErrorMessage({
name: data.name,
interval: validationPayload!.interval,
intervalAvailable: validationPayload!.intervalAvailable,
action: 'create',
})
);
}
try {
await withSpan({ name: 'authorization.ensureAuthorized', type: 'rules' }, () =>
context.authorization.ensureAuthorized({

View file

@ -183,53 +183,55 @@ describe('validateScheduleLimit', () => {
jest.clearAllMocks();
});
test('should not throw if the updated interval does not exceed limits', () => {
return expect(
validateScheduleLimit({
test('should not return anything if the updated interval does not exceed limits', async () => {
expect(
await validateScheduleLimit({
context,
updatedInterval: ['1m', '1m'],
})
).resolves.toBe(undefined);
).toBeNull();
});
test('should throw if the updated interval exceeds limits', () => {
return expect(
validateScheduleLimit({
test('should return interval if the updated interval exceeds limits', async () => {
expect(
await validateScheduleLimit({
context,
updatedInterval: ['1m', '1m', '1m', '2m'],
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Run limit reached: The rule has 3.5 runs per minute; there are only 3 runs per minute available."`
);
).toEqual({
interval: 3.5,
intervalAvailable: 3,
});
});
test('should not throw if previous interval was modified to be under the limit', () => {
test('should not return anything if previous interval was modified to be under the limit', async () => {
internalSavedObjectsRepository.find.mockResolvedValue(
getMockAggregationResult([{ interval: '1m', count: 6 }])
);
return expect(
validateScheduleLimit({
expect(
await validateScheduleLimit({
context,
prevInterval: ['1m', '1m'],
updatedInterval: ['2m', '2m'],
})
).resolves.toBe(undefined);
).toBeNull();
});
test('should throw if the previous interval was modified to exceed the limit', () => {
test('should return interval if the previous interval was modified to exceed the limit', async () => {
internalSavedObjectsRepository.find.mockResolvedValue(
getMockAggregationResult([{ interval: '1m', count: 5 }])
);
return expect(
validateScheduleLimit({
expect(
await validateScheduleLimit({
context,
prevInterval: ['1m'],
updatedInterval: ['30s'],
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Run limit reached: The rule has 2 runs per minute; there are only 1 runs per minute available."`
);
).toEqual({
interval: 2,
intervalAvailable: 0,
});
});
});

View file

@ -85,7 +85,11 @@ interface ValidateScheduleLimitParams {
updatedInterval: string | string[];
}
export const validateScheduleLimit = async (params: ValidateScheduleLimitParams) => {
export type ValidateScheduleLimitResult = { interval: number; intervalAvailable: number } | null;
export const validateScheduleLimit = async (
params: ValidateScheduleLimitParams
): Promise<ValidateScheduleLimitResult> => {
const { context, prevInterval = [], updatedInterval = [] } = params;
const prevIntervalArray = Array.isArray(prevInterval) ? prevInterval : [prevInterval];
@ -108,8 +112,11 @@ export const validateScheduleLimit = async (params: ValidateScheduleLimitParams)
const computedRemainingSchedulesPerMinute = remainingSchedulesPerMinute + prevSchedulePerMinute;
if (computedRemainingSchedulesPerMinute < updatedSchedulesPerMinute) {
throw new Error(
`Run limit reached: The rule has ${updatedSchedulesPerMinute} runs per minute; there are only ${computedRemainingSchedulesPerMinute} runs per minute available.`
);
return {
interval: updatedSchedulesPerMinute,
intervalAvailable: remainingSchedulesPerMinute,
};
}
return null;
};

View file

@ -7,4 +7,6 @@
export type { GetScheduleFrequencyResult } from './types';
export type { ValidateScheduleLimitResult } from './get_schedule_frequency';
export { getScheduleFrequency, validateScheduleLimit } from './get_schedule_frequency';

View file

@ -19,6 +19,7 @@ import {
buildKueryNodeFilter,
getAndValidateCommonBulkOptions,
} from '../common';
import { getRuleCircuitBreakerErrorMessage } from '../../../common';
import {
getAuthorizationFilter,
checkAuthorizationAndGetTotal,
@ -143,13 +144,18 @@ const bulkEnableRulesWithOCC = async (
.filter((rule) => !rule.attributes.enabled)
.map((rule) => rule.attributes.schedule?.interval);
try {
await validateScheduleLimit({
context,
updatedInterval,
const validationPayload = await validateScheduleLimit({
context,
updatedInterval,
});
if (validationPayload) {
scheduleValidationError = getRuleCircuitBreakerErrorMessage({
interval: validationPayload.interval,
intervalAvailable: validationPayload.intervalAvailable,
action: 'bulkEnable',
rules: updatedInterval.length,
});
} catch (error) {
scheduleValidationError = `Error validating enable rule data - ${error.message}`;
}
await pMap(rulesFinderRules, async (rule) => {

View file

@ -15,6 +15,7 @@ import { ruleAuditEvent, RuleAuditAction } from '../common/audit_events';
import { RulesClientContext } from '../types';
import { updateMeta, createNewAPIKeySet, scheduleTask, migrateLegacyActions } from '../lib';
import { validateScheduleLimit } from '../../application/rule/methods/get_schedule_frequency';
import { getRuleCircuitBreakerErrorMessage } from '../../../common';
export async function enable(context: RulesClientContext, { id }: { id: string }): Promise<void> {
return await retryIfConflicts(
@ -48,13 +49,20 @@ async function enableWithOCC(context: RulesClientContext, { id }: { id: string }
references = alert.references;
}
try {
await validateScheduleLimit({
context,
updatedInterval: attributes.schedule.interval,
});
} catch (error) {
throw Boom.badRequest(`Error validating enable rule data - ${error.message}`);
const validationPayload = await validateScheduleLimit({
context,
updatedInterval: attributes.schedule.interval,
});
if (validationPayload) {
throw Boom.badRequest(
getRuleCircuitBreakerErrorMessage({
name: attributes.name,
interval: validationPayload.interval,
intervalAvailable: validationPayload.intervalAvailable,
action: 'enable',
})
);
}
try {

View file

@ -17,7 +17,7 @@ import {
} from '../../types';
import { validateRuleTypeParams, getRuleNotifyWhenType } from '../../lib';
import { WriteOperations, AlertingAuthorizationEntity } from '../../authorization';
import { parseDuration } from '../../../common/parse_duration';
import { parseDuration, getRuleCircuitBreakerErrorMessage } from '../../../common';
import { retryIfConflicts } from '../../lib/retry_if_conflicts';
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
import { ruleAuditEvent, RuleAuditAction } from '../common/audit_events';
@ -33,7 +33,10 @@ import {
createNewAPIKeySet,
migrateLegacyActions,
} from '../lib';
import { validateScheduleLimit } from '../../application/rule/methods/get_schedule_frequency';
import {
validateScheduleLimit,
ValidateScheduleLimitResult,
} from '../../application/rule/methods/get_schedule_frequency';
type ShouldIncrementRevision = (params?: RuleTypeParams) => boolean;
@ -90,18 +93,27 @@ async function updateWithOCC<Params extends RuleTypeParams>(
}
const {
attributes: { enabled, schedule },
attributes: { enabled, schedule, name },
} = alertSavedObject;
try {
if (enabled && schedule.interval !== data.schedule.interval) {
await validateScheduleLimit({
context,
prevInterval: alertSavedObject.attributes.schedule?.interval,
updatedInterval: data.schedule.interval,
});
}
} catch (error) {
throw Boom.badRequest(`Error validating update data - ${error.message}`);
let validationPayload: ValidateScheduleLimitResult = null;
if (enabled && schedule.interval !== data.schedule.interval) {
validationPayload = await validateScheduleLimit({
context,
prevInterval: alertSavedObject.attributes.schedule?.interval,
updatedInterval: data.schedule.interval,
});
}
if (validationPayload) {
throw Boom.badRequest(
getRuleCircuitBreakerErrorMessage({
name,
interval: validationPayload.interval,
intervalAvailable: validationPayload.intervalAvailable,
action: 'update',
})
);
}
try {

View file

@ -0,0 +1,50 @@
/*
* 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 { i18n } from '@kbn/i18n';
import React, { useState, useCallback } from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
const seeFullErrorMessage = i18n.translate(
'xpack.triggersActionsUI.components.toastWithCircuitBreaker.seeFullError',
{
defaultMessage: 'See full error',
}
);
const hideFullErrorMessage = i18n.translate(
'xpack.triggersActionsUI.components.toastWithCircuitBreaker.hideFullError',
{
defaultMessage: 'Hide full error',
}
);
export const ToastWithCircuitBreakerContent: React.FC = ({ children }) => {
const [showDetails, setShowDetails] = useState(false);
const onToggleShowDetails = useCallback(() => {
setShowDetails((prev) => !prev);
}, []);
return (
<>
{showDetails && (
<>
<EuiText size="s">{children}</EuiText>
<EuiSpacer />
</>
)}
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton size="s" color="danger" onClick={onToggleShowDetails}>
{showDetails ? hideFullErrorMessage : seeFullErrorMessage}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};

View file

@ -51,7 +51,7 @@ jest.mock('../../../../common/lib/kibana', () => ({
}));
const mockAPIs = {
bulkEnableRules: jest.fn(),
bulkEnableRules: jest.fn().mockResolvedValue({ errors: [] }),
bulkDisableRules: jest.fn(),
snoozeRule: jest.fn(),
unsnoozeRule: jest.fn(),
@ -170,7 +170,6 @@ describe('rule status panel', () => {
it('should enable the rule when picking enable in the dropdown', async () => {
const rule = mockRule({ enabled: false });
const bulkEnableRules = jest.fn();
const wrapper = mountWithIntl(
<RuleStatusPanelWithProvider
{...mockAPIs}
@ -179,7 +178,6 @@ describe('rule status panel', () => {
healthColor="primary"
statusMessage="Ok"
requestRefresh={requestRefresh}
bulkEnableRules={bulkEnableRules}
/>
);
const actionsElem = wrapper
@ -199,7 +197,7 @@ describe('rule status panel', () => {
await nextTick();
});
expect(bulkEnableRules).toHaveBeenCalledTimes(1);
expect(mockAPIs.bulkEnableRules).toHaveBeenCalledTimes(1);
});
it('if rule is already enabled should do nothing when picking enable in the dropdown', async () => {

View file

@ -126,12 +126,8 @@ export const RuleStatusPanel: React.FC<RuleStatusPanelWithApiProps> = ({
</EuiFlexItem>
<EuiFlexItem>
<RuleStatusDropdown
disableRule={async () => {
await bulkDisableRules({ ids: [rule.id] });
}}
enableRule={async () => {
await bulkEnableRules({ ids: [rule.id] });
}}
disableRule={() => bulkDisableRules({ ids: [rule.id] })}
enableRule={() => bulkEnableRules({ ids: [rule.id] })}
snoozeRule={async () => {}}
unsnoozeRule={async () => {}}
rule={rule}

View file

@ -10,6 +10,8 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { EuiTitle, EuiFlyoutHeader, EuiFlyout, EuiFlyoutBody, EuiPortal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { parseRuleCircuitBreakerErrorMessage } from '@kbn/alerting-plugin/common';
import {
Rule,
RuleTypeParams,
@ -38,6 +40,14 @@ import { getRuleWithInvalidatedFields } from '../../lib/value_validators';
import { DEFAULT_RULE_INTERVAL } from '../../constants';
import { triggersActionsUiConfig } from '../../../common/lib/config_api';
import { getInitialInterval } from './get_initial_interval';
import { ToastWithCircuitBreakerContent } from '../../components/toast_with_circuit_breaker_content';
const defaultCreateRuleErrorMessage = i18n.translate(
'xpack.triggersActionsUI.sections.ruleAdd.saveErrorNotificationText',
{
defaultMessage: 'Cannot create rule.',
}
);
const RuleAdd = ({
consumer,
@ -238,12 +248,17 @@ const RuleAdd = ({
);
return newRule;
} catch (errorRes) {
toasts.addDanger(
errorRes.body?.message ??
i18n.translate('xpack.triggersActionsUI.sections.ruleAdd.saveErrorNotificationText', {
defaultMessage: 'Cannot create rule.',
})
const message = parseRuleCircuitBreakerErrorMessage(
errorRes.body?.message || defaultCreateRuleErrorMessage
);
toasts.addDanger({
title: message.summary,
...(message.details && {
text: toMountPoint(
<ToastWithCircuitBreakerContent>{message.details}</ToastWithCircuitBreakerContent>
),
}),
});
}
}

View file

@ -219,9 +219,9 @@ describe('rule_edit', () => {
await act(async () => {
wrapper.find('[data-test-subj="saveEditedRuleButton"]').last().simulate('click');
});
expect(useKibanaMock().services.notifications.toasts.addDanger).toHaveBeenCalledWith(
'Fail message'
);
expect(useKibanaMock().services.notifications.toasts.addDanger).toHaveBeenCalledWith({
title: 'Fail message',
});
});
it('should pass in the config into `getRuleErrors`', async () => {

View file

@ -26,6 +26,8 @@ import {
} from '@elastic/eui';
import { cloneDeep, omit } from 'lodash';
import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { parseRuleCircuitBreakerErrorMessage } from '@kbn/alerting-plugin/common';
import {
Rule,
RuleFlyoutCloseReason,
@ -47,6 +49,14 @@ import { ConfirmRuleClose } from './confirm_rule_close';
import { hasRuleChanged } from './has_rule_changed';
import { getRuleWithInvalidatedFields } from '../../lib/value_validators';
import { triggersActionsUiConfig } from '../../../common/lib/config_api';
import { ToastWithCircuitBreakerContent } from '../../components/toast_with_circuit_breaker_content';
const defaultUpdateRuleErrorMessage = i18n.translate(
'xpack.triggersActionsUI.sections.ruleEdit.saveErrorNotificationText',
{
defaultMessage: 'Cannot update rule.',
}
);
const cloneAndMigrateRule = (initialRule: Rule) => {
const clonedRule = cloneDeep(omit(initialRule, 'notifyWhen', 'throttle'));
@ -181,12 +191,17 @@ export const RuleEdit = ({
);
}
} catch (errorRes) {
toasts.addDanger(
errorRes.body?.message ??
i18n.translate('xpack.triggersActionsUI.sections.ruleEdit.saveErrorNotificationText', {
defaultMessage: 'Cannot update rule.',
})
const message = parseRuleCircuitBreakerErrorMessage(
errorRes.body?.message || defaultUpdateRuleErrorMessage
);
toasts.addDanger({
title: message.summary,
...(message.details && {
text: toMountPoint(
<ToastWithCircuitBreakerContent>{message.details}</ToastWithCircuitBreakerContent>
),
}),
});
}
setIsSaving(false);
}

View file

@ -12,6 +12,19 @@ import { RuleStatusDropdown, ComponentOpts } from './rule_status_dropdown';
const NOW_STRING = '2020-03-01T00:00:00.000Z';
const SNOOZE_UNTIL = new Date('2020-03-04T00:00:00.000Z');
jest.mock('../../../../common/lib/kibana', () => ({
useKibana: () => ({
services: {
notifications: {
toasts: {
addSuccess: jest.fn(),
addDanger: jest.fn(),
},
},
},
}),
}));
describe('RuleStatusDropdown', () => {
const enableRule = jest.fn();
const disableRule = jest.fn();

View file

@ -9,6 +9,8 @@ import React, { useState, useEffect, useCallback } from 'react';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import type { RuleSnooze } from '@kbn/alerting-plugin/common';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { parseRuleCircuitBreakerErrorMessage } from '@kbn/alerting-plugin/common';
import {
EuiLoadingSpinner,
EuiPopover,
@ -20,9 +22,11 @@ import {
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { SnoozePanel } from './rule_snooze';
import { isRuleSnoozed } from '../../../lib';
import { Rule, SnoozeSchedule } from '../../../../types';
import { Rule, SnoozeSchedule, BulkOperationResponse } from '../../../../types';
import { ToastWithCircuitBreakerContent } from '../../../components/toast_with_circuit_breaker_content';
export type SnoozeUnit = 'm' | 'h' | 'd' | 'w' | 'M';
const SNOOZE_END_TIME_FORMAT = 'LL @ LT';
@ -35,8 +39,8 @@ type DropdownRuleRecord = Pick<
export interface ComponentOpts {
rule: DropdownRuleRecord;
onRuleChanged: () => void;
enableRule: () => Promise<void>;
disableRule: () => Promise<void>;
enableRule: () => Promise<BulkOperationResponse>;
disableRule: () => Promise<BulkOperationResponse>;
snoozeRule: (snoozeSchedule: SnoozeSchedule) => Promise<void>;
unsnoozeRule: (scheduleIds?: string[]) => Promise<void>;
isEditable: boolean;
@ -58,6 +62,10 @@ export const RuleStatusDropdown: React.FunctionComponent<ComponentOpts> = ({
const [isEnabled, setIsEnabled] = useState<boolean>(rule.enabled);
const [isSnoozed, setIsSnoozed] = useState<boolean>(!hideSnoozeOption && isRuleSnoozed(rule));
const {
notifications: { toasts },
} = useKibana().services;
useEffect(() => {
setIsEnabled(rule.enabled);
}, [rule.enabled]);
@ -70,6 +78,25 @@ export const RuleStatusDropdown: React.FunctionComponent<ComponentOpts> = ({
const onClickBadge = useCallback(() => setIsPopoverOpen((isOpen) => !isOpen), [setIsPopoverOpen]);
const onClosePopover = useCallback(() => setIsPopoverOpen(false), [setIsPopoverOpen]);
const enableRuleInternal = useCallback(async () => {
const { errors } = await enableRule();
if (!errors.length) {
return;
}
const message = parseRuleCircuitBreakerErrorMessage(errors[0].message);
toasts.addDanger({
title: message.summary,
...(message.details && {
text: toMountPoint(
<ToastWithCircuitBreakerContent>{message.details}</ToastWithCircuitBreakerContent>
),
}),
});
throw new Error();
}, [enableRule, toasts]);
const onChangeEnabledStatus = useCallback(
async (enable: boolean) => {
if (rule.enabled === enable) {
@ -78,7 +105,7 @@ export const RuleStatusDropdown: React.FunctionComponent<ComponentOpts> = ({
setIsUpdating(true);
try {
if (enable) {
await enableRule();
await enableRuleInternal();
} else {
await disableRule();
}
@ -88,7 +115,7 @@ export const RuleStatusDropdown: React.FunctionComponent<ComponentOpts> = ({
setIsUpdating(false);
}
},
[rule.enabled, isEnabled, onRuleChanged, enableRule, disableRule]
[rule.enabled, isEnabled, onRuleChanged, enableRuleInternal, disableRule]
);
const onSnoozeRule = useCallback(

View file

@ -11,6 +11,8 @@ import { i18n } from '@kbn/i18n';
import { capitalize, isEmpty, isEqual, sortBy } from 'lodash';
import { KueryNode } from '@kbn/es-query';
import { FormattedMessage } from '@kbn/i18n-react';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { parseRuleCircuitBreakerErrorMessage } from '@kbn/alerting-plugin/common';
import React, {
lazy,
useEffect,
@ -90,6 +92,7 @@ import { useLoadRuleAggregationsQuery } from '../../../hooks/use_load_rule_aggre
import { useLoadRuleTypesQuery } from '../../../hooks/use_load_rule_types_query';
import { useLoadRulesQuery } from '../../../hooks/use_load_rules_query';
import { useLoadConfigQuery } from '../../../hooks/use_load_config_query';
import { ToastWithCircuitBreakerContent } from '../../../components/toast_with_circuit_breaker_content';
import {
getConfirmDeletionButtonText,
@ -550,15 +553,15 @@ export const RulesList = ({
};
const onDisableRule = useCallback(
async (rule: RuleTableItem) => {
await bulkDisableRules({ http, ids: [rule.id] });
(rule: RuleTableItem) => {
return bulkDisableRules({ http, ids: [rule.id] });
},
[bulkDisableRules]
);
const onEnableRule = useCallback(
async (rule: RuleTableItem) => {
await bulkEnableRules({ http, ids: [rule.id] });
(rule: RuleTableItem) => {
return bulkEnableRules({ http, ids: [rule.id] });
},
[bulkEnableRules]
);
@ -675,7 +678,23 @@ export const RulesList = ({
: await bulkEnableRules({ http, ids: selectedIds });
setIsEnablingRules(false);
showToast({ action: 'ENABLE', errors, total });
const circuitBreakerError = errors.find(
(error) => !!parseRuleCircuitBreakerErrorMessage(error.message).details
);
if (circuitBreakerError) {
const parsedError = parseRuleCircuitBreakerErrorMessage(circuitBreakerError.message);
toasts.addDanger({
title: parsedError.summary,
text: toMountPoint(
<ToastWithCircuitBreakerContent>{parsedError.details}</ToastWithCircuitBreakerContent>
),
});
} else {
showToast({ action: 'ENABLE', errors, total });
}
await refreshRules();
onClearSelection();
};

View file

@ -50,6 +50,7 @@ import {
TriggersActionsUiConfig,
RuleTypeRegistryContract,
SnoozeSchedule,
BulkOperationResponse,
} from '../../../../types';
import { DEFAULT_NUMBER_FORMAT } from '../../../constants';
import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils';
@ -125,8 +126,8 @@ export interface RulesListTableProps {
onTagClose?: (rule: RuleTableItem) => void;
onPercentileOptionsChange?: (options: EuiSelectableOption[]) => void;
onRuleChanged: () => Promise<void>;
onEnableRule: (rule: RuleTableItem) => Promise<void>;
onDisableRule: (rule: RuleTableItem) => Promise<void>;
onEnableRule: (rule: RuleTableItem) => Promise<BulkOperationResponse>;
onDisableRule: (rule: RuleTableItem) => Promise<BulkOperationResponse>;
onSnoozeRule: (rule: RuleTableItem, snoozeSchedule: SnoozeSchedule) => Promise<void>;
onUnsnoozeRule: (rule: RuleTableItem, scheduleIds?: string[]) => Promise<void>;
onSelectAll: () => void;
@ -193,8 +194,8 @@ export const RulesListTable = (props: RulesListTableProps) => {
onManageLicenseClick = EMPTY_HANDLER,
onPercentileOptionsChange = EMPTY_HANDLER,
onRuleChanged,
onEnableRule = EMPTY_HANDLER,
onDisableRule = EMPTY_HANDLER,
onEnableRule,
onDisableRule,
onSnoozeRule = EMPTY_HANDLER,
onUnsnoozeRule = EMPTY_HANDLER,
onSelectAll = EMPTY_HANDLER,

View file

@ -62,7 +62,7 @@ export default function bulkEditWithCircuitBreakerTests({ getService }: FtrProvi
expect(body.errors.length).eql(2);
expect(body.errors[0].message).eql(
'Failed to bulk edit rule - Run limit reached: The rule has 12 runs per minute; there are only 1 runs per minute available.'
'Error validating circuit breaker - Rules cannot be bulk edited. The maximum number of runs per minute would be exceeded. - The rules have 12 runs per minute; there is only 1 run per minute available. Before you can modify these rules, you must disable other rules or change their check intervals so they run less frequently.'
);
});

View file

@ -59,7 +59,7 @@ export default function bulkEnableWithCircuitBreakerTests({ getService }: FtrPro
expect(body.errors.length).eql(2);
expect(body.errors[0].message).eql(
'Error validating enable rule data - Run limit reached: The rule has 9 runs per minute; there are only 4 runs per minute available.'
'Error validating circuit breaker - Rules cannot be bulk enabled. The maximum number of runs per minute would be exceeded. - The rules have 9 runs per minute; there are only 4 runs per minute available. Before you can modify these rules, you must disable other rules or change their check intervals so they run less frequently.'
);
});
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
import { getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../../common/lib';
@ -26,11 +27,17 @@ export default function createWithCircuitBreakerTests({ getService }: FtrProvide
.expect(200);
objectRemover.add('space1', createdRule.id, 'rule', 'alerting');
await supertest
const {
body: { message },
} = await supertest
.post(`${getUrlPrefix('space1')}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData({ schedule: { interval: '10s' } }))
.expect(400);
expect(message).eql(
`Error validating circuit breaker - Rule 'abc' cannot be created. The maximum number of runs per minute would be exceeded. - The rule has 6 runs per minute; there are only 4 runs per minute available. Before you can modify this rule, you must increase its check interval so that it runs less frequently. Alternatively, disable other rules or change their check intervals.`
);
});
it('should prevent rules from being created across spaces', async () => {
@ -41,11 +48,17 @@ export default function createWithCircuitBreakerTests({ getService }: FtrProvide
.expect(200);
objectRemover.add('space1', createdRule.id, 'rule', 'alerting');
await supertest
const {
body: { message },
} = await supertest
.post(`${getUrlPrefix('space2')}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData({ schedule: { interval: '10s' } }))
.expect(400);
expect(message).eql(
`Error validating circuit breaker - Rule 'abc' cannot be created. The maximum number of runs per minute would be exceeded. - The rule has 6 runs per minute; there are only 4 runs per minute available. Before you can modify this rule, you must increase its check interval so that it runs less frequently. Alternatively, disable other rules or change their check intervals.`
);
});
it('should allow disabled rules to go over the circuit breaker', async () => {

View file

@ -45,7 +45,7 @@ export default function enableWithCircuitBreakerTests({ getService }: FtrProvide
.expect(400);
expect(body.message).eql(
'Error validating enable rule data - Run limit reached: The rule has 12 runs per minute; there are only 4 runs per minute available.'
`Error validating circuit breaker - Rule 'abc' cannot be enabled. The maximum number of runs per minute would be exceeded. - The rule has 12 runs per minute; there are only 4 runs per minute available. Before you can modify this rule, you must increase its check interval so that it runs less frequently. Alternatively, disable other rules or change their check intervals.`
);
});
});

View file

@ -53,7 +53,7 @@ export default function updateWithCircuitBreakerTests({ getService }: FtrProvide
.expect(400);
expect(body.message).eql(
'Error validating update data - Run limit reached: The rule has 12 runs per minute; there are only 7 runs per minute available.'
`Error validating circuit breaker - Rule 'abc' cannot be updated. The maximum number of runs per minute would be exceeded. - The rule has 12 runs per minute; there are only 4 runs per minute available. Before you can modify this rule, you must increase its check interval so that it runs less frequently. Alternatively, disable other rules or change their check intervals.`
);
});