[RAM] Add Snooze UI and Unsnooze API (#128214)

* Add Snooze UI and Unsnooze API

* Add unsnooze writeoperation

* Add unsnooze API tests

* Add UI tests

* Add tooltip and enable canceling snooze when clicking Enabled

* Fix rulesClient mock

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Zacqary Adam Xeper 2022-03-24 11:59:46 -05:00 committed by GitHub
parent 51e0845146
commit d102213d1d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1422 additions and 95 deletions

View file

@ -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

View file

@ -46,6 +46,7 @@ export enum WriteOperations {
MuteAlert = 'muteAlert',
UnmuteAlert = 'unmuteAlert',
Snooze = 'snooze',
Unsnooze = 'unsnooze',
}
export interface EnsureAuthorizedOpts {

View file

@ -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<AlertingRequestHandlerContext>;
@ -65,4 +66,5 @@ export function defineRoutes(opts: RouteOptions) {
unmuteAlertRoute(router, licenseState);
updateRuleApiKeyRoute(router, licenseState);
snoozeRuleRoute(router, licenseState);
unsnoozeRuleRoute(router, licenseState);
}

View file

@ -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();

View file

@ -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' } });
});
});

View file

@ -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<AlertingRequestHandlerContext>,
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;
}
})
)
);
};

View file

@ -33,6 +33,7 @@ const createRulesClientMock = () => {
getExecutionLogForRule: jest.fn(),
getSpaceId: jest.fn(),
snooze: jest.fn(),
unsnooze: jest.fn(),
};
return mocked;
};

View file

@ -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<RuleAuditAction, VerbsTuple> = {
'accessed execution log for',
],
rule_snooze: ['snooze', 'snoozing', 'snoozed'],
rule_unsnooze: ['unsnooze', 'unsnoozing', 'unsnoozed'],
};
const eventTypes: Record<RuleAuditAction, EcsEventType> = {
@ -69,6 +71,7 @@ const eventTypes: Record<RuleAuditAction, EcsEventType> = {
rule_aggregate: 'access',
rule_get_execution_log: 'access',
rule_snooze: 'change',
rule_unsnooze: 'change',
};
export interface RuleAuditEventParams {

View file

@ -1682,6 +1682,68 @@ export class RulesClient {
);
}
public async unsnooze({ id }: { id: string }): Promise<void> {
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<RawRule>(
'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<void> {
return await retryIfConflicts(
this.logger,

View file

@ -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",

View file

@ -33,6 +33,7 @@ const writeOperations: Record<AlertingEntity, string[]> = {
'muteAlert',
'unmuteAlert',
'snooze',
'unsnooze',
],
alert: ['update'],
};

View file

@ -27874,9 +27874,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": "ステータス",

View file

@ -27905,9 +27905,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": "状态",

View file

@ -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';

View file

@ -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,
},
})
);
};

View file

@ -43,6 +43,7 @@ export const transformRule: RewriteRequestCase<Rule> = ({
scheduled_task_id: scheduledTaskId,
execution_status: executionStatus,
actions: actions,
snooze_end_time: snoozeEndTime,
...rest
}: any) => ({
ruleTypeId,
@ -54,6 +55,7 @@ export const transformRule: RewriteRequestCase<Rule> = ({
notifyWhen,
muteAll,
mutedInstanceIds,
snoozeEndTime,
executionStatus: executionStatus ? transformExecutionStatus(executionStatus) : undefined,
actions: actions
? actions.map((action: AsApiContract<RuleAction>) => transformAction(action))

View file

@ -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';

View file

@ -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\\"}",
},
],
]
`);
});
});

View file

@ -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<void> {
await http.post(`${INTERNAL_BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/_snooze`, {
body: JSON.stringify({
snooze_end_time: snoozeEndTime,
}),
});
}

View file

@ -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",
],
]
`);
});
});

View file

@ -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<void> {
await http.post(`${INTERNAL_BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/_unsnooze`);
}

View file

@ -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(<RuleStatusDropdown {...props} />);
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(
<RuleStatusDropdown {...{ ...props, item: { ...props.item, enabled: false } }} />
);
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(
<RuleStatusDropdown
{...{ ...props, item: { ...props.item, snoozeEndTime: SNOOZE_END_TIME } }}
/>
);
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(
<RuleStatusDropdown {...{ ...props, item: { ...props.item, muteAll: true } }} />
);
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(
<RuleStatusDropdown
{...{ ...props, item: { ...props.item, enabled: false, snoozeEndTime: SNOOZE_END_TIME } }}
/>
);
expect(wrapper.find('[data-test-subj="statusDropdown"]').first().props().title).toBe(
'Disabled'
);
});
});

