diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index 58677141ab0c8..caa6512955f67 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -155,6 +155,15 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is updating an alert. | `failure` | User is not authorized to update an alert. +.2+| `rule_snooze` +| `unknown` | User is snoozing a rule. +| `failure` | User is not authorized to snooze a rule. + +.2+| `rule_unsnooze` +| `unknown` | User is unsnoozing a rule. +| `failure` | User is not authorized to unsnooze a rule. + + 3+a| ====== Type: deletion diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts index 546fd3e4aed9a..f7b154777baa4 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts @@ -46,6 +46,7 @@ export enum WriteOperations { MuteAlert = 'muteAlert', UnmuteAlert = 'unmuteAlert', Snooze = 'snooze', + Unsnooze = 'unsnooze', } export interface EnsureAuthorizedOpts { diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index ed1a9583cc75c..e03e726bb2b2c 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -31,6 +31,7 @@ import { unmuteAllRuleRoute } from './unmute_all_rule'; import { unmuteAlertRoute } from './unmute_alert'; import { updateRuleApiKeyRoute } from './update_rule_api_key'; import { snoozeRuleRoute } from './snooze_rule'; +import { unsnoozeRuleRoute } from './unsnooze_rule'; export interface RouteOptions { router: IRouter; @@ -65,4 +66,5 @@ export function defineRoutes(opts: RouteOptions) { unmuteAlertRoute(router, licenseState); updateRuleApiKeyRoute(router, licenseState); snoozeRuleRoute(router, licenseState); + unsnoozeRuleRoute(router, licenseState); } diff --git a/x-pack/plugins/alerting/server/routes/snooze_rule.test.ts b/x-pack/plugins/alerting/server/routes/snooze_rule.test.ts index 567ff3a5653d6..dbcce10cc8e3e 100644 --- a/x-pack/plugins/alerting/server/routes/snooze_rule.test.ts +++ b/x-pack/plugins/alerting/server/routes/snooze_rule.test.ts @@ -21,17 +21,10 @@ beforeEach(() => { jest.resetAllMocks(); }); -const SNOOZE_END_TIME = '2025-03-07T00:00:00.000Z'; +// These tests don't test for future snooze time validation, so this date doesn't need to be in the future +const SNOOZE_END_TIME = '2021-03-07T00:00:00.000Z'; describe('snoozeAlertRoute', () => { - beforeAll(() => { - jest.useFakeTimers('modern'); - jest.setSystemTime(new Date(2020, 3, 1)); - }); - - afterAll(() => { - jest.useRealTimers(); - }); it('snoozes an alert', async () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); diff --git a/x-pack/plugins/alerting/server/routes/unsnooze_rule.test.ts b/x-pack/plugins/alerting/server/routes/unsnooze_rule.test.ts new file mode 100644 index 0000000000000..a0fbf9776240a --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/unsnooze_rule.test.ts @@ -0,0 +1,80 @@ +/* + * 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 { unsnoozeRuleRoute } from './unsnooze_rule'; +import { httpServiceMock } from 'src/core/server/mocks'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { mockHandlerArguments } from './_mock_handler_arguments'; +import { rulesClientMock } from '../rules_client.mock'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; + +const rulesClient = rulesClientMock.create(); +jest.mock('../lib/license_api_access.ts', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('unsnoozeAlertRoute', () => { + it('unsnoozes an alert', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + unsnoozeRuleRoute(router, licenseState); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rule/{id}/_unsnooze"`); + + rulesClient.unsnooze.mockResolvedValueOnce(); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + }, + ['noContent'] + ); + + expect(await handler(context, req, res)).toEqual(undefined); + + expect(rulesClient.unsnooze).toHaveBeenCalledTimes(1); + expect(rulesClient.unsnooze.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + }, + ] + `); + + expect(res.noContent).toHaveBeenCalled(); + }); + + it('ensures the rule type gets validated for the license', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + unsnoozeRuleRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + rulesClient.unsnooze.mockRejectedValue(new AlertTypeDisabledError('Fail', 'license_invalid')); + + const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: {}, body: {} }, [ + 'ok', + 'forbidden', + ]); + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/unsnooze_rule.ts b/x-pack/plugins/alerting/server/routes/unsnooze_rule.ts new file mode 100644 index 0000000000000..f779f1681d482 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/unsnooze_rule.ts @@ -0,0 +1,45 @@ +/* + * 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 { IRouter } from 'kibana/server'; +import { schema } from '@kbn/config-schema'; +import { ILicenseState, RuleMutedError } from '../lib'; +import { verifyAccessAndContext } from './lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types'; + +const paramSchema = schema.object({ + id: schema.string(), +}); + +export const unsnoozeRuleRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.post( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rule/{id}/_unsnooze`, + validate: { + params: paramSchema, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = context.alerting.getRulesClient(); + const params = req.params; + try { + await rulesClient.unsnooze({ ...params }); + return res.noContent(); + } catch (e) { + if (e instanceof RuleMutedError) { + return e.sendResponse(res); + } + throw e; + } + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/rules_client.mock.ts b/x-pack/plugins/alerting/server/rules_client.mock.ts index de1de6a8e3cbc..bc5c9c0a5e0cd 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -33,6 +33,7 @@ const createRulesClientMock = () => { getExecutionLogForRule: jest.fn(), getSpaceId: jest.fn(), snooze: jest.fn(), + unsnooze: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/alerting/server/rules_client/audit_events.ts b/x-pack/plugins/alerting/server/rules_client/audit_events.ts index 65be7fc739ca2..2192073a1244b 100644 --- a/x-pack/plugins/alerting/server/rules_client/audit_events.ts +++ b/x-pack/plugins/alerting/server/rules_client/audit_events.ts @@ -25,6 +25,7 @@ export enum RuleAuditAction { AGGREGATE = 'rule_aggregate', GET_EXECUTION_LOG = 'rule_get_execution_log', SNOOZE = 'rule_snooze', + UNSNOOZE = 'rule_unsnooze', } type VerbsTuple = [string, string, string]; @@ -50,6 +51,7 @@ const eventVerbs: Record = { 'accessed execution log for', ], rule_snooze: ['snooze', 'snoozing', 'snoozed'], + rule_unsnooze: ['unsnooze', 'unsnoozing', 'unsnoozed'], }; const eventTypes: Record = { @@ -69,6 +71,7 @@ const eventTypes: Record = { rule_aggregate: 'access', rule_get_execution_log: 'access', rule_snooze: 'change', + rule_unsnooze: 'change', }; export interface RuleAuditEventParams { diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 666617dcf3fd8..7b3f0be202738 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -1659,6 +1659,68 @@ export class RulesClient { ); } + public async unsnooze({ id }: { id: string }): Promise { + return await retryIfConflicts( + this.logger, + `rulesClient.unsnooze('${id}')`, + async () => await this.unsnoozeWithOCC({ id }) + ); + } + + private async unsnoozeWithOCC({ id }: { id: string }) { + const { attributes, version } = await this.unsecuredSavedObjectsClient.get( + 'alert', + id + ); + + try { + await this.authorization.ensureAuthorized({ + ruleTypeId: attributes.alertTypeId, + consumer: attributes.consumer, + operation: WriteOperations.Unsnooze, + entity: AlertingAuthorizationEntity.Rule, + }); + + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.UNSNOOZE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.UNSNOOZE, + outcome: 'unknown', + savedObject: { type: 'alert', id }, + }) + ); + + this.ruleTypeRegistry.ensureRuleTypeEnabled(attributes.alertTypeId); + + const updateAttributes = this.updateMeta({ + snoozeEndTime: null, + muteAll: false, + updatedBy: await this.getUserName(), + updatedAt: new Date().toISOString(), + }); + const updateOptions = { version }; + + await partiallyUpdateAlert( + this.unsecuredSavedObjectsClient, + id, + updateAttributes, + updateOptions + ); + } + public async muteAll({ id }: { id: string }): Promise { return await retryIfConflicts( this.logger, diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts index 62aa9aca6ef2d..dc574251029e2 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts @@ -226,6 +226,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze", ] `); }); @@ -321,6 +322,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/update", @@ -376,6 +378,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", @@ -478,6 +481,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts index 13aa45d54f66e..800e8297d7fb1 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts @@ -33,6 +33,7 @@ const writeOperations: Record = { 'muteAlert', 'unmuteAlert', 'snooze', + 'unsnooze', ], alert: ['update'], }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 44cd0b1a20c8d..2b061fe37302f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -27878,9 +27878,7 @@ "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.durationTitle": "ルールを実行するのにかかる時間。", "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editAriaLabel": "編集", "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editButtonTooltip": "編集", - "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.enabledTitle": "有効", "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastExecutionDateTitle": "前回の実行の開始時間。", - "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.mutedBadge": "ミュート", "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.nameTitle": "名前", "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.scheduleTitle": "間隔", "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.statusTitle": "ステータス", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index abfb0a4dc2a82..5b22e2772f421 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -27909,9 +27909,7 @@ "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.durationTitle": "运行规则所需的时间长度。", "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editAriaLabel": "编辑", "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editButtonTooltip": "编辑", - "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.enabledTitle": "已启用", "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastExecutionDateTitle": "上次执行的开始时间。", - "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.mutedBadge": "已静音", "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.nameTitle": "名称", "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.scheduleTitle": "时间间隔", "xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.statusTitle": "状态", diff --git a/x-pack/plugins/triggers_actions_ui/common/index.ts b/x-pack/plugins/triggers_actions_ui/common/index.ts index 560d045c0bb47..bc9592a2e49f7 100644 --- a/x-pack/plugins/triggers_actions_ui/common/index.ts +++ b/x-pack/plugins/triggers_actions_ui/common/index.ts @@ -10,3 +10,5 @@ export * from './data'; export const BASE_TRIGGERS_ACTIONS_UI_API_PATH = '/api/triggers_actions_ui'; +export * from './parse_interval'; +export * from './experimental_features'; diff --git a/x-pack/plugins/triggers_actions_ui/common/parse_interval.ts b/x-pack/plugins/triggers_actions_ui/common/parse_interval.ts new file mode 100644 index 0000000000000..21fd1b214c32f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/common/parse_interval.ts @@ -0,0 +1,28 @@ +/* + * 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 dateMath from '@elastic/datemath'; +import { i18n } from '@kbn/i18n'; +export const INTERVAL_STRING_RE = new RegExp(`^([\\d\\.]+)\\s*(${dateMath.units.join('|')})$`); + +export const parseInterval = (intervalString: string) => { + if (intervalString) { + const matches = intervalString.match(INTERVAL_STRING_RE); + if (matches) { + const value = Number(matches[1]); + const unit = matches[2]; + return { value, unit }; + } + } + throw new Error( + i18n.translate('xpack.triggersActionsUI.parseInterval.errorMessage', { + defaultMessage: '{value} is not an interval string', + values: { + value: intervalString, + }, + }) + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts index 1a3f6a5ae2b86..69b7b494431fc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts @@ -43,6 +43,7 @@ export const transformRule: RewriteRequestCase = ({ scheduled_task_id: scheduledTaskId, execution_status: executionStatus, actions: actions, + snooze_end_time: snoozeEndTime, ...rest }: any) => ({ ruleTypeId, @@ -54,6 +55,7 @@ export const transformRule: RewriteRequestCase = ({ notifyWhen, muteAll, mutedInstanceIds, + snoozeEndTime, executionStatus: executionStatus ? transformExecutionStatus(executionStatus) : undefined, actions: actions ? actions.map((action: AsApiContract) => transformAction(action)) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts index fff1cef678b02..75e2bdc8b4a2b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts @@ -23,3 +23,5 @@ export { unmuteAlertInstance } from './unmute_alert'; export { unmuteRule, unmuteRules } from './unmute'; export { updateRule } from './update'; export { resolveRule } from './resolve_rule'; +export { snoozeRule } from './snooze'; +export { unsnoozeRule } from './unsnooze'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/snooze.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/snooze.test.ts new file mode 100644 index 0000000000000..02b40a25bc281 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/snooze.test.ts @@ -0,0 +1,29 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { snoozeRule } from './snooze'; + +const http = httpServiceMock.createStartContract(); +beforeEach(() => jest.resetAllMocks()); + +describe('snoozeRule', () => { + test('should call mute alert API', async () => { + const result = await snoozeRule({ http, id: '1/', snoozeEndTime: '9999-01-01T00:00:00.000Z' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/internal/alerting/rule/1%2F/_snooze", + Object { + "body": "{\\"snooze_end_time\\":\\"9999-01-01T00:00:00.000Z\\"}", + }, + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/snooze.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/snooze.ts new file mode 100644 index 0000000000000..3a414e914df7d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/snooze.ts @@ -0,0 +1,24 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; + +export async function snoozeRule({ + id, + snoozeEndTime, + http, +}: { + id: string; + snoozeEndTime: string | -1; + http: HttpSetup; +}): Promise { + await http.post(`${INTERNAL_BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/_snooze`, { + body: JSON.stringify({ + snooze_end_time: snoozeEndTime, + }), + }); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/unsnooze.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/unsnooze.test.ts new file mode 100644 index 0000000000000..58356d81aa7b1 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/unsnooze.test.ts @@ -0,0 +1,26 @@ +/* + * 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 { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { unsnoozeRule } from './unsnooze'; + +const http = httpServiceMock.createStartContract(); +beforeEach(() => jest.resetAllMocks()); + +describe('muteRule', () => { + test('should call mute alert API', async () => { + const result = await unsnoozeRule({ http, id: '1/' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/internal/alerting/rule/1%2F/_unsnooze", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/unsnooze.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/unsnooze.ts new file mode 100644 index 0000000000000..b76b248f9e4ce --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/unsnooze.ts @@ -0,0 +1,12 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; + +export async function unsnoozeRule({ id, http }: { id: string; http: HttpSetup }): Promise { + await http.post(`${INTERNAL_BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/_unsnooze`); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.test.tsx new file mode 100644 index 0000000000000..4f7df21ee53e1 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { RuleStatusDropdown, ComponentOpts } from './rule_status_dropdown'; + +const NOW_STRING = '2020-03-01T00:00:00.000Z'; +const SNOOZE_END_TIME = new Date('2020-03-04T00:00:00.000Z'); + +describe('RuleStatusDropdown', () => { + const enableRule = jest.fn(); + const disableRule = jest.fn(); + const snoozeRule = jest.fn(); + const unsnoozeRule = jest.fn(); + const props: ComponentOpts = { + disableRule, + enableRule, + snoozeRule, + unsnoozeRule, + item: { + id: '1', + name: 'test rule', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + consumer: 'test', + actionsCount: 0, + ruleType: 'test_rule_type', + createdAt: new Date('2020-08-20T19:23:38Z'), + enabledInLicense: true, + isEditable: true, + notifyWhen: null, + index: 0, + updatedAt: new Date('2020-08-20T19:23:38Z'), + snoozeEndTime: null, + }, + onRuleChanged: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + beforeAll(() => { + jest.spyOn(global.Date, 'now').mockImplementation(() => new Date(NOW_STRING).valueOf()); + }); + afterAll(() => { + jest.restoreAllMocks(); + }); + + test('renders status control', () => { + const wrapper = mountWithIntl(); + expect(wrapper.find('[data-test-subj="statusDropdown"]').first().props().title).toBe('Enabled'); + }); + + test('renders status control as disabled when rule is disabled', () => { + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('[data-test-subj="statusDropdown"]').first().props().title).toBe( + 'Disabled' + ); + }); + + test('renders status control as snoozed when rule is snoozed', () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => new Date(NOW_STRING).valueOf()); + + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('[data-test-subj="statusDropdown"]').first().props().title).toBe('Snoozed'); + expect(wrapper.find('[data-test-subj="remainingSnoozeTime"]').first().text()).toBe('3 days'); + }); + + test('renders status control as snoozed when rule has muteAll set to true', () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => new Date(NOW_STRING).valueOf()); + + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('[data-test-subj="statusDropdown"]').first().props().title).toBe('Snoozed'); + expect(wrapper.find('[data-test-subj="remainingSnoozeTime"]').first().text()).toBe( + 'Indefinitely' + ); + }); + + test('renders status control as disabled when rule is snoozed but also disabled', () => { + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('[data-test-subj="statusDropdown"]').first().props().title).toBe( + 'Disabled' + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx new file mode 100644 index 0000000000000..97652c5ab45aa --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx @@ -0,0 +1,475 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import { + useGeneratedHtmlId, + EuiLoadingSpinner, + EuiPopover, + EuiContextMenu, + EuiBadge, + EuiPanel, + EuiFieldNumber, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiHorizontalRule, + EuiTitle, + EuiFlexGrid, + EuiSpacer, + EuiLink, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { parseInterval } from '../../../../../common'; + +import { RuleTableItem } from '../../../../types'; + +type SnoozeUnit = 'm' | 'h' | 'd' | 'w' | 'M'; +const SNOOZE_END_TIME_FORMAT = 'LL @ LT'; + +export interface ComponentOpts { + item: RuleTableItem; + onRuleChanged: () => void; + enableRule: () => Promise; + disableRule: () => Promise; + snoozeRule: (snoozeEndTime: string | -1) => Promise; + unsnoozeRule: () => Promise; +} + +export const RuleStatusDropdown: React.FunctionComponent = ({ + item, + onRuleChanged, + disableRule, + enableRule, + snoozeRule, + unsnoozeRule, +}: ComponentOpts) => { + const [isEnabled, setIsEnabled] = useState(item.enabled); + const [isSnoozed, setIsSnoozed] = useState(isItemSnoozed(item)); + useEffect(() => { + setIsEnabled(item.enabled); + }, [item.enabled]); + useEffect(() => { + setIsSnoozed(isItemSnoozed(item)); + }, [item]); + const [isUpdating, setIsUpdating] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onClickBadge = useCallback(() => setIsPopoverOpen((isOpen) => !isOpen), [setIsPopoverOpen]); + const onClosePopover = useCallback(() => setIsPopoverOpen(false), [setIsPopoverOpen]); + + const onChangeEnabledStatus = useCallback( + async (enable: boolean) => { + setIsUpdating(true); + if (enable) { + await enableRule(); + } else { + await disableRule(); + } + setIsEnabled(!isEnabled); + onRuleChanged(); + setIsUpdating(false); + }, + [setIsUpdating, isEnabled, setIsEnabled, onRuleChanged, enableRule, disableRule] + ); + const onChangeSnooze = useCallback( + async (value: number, unit?: SnoozeUnit) => { + setIsUpdating(true); + if (value === -1) { + await snoozeRule(-1); + } else if (value !== 0) { + const snoozeEndTime = moment().add(value, unit).toISOString(); + await snoozeRule(snoozeEndTime); + } else await unsnoozeRule(); + setIsSnoozed(value !== 0); + onRuleChanged(); + setIsUpdating(false); + }, + [setIsUpdating, setIsSnoozed, onRuleChanged, snoozeRule, unsnoozeRule] + ); + + const badgeColor = !isEnabled ? 'default' : isSnoozed ? 'warning' : 'primary'; + const badgeMessage = !isEnabled ? DISABLED : isSnoozed ? SNOOZED : ENABLED; + + const remainingSnoozeTime = + isEnabled && isSnoozed ? ( + + + {item.muteAll ? INDEFINITELY : moment(item.snoozeEndTime).fromNow(true)} + + + ) : null; + + const badge = ( + + {badgeMessage} + {isUpdating && ( + + )} + + ); + + return ( + + + + + + + + {remainingSnoozeTime} + + + ); +}; + +interface RuleStatusMenuProps { + onChangeEnabledStatus: (enabled: boolean) => void; + onChangeSnooze: (value: number | -1, unit?: SnoozeUnit) => void; + onClosePopover: () => void; + isEnabled: boolean; + isSnoozed: boolean; + snoozeEndTime?: Date | null; +} + +const RuleStatusMenu: React.FunctionComponent = ({ + onChangeEnabledStatus, + onChangeSnooze, + onClosePopover, + isEnabled, + isSnoozed, + snoozeEndTime, +}) => { + const enableRule = useCallback(() => { + if (isSnoozed) { + // Unsnooze if the rule is snoozed and the user clicks Enabled + onChangeSnooze(0, 'm'); + } else { + onChangeEnabledStatus(true); + } + onClosePopover(); + }, [onChangeEnabledStatus, onClosePopover, onChangeSnooze, isSnoozed]); + const disableRule = useCallback(() => { + onChangeEnabledStatus(false); + onClosePopover(); + }, [onChangeEnabledStatus, onClosePopover]); + + const onApplySnooze = useCallback( + (value: number, unit?: SnoozeUnit) => { + onChangeSnooze(value, unit); + onClosePopover(); + }, + [onClosePopover, onChangeSnooze] + ); + + let snoozeButtonTitle = {SNOOZE}; + if (isSnoozed && snoozeEndTime) { + snoozeButtonTitle = ( + <> + {SNOOZE}{' '} + + {moment(snoozeEndTime).format(SNOOZE_END_TIME_FORMAT)} + + + ); + } + + const panels = [ + { + id: 0, + width: 360, + items: [ + { + name: ENABLED, + icon: isEnabled && !isSnoozed ? 'check' : 'empty', + onClick: enableRule, + }, + { + name: DISABLED, + icon: !isEnabled ? 'check' : 'empty', + onClick: disableRule, + }, + { + name: snoozeButtonTitle, + icon: isEnabled && isSnoozed ? 'check' : 'empty', + panel: 1, + disabled: !isEnabled, + }, + ], + }, + { + id: 1, + width: 360, + title: SNOOZE, + content: ( + + ), + }, + ]; + + return ; +}; + +interface SnoozePanelProps { + interval?: string; + applySnooze: (value: number | -1, unit?: SnoozeUnit) => void; + showCancel: boolean; +} + +const SnoozePanel: React.FunctionComponent = ({ + interval = '3d', + applySnooze, + showCancel, +}) => { + const [intervalValue, setIntervalValue] = useState(parseInterval(interval).value); + const [intervalUnit, setIntervalUnit] = useState(parseInterval(interval).unit); + + const onChangeValue = useCallback( + ({ target }) => setIntervalValue(target.value), + [setIntervalValue] + ); + const onChangeUnit = useCallback( + ({ target }) => setIntervalUnit(target.value), + [setIntervalUnit] + ); + + const onApply1h = useCallback(() => applySnooze(1, 'h'), [applySnooze]); + const onApply3h = useCallback(() => applySnooze(3, 'h'), [applySnooze]); + const onApply8h = useCallback(() => applySnooze(8, 'h'), [applySnooze]); + const onApply1d = useCallback(() => applySnooze(1, 'd'), [applySnooze]); + const onApplyIndefinite = useCallback(() => applySnooze(-1), [applySnooze]); + const onClickApplyButton = useCallback( + () => applySnooze(intervalValue, intervalUnit as SnoozeUnit), + [applySnooze, intervalValue, intervalUnit] + ); + const onCancelSnooze = useCallback(() => applySnooze(0, 'm'), [applySnooze]); + + return ( + + + + + + + + + + + + {i18n.translate('xpack.triggersActionsUI.sections.rulesList.applySnooze', { + defaultMessage: 'Apply', + })} + + + + + + + +
+ {i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeCommonlyUsed', { + defaultMessage: 'Commonly used', + })} +
+
+
+ + + + {i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeOneHour', { + defaultMessage: '1 hour', + })} + + + + + {i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeThreeHours', { + defaultMessage: '3 hours', + })} + + + + + {i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeEightHours', { + defaultMessage: '8 hours', + })} + + + + + {i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeOneDay', { + defaultMessage: '1 day', + })} + + +
+ + + + + {i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeIndefinitely', { + defaultMessage: 'Snooze indefinitely', + })} + + + + {showCancel && ( + <> + + + + + Cancel snooze + + + + + )} + +
+ ); +}; + +const isItemSnoozed = (item: { snoozeEndTime?: Date | null; muteAll: boolean }) => { + const { snoozeEndTime, muteAll } = item; + if (muteAll) return true; + if (!snoozeEndTime) { + return false; + } + return moment(Date.now()).isBefore(snoozeEndTime); +}; + +const futureTimeToInterval = (time?: Date | null) => { + if (!time) return; + const relativeTime = moment(time).locale('en').fromNow(true); + const [valueStr, unitStr] = relativeTime.split(' '); + let value = valueStr === 'a' || valueStr === 'an' ? 1 : parseInt(valueStr, 10); + let unit; + switch (unitStr) { + case 'year': + case 'years': + unit = 'M'; + value = value * 12; + break; + case 'month': + case 'months': + unit = 'M'; + break; + case 'day': + case 'days': + unit = 'd'; + break; + case 'hour': + case 'hours': + unit = 'h'; + break; + case 'minute': + case 'minutes': + unit = 'm'; + break; + } + + if (!unit) return; + return `${value}${unit}`; +}; + +const ENABLED = i18n.translate('xpack.triggersActionsUI.sections.rulesList.enabledRuleStatus', { + defaultMessage: 'Enabled', +}); + +const DISABLED = i18n.translate('xpack.triggersActionsUI.sections.rulesList.disabledRuleStatus', { + defaultMessage: 'Disabled', +}); + +const SNOOZED = i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozedRuleStatus', { + defaultMessage: 'Snoozed', +}); + +const SNOOZE = i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeMenuTitle', { + defaultMessage: 'Snooze', +}); + +const OPEN_MENU_ARIA_LABEL = i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.ruleStatusDropdownMenuLabel', + { + defaultMessage: 'Change rule status or snooze', + } +); + +const MINUTES = i18n.translate('xpack.triggersActionsUI.sections.rulesList.minutesLabel', { + defaultMessage: 'minutes', +}); +const HOURS = i18n.translate('xpack.triggersActionsUI.sections.rulesList.hoursLabel', { + defaultMessage: 'hours', +}); +const DAYS = i18n.translate('xpack.triggersActionsUI.sections.rulesList.daysLabel', { + defaultMessage: 'days', +}); +const WEEKS = i18n.translate('xpack.triggersActionsUI.sections.rulesList.weeksLabel', { + defaultMessage: 'weeks', +}); +const MONTHS = i18n.translate('xpack.triggersActionsUI.sections.rulesList.monthsLabel', { + defaultMessage: 'months', +}); + +const INDEFINITELY = i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.remainingSnoozeIndefinite', + { defaultMessage: 'Indefinitely' } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx index d7184fc6ce400..185d18f605d42 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx @@ -59,7 +59,7 @@ export const RuleStatusFilter: React.FunctionComponent = > } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 36c102c6f54bb..6c1912b5988fd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -427,11 +427,6 @@ describe('rules_list component with items', () => { expect(wrapper.find('EuiBasicTable')).toHaveLength(1); expect(wrapper.find('EuiTableRow')).toHaveLength(mockedRulesData.length); - // Enabled switch column - expect(wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-enabled"]').length).toEqual( - mockedRulesData.length - ); - // Name and rule type column const ruleNameColumns = wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-name"]'); expect(ruleNameColumns.length).toEqual(mockedRulesData.length); @@ -499,10 +494,10 @@ describe('rules_list component with items', () => { 'The length of time it took for the rule to run (mm:ss).' ); - // Status column - expect(wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-status"]').length).toEqual( - mockedRulesData.length - ); + // Last response column + expect( + wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-lastResponse"]').length + ).toEqual(mockedRulesData.length); expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-active"]').length).toEqual(1); expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-ok"]').length).toEqual(1); expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-pending"]').length).toEqual(1); @@ -523,6 +518,11 @@ describe('rules_list component with items', () => { 'License Error' ); + // Status control column + expect(wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-status"]').length).toEqual( + mockedRulesData.length + ); + // Monitoring column expect( wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-successRatio"]').length @@ -714,7 +714,7 @@ describe('rules_list component with items', () => { it('sorts rules when clicking the name column', async () => { await setup(); wrapper - .find('[data-test-subj="tableHeaderCell_name_1"] .euiTableHeaderButton') + .find('[data-test-subj="tableHeaderCell_name_0"] .euiTableHeaderButton') .first() .simulate('click'); @@ -733,10 +733,10 @@ describe('rules_list component with items', () => { ); }); - it('sorts rules when clicking the enabled column', async () => { + it('sorts rules when clicking the status control column', async () => { await setup(); wrapper - .find('[data-test-subj="tableHeaderCell_enabled_0"] .euiTableHeaderButton') + .find('[data-test-subj="tableHeaderCell_enabled_8"] .euiTableHeaderButton') .first() .simulate('click'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index c55f1303120f0..70dc9d76fed90 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -62,6 +62,8 @@ import { loadRuleTypes, disableRule, enableRule, + snoozeRule, + unsnoozeRule, deleteRules, } from '../../../lib/rule_api'; import { loadActionTypes } from '../../../lib/action_connector_api'; @@ -84,7 +86,7 @@ import './rules_list.scss'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; import { ManageLicenseModal } from './manage_license_modal'; import { checkRuleTypeEnabled } from '../../../lib/check_rule_type_enabled'; -import { RuleEnabledSwitch } from './rule_enabled_switch'; +import { RuleStatusDropdown } from './rule_status_dropdown'; import { PercentileSelectablePopover } from './percentile_selectable_popover'; import { RuleDurationFormat } from './rule_duration_format'; import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; @@ -326,6 +328,21 @@ export const RulesList: React.FunctionComponent = () => { } } + const renderRuleStatusDropdown = (ruleEnabled: boolean | undefined, item: RuleTableItem) => { + return ( + await disableRule({ http, id: item.id })} + enableRule={async () => await enableRule({ http, id: item.id })} + snoozeRule={async (snoozeEndTime: string | -1) => + await snoozeRule({ http, id: item.id, snoozeEndTime }) + } + unsnoozeRule={async () => await unsnoozeRule({ http, id: item.id })} + item={item} + onRuleChanged={() => loadRulesData()} + /> + ); + }; + const renderAlertExecutionStatus = ( executionStatus: AlertExecutionStatus, item: RuleTableItem @@ -430,26 +447,6 @@ export const RulesList: React.FunctionComponent = () => { const getRulesTableColumns = () => { return [ - { - field: 'enabled', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.enabledTitle', - { defaultMessage: 'Enabled' } - ), - width: '50px', - render(_enabled: boolean | undefined, item: RuleTableItem) { - return ( - await disableRule({ http, id: item.id })} - enableRule={async () => await enableRule({ http, id: item.id })} - item={item} - onRuleChanged={() => loadRulesData()} - /> - ); - }, - sortable: true, - 'data-test-subj': 'rulesTableCell-enabled', - }, { field: 'name', name: i18n.translate( @@ -499,19 +496,7 @@ export const RulesList: React.FunctionComponent = () => { ); - return ( - <> - {link} - {rule.enabled && rule.muteAll && ( - - - - )} - - ); + return <>{link}; }, }, { @@ -695,17 +680,31 @@ export const RulesList: React.FunctionComponent = () => { { field: 'executionStatus.status', name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.statusTitle', - { defaultMessage: 'Status' } + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastResponseTitle', + { defaultMessage: 'Last response' } ), sortable: true, truncateText: false, width: '120px', - 'data-test-subj': 'rulesTableCell-status', + 'data-test-subj': 'rulesTableCell-lastResponse', render: (_executionStatus: AlertExecutionStatus, item: RuleTableItem) => { return renderAlertExecutionStatus(item.executionStatus, item); }, }, + { + field: 'enabled', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.statusTitle', + { defaultMessage: 'Status' } + ), + sortable: true, + truncateText: false, + width: '200px', + 'data-test-subj': 'rulesTableCell-status', + render: (_enabled: boolean | undefined, item: RuleTableItem) => { + return renderRuleStatusDropdown(item.enabled, item); + }, + }, { name: '', width: '10%', diff --git a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts index 9610fc8d076d2..436a98d4cf3f8 100644 --- a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts @@ -97,6 +97,17 @@ export class AlertUtils { return request; } + public getUnsnoozeRequest(alertId: string) { + const request = this.supertestWithoutAuth + .post(`${getUrlPrefix(this.space.id)}/internal/alerting/rule/${alertId}/_unsnooze`) + .set('kbn-xsrf', 'foo') + .set('content-type', 'application/json'); + if (this.user) { + return request.auth(this.user.username, this.user.password); + } + return request; + } + public getMuteAllRequest(alertId: string) { const request = this.supertestWithoutAuth .post(`${getUrlPrefix(this.space.id)}/api/alerting/rule/${alertId}/_mute_all`) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unsnooze.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unsnooze.ts new file mode 100644 index 0000000000000..ed37a19d80707 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unsnooze.ts @@ -0,0 +1,311 @@ +/* + * 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 expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../scenarios'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + AlertUtils, + checkAAD, + getUrlPrefix, + getTestRuleData, + ObjectRemover, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, +} from '../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function createUnsnoozeRuleTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('unsnooze', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + const alertUtils = new AlertUtils({ user, space, supertestWithoutAuth }); + + describe(scenario.id, () => { + it('should handle unsnooze rule request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + enabled: false, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting'); + + const response = await alertUtils.getUnsnoozeRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'unsnooze', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to execute actions`, + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.snooze_end_time).to.eql(null); + expect(updatedAlert.mute_all).to.eql(false); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle unsnooze rule request appropriately when consumer is the same as producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + enabled: false, + rule_type_id: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting'); + + const response = await alertUtils.getUnsnoozeRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'unsnooze', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.snooze_end_time).to.eql(null); + expect(updatedAlert.mute_all).to.eql(false); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle unsnooze rule request appropriately when consumer is not the producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + enabled: false, + rule_type_id: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting'); + + const response = await alertUtils.getUnsnoozeRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'unsnooze', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'unsnooze', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.snooze_end_time).to.eql(null); + expect(updatedAlert.mute_all).to.eql(false); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle unsnooze rule request appropriately when consumer is "alerts"', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + enabled: false, + rule_type_id: 'test.restricted-noop', + consumer: 'alerts', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting'); + + const response = await alertUtils.getUnsnoozeRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'unsnooze', + 'test.restricted-noop', + 'alerts' + ), + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'unsnooze', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.snooze_end_time).to.eql(null); + expect(updatedAlert.mute_all).to.eql(false); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/unsnooze.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/unsnooze.ts new file mode 100644 index 0000000000000..317d099026652 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/unsnooze.ts @@ -0,0 +1,80 @@ +/* + * 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 expect from '@kbn/expect'; +import { Spaces } from '../../scenarios'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + AlertUtils, + checkAAD, + getUrlPrefix, + getTestRuleData, + ObjectRemover, +} from '../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function createSnoozeRuleTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('unsnooze', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + const alertUtils = new AlertUtils({ space: Spaces.space1, supertestWithoutAuth }); + + it('should handle unsnooze rule request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + enabled: false, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting'); + + const response = await alertUtils.getSnoozeRequest(createdAlert.id); + + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .expect(200); + expect(updatedAlert.snooze_end_time).to.eql(null); + expect(updatedAlert.mute_all).to.eql(false); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: Spaces.space1.id, + type: 'alert', + id: createdAlert.id, + }); + }); + }); +} diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts index 0f6e99ccf27f3..47a7e70c30a63 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts @@ -162,10 +162,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('disableButton'); - await pageObjects.triggersActionsUI.ensureRuleActionToggleApplied( + await pageObjects.triggersActionsUI.ensureRuleActionStatusApplied( createdAlert.name, - 'enableSwitch', - 'true' + 'statusDropdown', + 'disabled' ); }); @@ -181,10 +181,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('collapsedItemActions'); await testSubjects.click('disableButton'); - await pageObjects.triggersActionsUI.ensureRuleActionToggleApplied( + await pageObjects.triggersActionsUI.ensureRuleActionStatusApplied( createdAlert.name, - 'enableSwitch', - 'false' + 'statusDropdown', + 'enabled' ); }); @@ -201,9 +201,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('muteButton'); await retry.tryForTime(30000, async () => { - await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); - const muteBadge = await testSubjects.find('mutedActionsBadge'); - expect(await muteBadge.isDisplayed()).to.eql(true); + await pageObjects.triggersActionsUI.ensureRuleActionStatusApplied( + createdAlert.name, + 'statusDropdown', + 'snoozed' + ); }); }); @@ -221,9 +223,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('muteButton'); await retry.tryForTime(30000, async () => { - await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); - const muteBadge = await testSubjects.find('mutedActionsBadge'); - expect(await muteBadge.isDisplayed()).to.eql(true); + await pageObjects.triggersActionsUI.ensureRuleActionStatusApplied( + createdAlert.name, + 'statusDropdown', + 'snoozed' + ); }); }); @@ -241,8 +245,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('muteButton'); await retry.tryForTime(30000, async () => { - await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); - await testSubjects.missingOrFail('mutedActionsBadge'); + await pageObjects.triggersActionsUI.ensureRuleActionStatusApplied( + createdAlert.name, + 'statusDropdown', + 'enabled' + ); }); }); @@ -289,9 +296,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await retry.tryForTime(30000, async () => { - await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); - const muteBadge = await testSubjects.find('mutedActionsBadge'); - expect(await muteBadge.isDisplayed()).to.eql(true); + await pageObjects.triggersActionsUI.ensureRuleActionStatusApplied( + createdAlert.name, + 'statusDropdown', + 'snoozed' + ); }); }); @@ -312,8 +321,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.existOrFail('muteAll'); await retry.tryForTime(30000, async () => { - await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); - await testSubjects.missingOrFail('mutedActionsBadge'); + await pageObjects.triggersActionsUI.ensureRuleActionStatusApplied( + createdAlert.name, + 'statusDropdown', + 'enabled' + ); }); }); @@ -331,10 +343,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Enable all button shows after clicking disable all await testSubjects.existOrFail('enableAll'); - await pageObjects.triggersActionsUI.ensureRuleActionToggleApplied( + await pageObjects.triggersActionsUI.ensureRuleActionStatusApplied( createdAlert.name, - 'enableSwitch', - 'false' + 'statusDropdown', + 'disabled' ); }); @@ -354,10 +366,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Disable all button shows after clicking enable all await testSubjects.existOrFail('disableAll'); - await pageObjects.triggersActionsUI.ensureRuleActionToggleApplied( + await pageObjects.triggersActionsUI.ensureRuleActionStatusApplied( createdAlert.name, - 'enableSwitch', - 'true' + 'statusDropdown', + 'enabled' ); }); diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts index c715800abd37e..7379a5ad1329c 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -114,7 +114,7 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) return { ...rowItem, status: $(row) - .findTestSubject('rulesTableCell-status') + .findTestSubject('rulesTableCell-lastResponse') .find('.euiTableCellContent') .text(), }; @@ -183,16 +183,16 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) expect(isConfirmationModalVisible).to.eql(true, 'Expect confirmation modal to be visible'); await testSubjects.click('confirmModalConfirmButton'); }, - async ensureRuleActionToggleApplied( + async ensureRuleActionStatusApplied( ruleName: string, - switchName: string, - shouldBeCheckedAsString: string + controlName: string, + expectedStatus: string ) { await retry.tryForTime(30000, async () => { await this.searchAlerts(ruleName); - const switchControl = await testSubjects.find(switchName); - const isChecked = await switchControl.getAttribute('aria-checked'); - expect(isChecked).to.eql(shouldBeCheckedAsString); + const statusControl = await testSubjects.find(controlName); + const title = await statusControl.getAttribute('title'); + expect(title.toLowerCase()).to.eql(expectedStatus.toLowerCase()); }); }, };