View file

@ -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<void>;
disableRule: () => Promise<void>;
snoozeRule: (snoozeEndTime: string | -1) => Promise<void>;
unsnoozeRule: () => Promise<void>;
}
export const RuleStatusDropdown: React.FunctionComponent<ComponentOpts> = ({
item,
onRuleChanged,
disableRule,
enableRule,
snoozeRule,
unsnoozeRule,
}: ComponentOpts) => {
const [isEnabled, setIsEnabled] = useState<boolean>(item.enabled);
const [isSnoozed, setIsSnoozed] = useState<boolean>(isItemSnoozed(item));
useEffect(() => {
setIsEnabled(item.enabled);
}, [item.enabled]);
useEffect(() => {
setIsSnoozed(isItemSnoozed(item));
}, [item]);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(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 ? (
<EuiToolTip content={moment(item.snoozeEndTime).format(SNOOZE_END_TIME_FORMAT)}>
<EuiText color="subdued" size="xs">
{item.muteAll ? INDEFINITELY : moment(item.snoozeEndTime).fromNow(true)}
</EuiText>
</EuiToolTip>
) : null;
const badge = (
<EuiBadge
color={badgeColor}
iconSide="right"
iconType={!isUpdating ? 'arrowDown' : undefined}
onClick={onClickBadge}
iconOnClick={onClickBadge}
onClickAriaLabel={OPEN_MENU_ARIA_LABEL}
iconOnClickAriaLabel={OPEN_MENU_ARIA_LABEL}
isDisabled={isUpdating}
>
{badgeMessage}
{isUpdating && (
<EuiLoadingSpinner style={{ marginLeft: '4px', marginRight: '4px' }} size="s" />
)}
</EuiBadge>
);
return (
<EuiFlexGroup alignItems="center" justifyContent="flexStart" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiPopover
button={badge}
isOpen={isPopoverOpen}
closePopover={onClosePopover}
panelPaddingSize="s"
data-test-subj="statusDropdown"
title={badgeMessage}
>
<RuleStatusMenu
onClosePopover={onClosePopover}
onChangeEnabledStatus={onChangeEnabledStatus}
onChangeSnooze={onChangeSnooze}
isEnabled={isEnabled}
isSnoozed={isSnoozed}
snoozeEndTime={item.snoozeEndTime}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem data-test-subj="remainingSnoozeTime" grow={false}>
{remainingSnoozeTime}
</EuiFlexItem>
</EuiFlexGroup>
);
};
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<RuleStatusMenuProps> = ({
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 = <EuiText size="s">{SNOOZE}</EuiText>;
if (isSnoozed && snoozeEndTime) {
snoozeButtonTitle = (
<>
<EuiText size="s">{SNOOZE}</EuiText>{' '}
<EuiText size="xs" color="subdued">
{moment(snoozeEndTime).format(SNOOZE_END_TIME_FORMAT)}
</EuiText>
</>
);
}
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: (
<SnoozePanel
applySnooze={onApplySnooze}
interval={futureTimeToInterval(snoozeEndTime)}
showCancel={isSnoozed}
/>
),
},
];
return <EuiContextMenu initialPanelId={0} panels={panels} />;
};
interface SnoozePanelProps {
interval?: string;
applySnooze: (value: number | -1, unit?: SnoozeUnit) => void;
showCancel: boolean;
}
const SnoozePanel: React.FunctionComponent<SnoozePanelProps> = ({
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 (
<EuiPanel paddingSize="none">
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem>
<EuiFieldNumber
value={intervalValue}
onChange={onChangeValue}
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.snoozePanelIntervalValueLabel',
{ defaultMessage: 'Snooze interval value' }
)}
/>
</EuiFlexItem>
<EuiFlexItem grow={2}>
<EuiSelect
id={useGeneratedHtmlId({ prefix: 'snoozeUnit' })}
value={intervalUnit}
onChange={onChangeUnit}
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.snoozePanelIntervalUnitLabel',
{ defaultMessage: 'Snooze interval unit' }
)}
options={[
{ value: 'm', text: MINUTES },
{ value: 'h', text: HOURS },
{ value: 'd', text: DAYS },
{ value: 'w', text: WEEKS },
{ value: 'M', text: MONTHS },
]}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={onClickApplyButton}>
{i18n.translate('xpack.triggersActionsUI.sections.rulesList.applySnooze', {
defaultMessage: 'Apply',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="s" />
<EuiFlexGrid columns={2} gutterSize="s">
<EuiFlexItem>
<EuiTitle size="xxs">
<h5>
{i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeCommonlyUsed', {
defaultMessage: 'Commonly used',
})}
</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem />
<EuiFlexItem>
<EuiLink onClick={onApply1h}>
{i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeOneHour', {
defaultMessage: '1 hour',
})}
</EuiLink>
</EuiFlexItem>
<EuiFlexItem>
<EuiLink onClick={onApply3h}>
{i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeThreeHours', {
defaultMessage: '3 hours',
})}
</EuiLink>
</EuiFlexItem>
<EuiFlexItem>
<EuiLink onClick={onApply8h}>
{i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeEightHours', {
defaultMessage: '8 hours',
})}
</EuiLink>
</EuiFlexItem>
<EuiFlexItem>
<EuiLink onClick={onApply1d}>
{i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeOneDay', {
defaultMessage: '1 day',
})}
</EuiLink>
</EuiFlexItem>
</EuiFlexGrid>
<EuiHorizontalRule margin="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiLink onClick={onApplyIndefinite}>
{i18n.translate('xpack.triggersActionsUI.sections.rulesList.snoozeIndefinitely', {
defaultMessage: 'Snooze indefinitely',
})}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
{showCancel && (
<>
<EuiHorizontalRule margin="s" />
<EuiFlexGroup>
<EuiFlexItem grow>
<EuiButton color="danger" onClick={onCancelSnooze}>
Cancel snooze
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
<EuiSpacer size="s" />
</EuiPanel>
);
};
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' }
);

View file

@ -59,7 +59,7 @@ export const RuleStatusFilter: React.FunctionComponent<RuleStatusFilterProps> =
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.ruleStatusFilterLabel"
defaultMessage="Status"
defaultMessage="Last response"
/>
</EuiFilterButton>
}

View file

@ -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);
@ -512,10 +507,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);
@ -536,6 +531,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
@ -727,7 +727,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');
@ -746,10 +746,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');

View file

@ -63,6 +63,8 @@ import {
loadRuleTypes,
disableRule,
enableRule,
snoozeRule,
unsnoozeRule,
deleteRules,
} from '../../../lib/rule_api';
import { loadActionTypes } from '../../../lib/action_connector_api';
@ -86,7 +88,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';
@ -336,6 +338,21 @@ export const RulesList: React.FunctionComponent = () => {
}
}
const renderRuleStatusDropdown = (ruleEnabled: boolean | undefined, item: RuleTableItem) => {
return (
<RuleStatusDropdown
disableRule={async () => 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
@ -440,26 +457,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 (
<RuleEnabledSwitch
disableRule={async () => 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(
@ -509,19 +506,7 @@ export const RulesList: React.FunctionComponent = () => {
</EuiFlexGroup>
</>
);
return (
<>
{link}
{rule.enabled && rule.muteAll && (
<EuiBadge data-test-subj="mutedActionsBadge" color="hollow">
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.mutedBadge"
defaultMessage="Muted"
/>
</EuiBadge>
)}
</>
);
return <>{link}</>;
},
},
{
@ -756,16 +741,30 @@ export const RulesList: React.FunctionComponent = () => {
},
{
field: 'executionStatus.status',
name: i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastResponseTitle',
{ defaultMessage: 'Last response' }
),
sortable: true,
truncateText: false,
width: '120px',
'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: '120px',
width: '200px',
'data-test-subj': 'rulesTableCell-status',
render: (_executionStatus: AlertExecutionStatus, item: RuleTableItem) => {
return renderAlertExecutionStatus(item.executionStatus, item);
render: (_enabled: boolean | undefined, item: RuleTableItem) => {
return renderRuleStatusDropdown(item.enabled, item);
},
},
{

View file

@ -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`)

View file

@ -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)}`);
}
});
});
}
});
}

View file

@ -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,
});
});
});
}

View file

@ -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'
);
});

View file

@ -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());
});
},
};