diff --git a/x-pack/legacy/plugins/alerting/common/types.ts b/x-pack/legacy/plugins/alerting/common/alert.ts similarity index 94% rename from x-pack/legacy/plugins/alerting/common/types.ts rename to x-pack/legacy/plugins/alerting/common/alert.ts index 54bf04d0765d6..8f28c8fbaed7f 100644 --- a/x-pack/legacy/plugins/alerting/common/types.ts +++ b/x-pack/legacy/plugins/alerting/common/alert.ts @@ -5,12 +5,13 @@ */ import { SavedObjectAttributes } from 'kibana/server'; -import { AlertActionParams } from '../server/types'; export interface IntervalSchedule extends SavedObjectAttributes { interval: string; } +export type AlertActionParams = SavedObjectAttributes; + export interface AlertAction { group: string; id: string; diff --git a/x-pack/legacy/plugins/alerting/common/alert_instance.ts b/x-pack/legacy/plugins/alerting/common/alert_instance.ts new file mode 100644 index 0000000000000..a6852f06efd34 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/common/alert_instance.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; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; +import { DateFromString } from './date_from_string'; + +const metaSchema = t.partial({ + lastScheduledActions: t.type({ + group: t.string, + date: DateFromString, + }), +}); +export type AlertInstanceMeta = t.TypeOf; + +const stateSchema = t.record(t.string, t.unknown); +export type AlertInstanceState = t.TypeOf; + +export const rawAlertInstance = t.partial({ + state: stateSchema, + meta: metaSchema, +}); +export type RawAlertInstance = t.TypeOf; diff --git a/x-pack/legacy/plugins/alerting/common/alert_task_instance.ts b/x-pack/legacy/plugins/alerting/common/alert_task_instance.ts new file mode 100644 index 0000000000000..50722a471f3d7 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/common/alert_task_instance.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; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; +import { rawAlertInstance } from './alert_instance'; +import { DateFromString } from './date_from_string'; + +export const alertStateSchema = t.partial({ + alertTypeState: t.record(t.string, t.unknown), + alertInstances: t.record(t.string, rawAlertInstance), + previousStartedAt: t.union([t.null, DateFromString]), +}); + +export type AlertTaskState = t.TypeOf; + +export const alertParamsSchema = t.intersection([ + t.type({ + alertId: t.string, + }), + t.partial({ + spaceId: t.string, + }), +]); +export type AlertTaskParams = t.TypeOf; diff --git a/x-pack/legacy/plugins/alerting/common/date_from_string.test.ts b/x-pack/legacy/plugins/alerting/common/date_from_string.test.ts new file mode 100644 index 0000000000000..ecf7bdb324578 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/common/date_from_string.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DateFromString } from './date_from_string'; +import { right, isLeft } from 'fp-ts/lib/Either'; + +describe('DateFromString', () => { + test('validated and parses a string into a Date', () => { + const date = new Date(1973, 10, 30); + expect(DateFromString.decode(date.toISOString())).toEqual(right(date)); + }); + + test('validated and returns a failure for an actual Date', () => { + const date = new Date(1973, 10, 30); + expect(isLeft(DateFromString.decode(date))).toEqual(true); + }); + + test('validated and returns a failure for an invalid Date string', () => { + expect(isLeft(DateFromString.decode('1234-23-45'))).toEqual(true); + }); + + test('validated and returns a failure for a null value', () => { + expect(isLeft(DateFromString.decode(null))).toEqual(true); + }); +}); diff --git a/x-pack/legacy/plugins/alerting/common/date_from_string.ts b/x-pack/legacy/plugins/alerting/common/date_from_string.ts new file mode 100644 index 0000000000000..831891fc12d92 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/common/date_from_string.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; + +// represents a Date from an ISO string +export const DateFromString = new t.Type( + 'DateFromString', + // detect the type + (value): value is Date => value instanceof Date, + (valueToDecode, context) => + either.chain( + // validate this is a string + t.string.validate(valueToDecode, context), + // decode + value => { + const decoded = new Date(value); + return isNaN(decoded.getTime()) ? t.failure(valueToDecode, context) : t.success(decoded); + } + ), + valueToEncode => valueToEncode.toISOString() +); diff --git a/x-pack/legacy/plugins/alerting/common/index.ts b/x-pack/legacy/plugins/alerting/common/index.ts index 9f4141dbcae7d..03b3487f10f1d 100644 --- a/x-pack/legacy/plugins/alerting/common/index.ts +++ b/x-pack/legacy/plugins/alerting/common/index.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './types'; +export * from './alert'; +export * from './alert_instance'; +export * from './alert_task_instance'; diff --git a/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts index df67f7d2a1d9e..4d106178f86fb 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts @@ -3,10 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import * as t from 'io-ts'; +import { + AlertInstanceMeta, + AlertInstanceState, + RawAlertInstance, + rawAlertInstance, +} from '../../common'; import { State, Context } from '../types'; -import { DateFromString } from '../lib/types'; import { parseDuration } from '../lib'; interface ScheduledExecutionOptions { @@ -14,24 +18,7 @@ interface ScheduledExecutionOptions { context: Context; state: State; } - -const metaSchema = t.partial({ - lastScheduledActions: t.type({ - group: t.string, - date: DateFromString, - }), -}); -type AlertInstanceMeta = t.TypeOf; - -const stateSchema = t.record(t.string, t.unknown); -type AlertInstanceState = t.TypeOf; - -export const rawAlertInstance = t.partial({ - state: stateSchema, - meta: metaSchema, -}); -export type RawAlertInstance = t.TypeOf; - +export type AlertInstances = Record; export class AlertInstance { private scheduledExecutionOptions?: ScheduledExecutionOptions; private meta: AlertInstanceMeta; diff --git a/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts index fc828096adf28..40ee0874e805c 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { AlertInstance, RawAlertInstance, rawAlertInstance } from './alert_instance'; +export { AlertInstance } from './alert_instance'; export { createAlertInstanceFactory } from './create_alert_instance_factory'; diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.ts index 4f4443a9ce655..06d88d63ffc3a 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.ts @@ -22,6 +22,7 @@ import { AlertType, IntervalSchedule, SanitizedAlert, + AlertTaskState, } from './types'; import { validateAlertTypeParams } from './lib'; import { @@ -31,7 +32,7 @@ import { } from '../../../../plugins/security/server'; import { EncryptedSavedObjectsPluginStart } from '../../../../plugins/encrypted_saved_objects/server'; import { TaskManagerStartContract } from '../../../../plugins/task_manager/server'; -import { AlertTaskState, taskInstanceToAlertTaskInstance } from './task_runner/alert_task_instance'; +import { taskInstanceToAlertTaskInstance } from './task_runner/alert_task_instance'; type NormalizedAlertAction = Omit; export type CreateAPIKeyResult = diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.ts b/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.ts index 33b416fe8e2da..6bc318070377d 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.ts @@ -7,32 +7,12 @@ import * as t from 'io-ts'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { ConcreteTaskInstance } from '../../../../../plugins/task_manager/server'; -import { SanitizedAlert } from '../types'; -import { DateFromString } from '../lib/types'; -import { AlertInstance, rawAlertInstance } from '../alert_instance'; +import { SanitizedAlert, AlertTaskState, alertParamsSchema, alertStateSchema } from '../../common'; export interface AlertTaskInstance extends ConcreteTaskInstance { state: AlertTaskState; } -export const alertStateSchema = t.partial({ - alertTypeState: t.record(t.string, t.unknown), - alertInstances: t.record(t.string, rawAlertInstance), - previousStartedAt: t.union([t.null, DateFromString]), -}); -export type AlertInstances = Record; -export type AlertTaskState = t.TypeOf; - -const alertParamsSchema = t.intersection([ - t.type({ - alertId: t.string, - }), - t.partial({ - spaceId: t.string, - }), -]); -export type AlertTaskParams = t.TypeOf; - const enumerateErrorFields = (e: t.Errors) => `${e.map(({ context }) => context.map(({ key }) => key).join('.'))}`; diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts index 1466d3ccd274b..2632decb125f0 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts @@ -10,16 +10,21 @@ import { SavedObject } from '../../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; import { ConcreteTaskInstance } from '../../../../../plugins/task_manager/server'; import { createExecutionHandler } from './create_execution_handler'; -import { AlertInstance, createAlertInstanceFactory, RawAlertInstance } from '../alert_instance'; +import { AlertInstance, createAlertInstanceFactory } from '../alert_instance'; import { getNextRunAt } from './get_next_run_at'; import { validateAlertTypeParams } from '../lib'; -import { AlertType, RawAlert, IntervalSchedule, Services, AlertInfoParams } from '../types'; -import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type'; import { + AlertType, + RawAlert, + IntervalSchedule, + Services, + AlertInfoParams, + RawAlertInstance, AlertTaskState, - AlertInstances, - taskInstanceToAlertTaskInstance, -} from './alert_task_instance'; +} from '../types'; +import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type'; +import { taskInstanceToAlertTaskInstance } from './alert_task_instance'; +import { AlertInstances } from '../alert_instance/alert_instance'; const FALLBACK_RETRY_INTERVAL: IntervalSchedule = { interval: '5m' }; diff --git a/x-pack/legacy/plugins/alerting/server/types.ts b/x-pack/legacy/plugins/alerting/server/types.ts index 5aef3b1337a88..5e8adadf74ac0 100644 --- a/x-pack/legacy/plugins/alerting/server/types.ts +++ b/x-pack/legacy/plugins/alerting/server/types.ts @@ -8,8 +8,7 @@ import { AlertInstance } from './alert_instance'; import { AlertTypeRegistry as OrigAlertTypeRegistry } from './alert_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { SavedObjectAttributes, SavedObjectsClientContract } from '../../../../../src/core/server'; -import { Alert } from '../common'; - +import { Alert, AlertActionParams } from '../common'; export * from '../common'; export type State = Record; @@ -53,8 +52,6 @@ export interface AlertType { executor: ({ services, params, state }: AlertExecutorOptions) => Promise; } -export type AlertActionParams = SavedObjectAttributes; - export interface RawAlertAction extends SavedObjectAttributes { group: string; actionRef: string; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index 306d959b3523e..9c6f4daccc705 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -16,12 +16,15 @@ import { enableAlert, loadAlert, loadAlerts, + loadAlertState, loadAlertTypes, muteAlerts, unmuteAlerts, muteAlert, unmuteAlert, updateAlert, + muteAlertInstance, + unmuteAlertInstance, } from './alert_api'; import uuid from 'uuid'; @@ -76,6 +79,70 @@ describe('loadAlert', () => { }); }); +describe('loadAlertState', () => { + test('should call get API with base parameters', async () => { + const alertId = uuid.v4(); + const resolvedValue = { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: {}, + second_instance: {}, + }, + }; + http.get.mockResolvedValueOnce(resolvedValue); + + expect(await loadAlertState({ http, alertId })).toEqual(resolvedValue); + expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}/state`); + }); + + test('should parse AlertInstances', async () => { + const alertId = uuid.v4(); + const resolvedValue = { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: '2020-02-09T23:15:41.941Z', + }, + }, + }, + }, + }; + http.get.mockResolvedValueOnce(resolvedValue); + + expect(await loadAlertState({ http, alertId })).toEqual({ + ...resolvedValue, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: new Date('2020-02-09T23:15:41.941Z'), + }, + }, + }, + }, + }); + expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}/state`); + }); + + test('should handle empty response from api', async () => { + const alertId = uuid.v4(); + http.get.mockResolvedValueOnce(''); + + expect(await loadAlertState({ http, alertId })).toEqual({}); + expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}/state`); + }); +}); + describe('loadAlerts', () => { test('should call find API with base parameters', async () => { const resolvedValue = { @@ -410,6 +477,34 @@ describe('disableAlert', () => { }); }); +describe('muteAlertInstance', () => { + test('should call mute instance alert API', async () => { + const result = await muteAlertInstance({ http, id: '1', instanceId: '123' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/alert_instance/123/_mute", + ], + ] + `); + }); +}); + +describe('unmuteAlertInstance', () => { + test('should call mute instance alert API', async () => { + const result = await unmuteAlertInstance({ http, id: '1', instanceId: '123' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/alert_instance/123/_unmute", + ], + ] + `); + }); +}); + describe('muteAlert', () => { test('should call mute alert API', async () => { const result = await muteAlert({ http, id: '1' }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index acc318bd5fbea..22fd01c1aee81 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -5,8 +5,12 @@ */ import { HttpSetup } from 'kibana/public'; +import * as t from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; import { BASE_ALERT_API_PATH } from '../constants'; -import { Alert, AlertType, AlertWithoutId } from '../../types'; +import { Alert, AlertType, AlertWithoutId, AlertTaskState } from '../../types'; +import { alertStateSchema } from '../../../../../legacy/plugins/alerting/common'; export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { return await http.get(`${BASE_ALERT_API_PATH}/types`); @@ -22,6 +26,27 @@ export async function loadAlert({ return await http.get(`${BASE_ALERT_API_PATH}/${alertId}`); } +type EmptyHttpResponse = ''; +export async function loadAlertState({ + http, + alertId, +}: { + http: HttpSetup; + alertId: string; +}): Promise { + return await http + .get(`${BASE_ALERT_API_PATH}/${alertId}/state`) + .then((state: AlertTaskState | EmptyHttpResponse) => (state ? state : {})) + .then((state: AlertTaskState) => { + return pipe( + alertStateSchema.decode(state), + fold((e: t.Errors) => { + throw new Error(`Alert "${alertId}" has invalid state`); + }, t.identity) + ); + }); +} + export async function loadAlerts({ http, page, @@ -133,6 +158,30 @@ export async function disableAlerts({ await Promise.all(ids.map(id => disableAlert({ id, http }))); } +export async function muteAlertInstance({ + id, + instanceId, + http, +}: { + id: string; + instanceId: string; + http: HttpSetup; +}): Promise { + await http.post(`${BASE_ALERT_API_PATH}/${id}/alert_instance/${instanceId}/_mute`); +} + +export async function unmuteAlertInstance({ + id, + instanceId, + http, +}: { + id: string; + instanceId: string; + http: HttpSetup; +}): Promise { + await http.post(`${BASE_ALERT_API_PATH}/${id}/alert_instance/${instanceId}/_unmute`); +} + export async function muteAlert({ id, http }: { id: string; http: HttpSetup }): Promise { await http.post(`${BASE_ALERT_API_PATH}/${id}/_mute_all`); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index 683aca742ac87..e8221e546cea0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -32,6 +32,7 @@ const mockAlertApis = { unmuteAlert: jest.fn(), enableAlert: jest.fn(), disableAlert: jest.fn(), + requestRefresh: jest.fn(), }; // const AlertDetails = withBulkAlertOperations(RawAlertDetails); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index ffdf846efd49d..9c3b69962879f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -19,6 +19,8 @@ import { EuiPageContentBody, EuiButtonEmpty, EuiSwitch, + EuiCallOut, + EuiSpacer, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useAppDependencies } from '../../../app_context'; @@ -28,11 +30,13 @@ import { ComponentOpts as BulkOperationsComponentOpts, withBulkAlertOperations, } from '../../common/components/with_bulk_alert_api_operations'; +import { AlertInstancesRouteWithApi } from './alert_instances_route'; type AlertDetailsProps = { alert: Alert; alertType: AlertType; actionTypes: ActionType[]; + requestRefresh: () => Promise; } & Pick; export const AlertDetails: React.FunctionComponent = ({ @@ -43,6 +47,7 @@ export const AlertDetails: React.FunctionComponent = ({ enableAlert, unmuteAlert, muteAlert, + requestRefresh, }) => { const { capabilities } = useAppDependencies(); @@ -131,10 +136,11 @@ export const AlertDetails: React.FunctionComponent = ({ setIsEnabled(true); await enableAlert(alert); } + requestRefresh(); }} label={ } @@ -154,10 +160,11 @@ export const AlertDetails: React.FunctionComponent = ({ setIsMuted(true); await muteAlert(alert); } + requestRefresh(); }} label={ } @@ -166,6 +173,23 @@ export const AlertDetails: React.FunctionComponent = ({ + + + + {alert.enabled ? ( + + ) : ( + +

+ +

+
+ )} +
+
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx index 4e00ea304d987..9198607df7863 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx @@ -41,7 +41,7 @@ export const AlertDetailsRoute: React.FunctionComponent const [alert, setAlert] = useState(null); const [alertType, setAlertType] = useState(null); const [actionTypes, setActionTypes] = useState(null); - + const [refreshToken, requestRefresh] = React.useState(); useEffect(() => { getAlertData( alertId, @@ -53,10 +53,15 @@ export const AlertDetailsRoute: React.FunctionComponent setActionTypes, toastNotifications ); - }, [alertId, http, loadActionTypes, loadAlert, loadAlertTypes, toastNotifications]); + }, [alertId, http, loadActionTypes, loadAlert, loadAlertTypes, toastNotifications, refreshToken]); return alert && alertType && actionTypes ? ( - + requestRefresh(Date.now())} + /> ) : (
{ + jest.resetAllMocks(); + global.Date.now = jest.fn(() => fakeNow.getTime()); +}); + +jest.mock('../../../app_context', () => { + const toastNotifications = jest.fn(); + return { + useAppDependencies: jest.fn(() => ({ toastNotifications })), + }; +}); + +describe('alert_instances', () => { + it('render a list of alert instances', () => { + const alert = mockAlert(); + + const alertState = mockAlertState(); + const instances: AlertInstanceListItem[] = [ + alertInstanceToListItem(alert, 'first_instance', alertState.alertInstances!.first_instance), + alertInstanceToListItem(alert, 'second_instance', alertState.alertInstances!.second_instance), + ]; + + expect( + shallow() + .find(EuiBasicTable) + .prop('items') + ).toEqual(instances); + }); + + it('render all active alert instances', () => { + const alert = mockAlert(); + const instances = { + ['us-central']: { + state: {}, + meta: { + lastScheduledActions: { + group: 'warning', + date: fake2MinutesAgo, + }, + }, + }, + ['us-east']: {}, + }; + expect( + shallow( + + ) + .find(EuiBasicTable) + .prop('items') + ).toEqual([ + alertInstanceToListItem(alert, 'us-central', instances['us-central']), + alertInstanceToListItem(alert, 'us-east', instances['us-east']), + ]); + }); + + it('render all inactive alert instances', () => { + const alert = mockAlert({ + mutedInstanceIds: ['us-west', 'us-east'], + }); + + expect( + shallow( + + ) + .find(EuiBasicTable) + .prop('items') + ).toEqual([ + alertInstanceToListItem(alert, 'us-west'), + alertInstanceToListItem(alert, 'us-east'), + ]); + }); +}); + +describe('alertInstanceToListItem', () => { + it('handles active instances', () => { + const alert = mockAlert(); + const start = fake2MinutesAgo; + const instance: RawAlertInstance = { + meta: { + lastScheduledActions: { + date: start, + group: 'default', + }, + }, + }; + + expect(alertInstanceToListItem(alert, 'id', instance)).toEqual({ + instance: 'id', + status: { label: 'Active', healthColor: 'primary' }, + start, + duration: fakeNow.getTime() - fake2MinutesAgo.getTime(), + isMuted: false, + }); + }); + + it('handles active muted instances', () => { + const alert = mockAlert({ + mutedInstanceIds: ['id'], + }); + const start = fake2MinutesAgo; + const instance: RawAlertInstance = { + meta: { + lastScheduledActions: { + date: start, + group: 'default', + }, + }, + }; + + expect(alertInstanceToListItem(alert, 'id', instance)).toEqual({ + instance: 'id', + status: { label: 'Active', healthColor: 'primary' }, + start, + duration: fakeNow.getTime() - fake2MinutesAgo.getTime(), + isMuted: true, + }); + }); + + it('handles active instances with no meta', () => { + const alert = mockAlert(); + const instance: RawAlertInstance = {}; + + expect(alertInstanceToListItem(alert, 'id', instance)).toEqual({ + instance: 'id', + status: { label: 'Active', healthColor: 'primary' }, + start: undefined, + duration: 0, + isMuted: false, + }); + }); + + it('handles active instances with no lastScheduledActions', () => { + const alert = mockAlert(); + const instance: RawAlertInstance = { + meta: {}, + }; + + expect(alertInstanceToListItem(alert, 'id', instance)).toEqual({ + instance: 'id', + status: { label: 'Active', healthColor: 'primary' }, + start: undefined, + duration: 0, + isMuted: false, + }); + }); + + it('handles muted inactive instances', () => { + const alert = mockAlert({ + mutedInstanceIds: ['id'], + }); + expect(alertInstanceToListItem(alert, 'id')).toEqual({ + instance: 'id', + status: { label: 'Inactive', healthColor: 'subdued' }, + start: undefined, + duration: 0, + isMuted: true, + }); + }); +}); + +function mockAlert(overloads: Partial = {}): Alert { + return { + id: uuid.v4(), + enabled: true, + name: `alert-${uuid.v4()}`, + tags: [], + alertTypeId: '.noop', + consumer: 'consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + ...overloads, + }; +} + +function mockAlertState(overloads: Partial = {}): AlertTaskState { + return { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: new Date(), + }, + }, + }, + second_instance: {}, + }, + ...overloads, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx new file mode 100644 index 0000000000000..1f0e4f015f229 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import moment, { Duration } from 'moment'; +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable, EuiButtonToggle, EuiBadge, EuiHealth } from '@elastic/eui'; +// @ts-ignore +import { RIGHT_ALIGNMENT, CENTER_ALIGNMENT } from '@elastic/eui/lib/services'; +import { padLeft, difference } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { RawAlertInstance } from '../../../../../../../legacy/plugins/alerting/common'; +import { Alert, AlertTaskState } from '../../../../types'; +import { + ComponentOpts as AlertApis, + withBulkAlertOperations, +} from '../../common/components/with_bulk_alert_api_operations'; + +type AlertInstancesProps = { + alert: Alert; + alertState: AlertTaskState; + requestRefresh: () => Promise; +} & Pick; + +export const alertInstancesTableColumns = ( + onMuteAction: (instance: AlertInstanceListItem) => Promise +) => [ + { + field: 'instance', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.instance', + { defaultMessage: 'Instance' } + ), + sortable: false, + truncateText: true, + 'data-test-subj': 'alertInstancesTableCell-instance', + }, + { + field: 'status', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.status', + { defaultMessage: 'Status' } + ), + render: (value: AlertInstanceListItemStatus, instance: AlertInstanceListItem) => { + return {value.label}; + }, + sortable: false, + 'data-test-subj': 'alertInstancesTableCell-status', + }, + { + field: 'start', + render: (value: Date | undefined, instance: AlertInstanceListItem) => { + return value ? moment(value).format('D MMM YYYY @ HH:mm:ss') : ''; + }, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.start', + { defaultMessage: 'Start' } + ), + sortable: false, + 'data-test-subj': 'alertInstancesTableCell-start', + }, + { + field: 'duration', + align: CENTER_ALIGNMENT, + render: (value: number, instance: AlertInstanceListItem) => { + return value ? durationAsString(moment.duration(value)) : ''; + }, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.duration', + { defaultMessage: 'Duration' } + ), + sortable: false, + 'data-test-subj': 'alertInstancesTableCell-duration', + }, + { + field: '', + align: RIGHT_ALIGNMENT, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.actions', + { defaultMessage: 'Actions' } + ), + render: (alertInstance: AlertInstanceListItem) => { + return ( + + {alertInstance.isMuted ? ( + + + + ) : ( + + )} + onMuteAction(alertInstance)} + isSelected={alertInstance.isMuted} + isEmpty + isIconOnly + /> + + ); + }, + sortable: false, + 'data-test-subj': 'alertInstancesTableCell-actions', + }, +]; + +function durationAsString(duration: Duration): string { + return [duration.hours(), duration.minutes(), duration.seconds()] + .map(value => padLeft(`${value}`, 2, '0')) + .join(':'); +} + +export function AlertInstances({ + alert, + alertState: { alertInstances = {} }, + muteAlertInstance, + unmuteAlertInstance, + requestRefresh, +}: AlertInstancesProps) { + const onMuteAction = async (instance: AlertInstanceListItem) => { + await (instance.isMuted + ? unmuteAlertInstance(alert, instance.instance) + : muteAlertInstance(alert, instance.instance)); + requestRefresh(); + }; + return ( + + alertInstanceToListItem(alert, instanceId, instance) + ), + ...difference(alert.mutedInstanceIds, Object.keys(alertInstances)).map(instanceId => + alertInstanceToListItem(alert, instanceId) + ), + ]} + rowProps={() => ({ + 'data-test-subj': 'alert-instance-row', + })} + cellProps={() => ({ + 'data-test-subj': 'cell', + })} + columns={alertInstancesTableColumns(onMuteAction)} + data-test-subj="alertInstancesList" + /> + ); +} +export const AlertInstancesWithApi = withBulkAlertOperations(AlertInstances); + +interface AlertInstanceListItemStatus { + label: string; + healthColor: string; +} +export interface AlertInstanceListItem { + instance: string; + status: AlertInstanceListItemStatus; + start?: Date; + duration: number; + isMuted: boolean; +} + +const ACTIVE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.status.active', + { defaultMessage: 'Active' } +); + +const INACTIVE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.status.inactive', + { defaultMessage: 'Inactive' } +); + +const durationSince = (start?: Date) => (start ? Date.now() - start.getTime() : 0); + +export function alertInstanceToListItem( + alert: Alert, + instanceId: string, + instance?: RawAlertInstance +): AlertInstanceListItem { + const isMuted = alert.mutedInstanceIds.findIndex(muted => muted === instanceId) >= 0; + return { + instance: instanceId, + status: instance + ? { label: ACTIVE_LABEL, healthColor: 'primary' } + : { label: INACTIVE_LABEL, healthColor: 'subdued' }, + start: instance?.meta?.lastScheduledActions?.date, + duration: durationSince(instance?.meta?.lastScheduledActions?.date), + isMuted, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx new file mode 100644 index 0000000000000..9bff33e4aa69c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import uuid from 'uuid'; +import { shallow } from 'enzyme'; +import { ToastsApi } from 'kibana/public'; +import { AlertInstancesRoute, getAlertState } from './alert_instances_route'; +import { Alert } from '../../../../types'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +jest.mock('../../../app_context', () => { + const toastNotifications = jest.fn(); + return { + useAppDependencies: jest.fn(() => ({ toastNotifications })), + }; +}); +describe('alert_state_route', () => { + it('render a loader while fetching data', () => { + const alert = mockAlert(); + + expect( + shallow().containsMatchingElement( + + ) + ).toBeTruthy(); + }); +}); + +describe('getAlertState useEffect handler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches alert state', async () => { + const alert = mockAlert(); + const alertState = mockAlertState(); + const { loadAlertState } = mockApis(); + const { setAlertState } = mockStateSetter(); + + loadAlertState.mockImplementationOnce(async () => alertState); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + + await getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); + + expect(loadAlertState).toHaveBeenCalledWith(alert.id); + expect(setAlertState).toHaveBeenCalledWith(alertState); + }); + + it('displays an error if the alert state isnt found', async () => { + const actionType = { + id: '.server-log', + name: 'Server log', + enabled: true, + }; + const alert = mockAlert({ + actions: [ + { + group: '', + id: uuid.v4(), + actionTypeId: actionType.id, + params: {}, + }, + ], + }); + + const { loadAlertState } = mockApis(); + const { setAlertState } = mockStateSetter(); + + loadAlertState.mockImplementation(async () => { + throw new Error('OMG'); + }); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + await getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); + expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); + expect(toastNotifications.addDanger).toHaveBeenCalledWith({ + title: 'Unable to load alert state: OMG', + }); + }); +}); + +function mockApis() { + return { + loadAlertState: jest.fn(), + requestRefresh: jest.fn(), + }; +} + +function mockStateSetter() { + return { + setAlertState: jest.fn(), + }; +} + +function mockAlert(overloads: Partial = {}): Alert { + return { + id: uuid.v4(), + enabled: true, + name: `alert-${uuid.v4()}`, + tags: [], + alertTypeId: '.noop', + consumer: 'consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + ...overloads, + }; +} + +function mockAlertState(overloads: Partial = {}): any { + return { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: new Date(), + }, + }, + }, + second_instance: {}, + }, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx new file mode 100644 index 0000000000000..498ecffe9b947 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ToastsApi } from 'kibana/public'; +import React, { useState, useEffect } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { Alert, AlertTaskState } from '../../../../types'; +import { useAppDependencies } from '../../../app_context'; +import { + ComponentOpts as AlertApis, + withBulkAlertOperations, +} from '../../common/components/with_bulk_alert_api_operations'; +import { AlertInstancesWithApi as AlertInstances } from './alert_instances'; + +type WithAlertStateProps = { + alert: Alert; + requestRefresh: () => Promise; +} & Pick; + +export const AlertInstancesRoute: React.FunctionComponent = ({ + alert, + requestRefresh, + loadAlertState, +}) => { + const { http, toastNotifications } = useAppDependencies(); + + const [alertState, setAlertState] = useState(null); + + useEffect(() => { + getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); + }, [alert, http, loadAlertState, toastNotifications]); + + return alertState ? ( + + ) : ( +
+ +
+ ); +}; + +export async function getAlertState( + alertId: string, + loadAlertState: AlertApis['loadAlertState'], + setAlertState: React.Dispatch>, + toastNotifications: Pick +) { + try { + const loadedState = await loadAlertState(alertId); + setAlertState(loadedState); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertStateMessage', + { + defaultMessage: 'Unable to load alert state: {message}', + values: { + message: e.message, + }, + } + ), + }); + } +} + +export const AlertInstancesRouteWithApi = withBulkAlertOperations(AlertInstancesRoute); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx index c61ba631ab868..4b348b85fe5bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -6,7 +6,7 @@ import React from 'react'; -import { Alert, AlertType } from '../../../../types'; +import { Alert, AlertType, AlertTaskState } from '../../../../types'; import { useAppDependencies } from '../../../app_context'; import { deleteAlerts, @@ -19,7 +19,10 @@ import { enableAlert, muteAlert, unmuteAlert, + muteAlertInstance, + unmuteAlertInstance, loadAlert, + loadAlertState, loadAlertTypes, } from '../../../lib/alert_api'; @@ -31,10 +34,13 @@ export interface ComponentOpts { deleteAlerts: (alerts: Alert[]) => Promise; muteAlert: (alert: Alert) => Promise; unmuteAlert: (alert: Alert) => Promise; + muteAlertInstance: (alert: Alert, alertInstanceId: string) => Promise; + unmuteAlertInstance: (alert: Alert, alertInstanceId: string) => Promise; enableAlert: (alert: Alert) => Promise; disableAlert: (alert: Alert) => Promise; deleteAlert: (alert: Alert) => Promise; loadAlert: (id: Alert['id']) => Promise; + loadAlertState: (id: Alert['id']) => Promise; loadAlertTypes: () => Promise; } @@ -76,6 +82,16 @@ export function withBulkAlertOperations( return unmuteAlert({ http, id: alert.id }); } }} + muteAlertInstance={async (alert: Alert, instanceId: string) => { + if (!isAlertInstanceMuted(alert, instanceId)) { + return muteAlertInstance({ http, id: alert.id, instanceId }); + } + }} + unmuteAlertInstance={async (alert: Alert, instanceId: string) => { + if (isAlertInstanceMuted(alert, instanceId)) { + return unmuteAlertInstance({ http, id: alert.id, instanceId }); + } + }} enableAlert={async (alert: Alert) => { if (isAlertDisabled(alert)) { return enableAlert({ http, id: alert.id }); @@ -88,6 +104,7 @@ export function withBulkAlertOperations( }} deleteAlert={async (alert: Alert) => deleteAlert({ http, id: alert.id })} loadAlert={async (alertId: Alert['id']) => loadAlert({ http, alertId })} + loadAlertState={async (alertId: Alert['id']) => loadAlertState({ http, alertId })} loadAlertTypes={async () => loadAlertTypes({ http })} /> ); @@ -101,3 +118,7 @@ function isAlertDisabled(alert: Alert) { function isAlertMuted(alert: Alert) { return alert.muteAll === true; } + +function isAlertInstanceMuted(alert: Alert, instanceId: string) { + return alert.mutedInstanceIds.findIndex(muted => muted === instanceId) >= 0; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 73ecafb023848..30718f702c9cb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -5,8 +5,13 @@ */ import { ActionType } from '../../actions/common'; import { TypeRegistry } from './application/type_registry'; -import { SanitizedAlert as Alert, AlertAction } from '../../../legacy/plugins/alerting/common'; -export { Alert, AlertAction }; +import { + SanitizedAlert as Alert, + AlertAction, + AlertTaskState, + RawAlertInstance, +} from '../../../legacy/plugins/alerting/common'; +export { Alert, AlertAction, AlertTaskState, RawAlertInstance }; export { ActionType }; export type ActionTypeIndex = Record; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index e8ed54571c77c..938b98591b6a2 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -6,141 +6,328 @@ import expect from '@kbn/expect'; import uuid from 'uuid'; +import { omit } from 'lodash'; +import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header', 'alertDetailsUI']); const browser = getService('browser'); + const log = getService('log'); const alerting = getService('alerting'); + const retry = getService('retry'); describe('Alert Details', function() { - const testRunUuid = uuid.v4(); - - before(async () => { - await pageObjects.common.navigateToApp('triggersActions'); - - const actions = await Promise.all([ - alerting.actions.createAction({ - name: `server-log-${testRunUuid}-${0}`, - actionTypeId: '.server-log', - config: {}, - secrets: {}, - }), - alerting.actions.createAction({ - name: `server-log-${testRunUuid}-${1}`, - actionTypeId: '.server-log', - config: {}, - secrets: {}, - }), - ]); - - const alert = await alerting.alerts.createAlwaysFiringWithActions( - `test-alert-${testRunUuid}`, - actions.map(action => ({ - id: action.id, - group: 'default', - params: { - message: 'from alert 1s', - level: 'warn', - }, - })) - ); - - // refresh to see alert - await browser.refresh(); - - await pageObjects.header.waitUntilLoadingHasFinished(); - - // Verify content - await testSubjects.existOrFail('alertsList'); - - // click on first alert - await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name); - }); - - it('renders the alert details', async () => { - const headingText = await pageObjects.alertDetailsUI.getHeadingText(); - expect(headingText).to.be(`test-alert-${testRunUuid}`); - - const alertType = await pageObjects.alertDetailsUI.getAlertType(); - expect(alertType).to.be(`Always Firing`); - - const { actionType, actionCount } = await pageObjects.alertDetailsUI.getActionsLabels(); - expect(actionType).to.be(`Server log`); - expect(actionCount).to.be(`+1`); - }); + describe('Header', function() { + const testRunUuid = uuid.v4(); + before(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + + const actions = await Promise.all([ + alerting.actions.createAction({ + name: `server-log-${testRunUuid}-${0}`, + actionTypeId: '.server-log', + config: {}, + secrets: {}, + }), + alerting.actions.createAction({ + name: `server-log-${testRunUuid}-${1}`, + actionTypeId: '.server-log', + config: {}, + secrets: {}, + }), + ]); + + const alert = await alerting.alerts.createAlwaysFiringWithActions( + `test-alert-${testRunUuid}`, + actions.map(action => ({ + id: action.id, + group: 'default', + params: { + message: 'from alert 1s', + level: 'warn', + }, + })) + ); + + // refresh to see alert + await browser.refresh(); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify content + await testSubjects.existOrFail('alertsList'); + + // click on first alert + await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name); + }); + + it('renders the alert details', async () => { + const headingText = await pageObjects.alertDetailsUI.getHeadingText(); + expect(headingText).to.be(`test-alert-${testRunUuid}`); + + const alertType = await pageObjects.alertDetailsUI.getAlertType(); + expect(alertType).to.be(`Always Firing`); + + const { actionType, actionCount } = await pageObjects.alertDetailsUI.getActionsLabels(); + expect(actionType).to.be(`Server log`); + expect(actionCount).to.be(`+1`); + }); + + it('should disable the alert', async () => { + const enableSwitch = await testSubjects.find('enableSwitch'); + + const isChecked = await enableSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); + + await enableSwitch.click(); + + const enabledSwitchAfterDisabling = await testSubjects.find('enableSwitch'); + const isCheckedAfterDisabling = await enabledSwitchAfterDisabling.getAttribute( + 'aria-checked' + ); + expect(isCheckedAfterDisabling).to.eql('false'); + }); + + it('shouldnt allow you to mute a disabled alert', async () => { + const disabledEnableSwitch = await testSubjects.find('enableSwitch'); + expect(await disabledEnableSwitch.getAttribute('aria-checked')).to.eql('false'); + + const muteSwitch = await testSubjects.find('muteSwitch'); + expect(await muteSwitch.getAttribute('aria-checked')).to.eql('false'); + + await muteSwitch.click(); + + const muteSwitchAfterTryingToMute = await testSubjects.find('muteSwitch'); + const isDisabledMuteAfterDisabling = await muteSwitchAfterTryingToMute.getAttribute( + 'aria-checked' + ); + expect(isDisabledMuteAfterDisabling).to.eql('false'); + }); + + it('should reenable a disabled the alert', async () => { + const enableSwitch = await testSubjects.find('enableSwitch'); + + const isChecked = await enableSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + + await enableSwitch.click(); + + const enabledSwitchAfterReenabling = await testSubjects.find('enableSwitch'); + const isCheckedAfterDisabling = await enabledSwitchAfterReenabling.getAttribute( + 'aria-checked' + ); + expect(isCheckedAfterDisabling).to.eql('true'); + }); + + it('should mute the alert', async () => { + const muteSwitch = await testSubjects.find('muteSwitch'); + + const isChecked = await muteSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + + await muteSwitch.click(); + + const muteSwitchAfterDisabling = await testSubjects.find('muteSwitch'); + const isCheckedAfterDisabling = await muteSwitchAfterDisabling.getAttribute('aria-checked'); + expect(isCheckedAfterDisabling).to.eql('true'); + }); - it('should disable the alert', async () => { - const enableSwitch = await testSubjects.find('enableSwitch'); + it('should unmute the alert', async () => { + const muteSwitch = await testSubjects.find('muteSwitch'); - const isChecked = await enableSwitch.getAttribute('aria-checked'); - expect(isChecked).to.eql('true'); + const isChecked = await muteSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); - await enableSwitch.click(); + await muteSwitch.click(); - const enabledSwitchAfterDisabling = await testSubjects.find('enableSwitch'); - const isCheckedAfterDisabling = await enabledSwitchAfterDisabling.getAttribute( - 'aria-checked' - ); - expect(isCheckedAfterDisabling).to.eql('false'); + const muteSwitchAfterUnmuting = await testSubjects.find('muteSwitch'); + const isCheckedAfterDisabling = await muteSwitchAfterUnmuting.getAttribute('aria-checked'); + expect(isCheckedAfterDisabling).to.eql('false'); + }); }); - it('shouldnt allow you to mute a disabled alert', async () => { - const disabledEnableSwitch = await testSubjects.find('enableSwitch'); - expect(await disabledEnableSwitch.getAttribute('aria-checked')).to.eql('false'); - - const muteSwitch = await testSubjects.find('muteSwitch'); - expect(await muteSwitch.getAttribute('aria-checked')).to.eql('false'); - - await muteSwitch.click(); + describe('Alert Instances', function() { + const testRunUuid = uuid.v4(); + let alert: any; + + before(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + + const actions = await Promise.all([ + alerting.actions.createAction({ + name: `server-log-${testRunUuid}-${0}`, + actionTypeId: '.server-log', + config: {}, + secrets: {}, + }), + alerting.actions.createAction({ + name: `server-log-${testRunUuid}-${1}`, + actionTypeId: '.server-log', + config: {}, + secrets: {}, + }), + ]); + + const instances = [{ id: 'us-central' }, { id: 'us-east' }, { id: 'us-west' }]; + alert = await alerting.alerts.createAlwaysFiringWithActions( + `test-alert-${testRunUuid}`, + actions.map(action => ({ + id: action.id, + group: 'default', + params: { + message: 'from alert 1s', + level: 'warn', + }, + })), + { + instances, + } + ); + + // refresh to see alert + await browser.refresh(); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify content + await testSubjects.existOrFail('alertsList'); + + // click on first alert + await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name); + + // await first run to complete so we have an initial state + await retry.try(async () => { + const { alertInstances } = await alerting.alerts.getAlertState(alert.id); + expect(Object.keys(alertInstances).length).to.eql(instances.length); + }); + }); + + it('renders the active alert instances', async () => { + const testBeganAt = moment().utc(); + + // Verify content + await testSubjects.existOrFail('alertInstancesList'); + + const { + alertInstances: { + ['us-central']: { + meta: { + lastScheduledActions: { date }, + }, + }, + }, + } = await alerting.alerts.getAlertState(alert.id); + + const dateOnAllInstances = moment(date) + .utc() + .format('D MMM YYYY @ HH:mm:ss'); + + const instancesList = await pageObjects.alertDetailsUI.getAlertInstancesList(); + expect(instancesList.map(instance => omit(instance, 'duration'))).to.eql([ + { + instance: 'us-central', + status: 'Active', + start: dateOnAllInstances, + }, + { + instance: 'us-east', + status: 'Active', + start: dateOnAllInstances, + }, + { + instance: 'us-west', + status: 'Active', + start: dateOnAllInstances, + }, + ]); + + const durationFromInstanceTillPageLoad = moment.duration( + testBeganAt.diff(moment(date).utc()) + ); + instancesList + .map(alertInstance => alertInstance.duration.split(':').map(part => parseInt(part, 10))) + .map(([hours, minutes, seconds]) => + moment.duration({ + hours, + minutes, + seconds, + }) + ) + .forEach(alertInstanceDuration => { + // make sure the duration is within a 2 second range + expect(alertInstanceDuration.as('milliseconds')).to.greaterThan( + durationFromInstanceTillPageLoad.subtract(1000 * 2).as('milliseconds') + ); + expect(alertInstanceDuration.as('milliseconds')).to.lessThan( + durationFromInstanceTillPageLoad.add(1000 * 2).as('milliseconds') + ); + }); + }); + + it('renders the muted inactive alert instances', async () => { + // mute an alert instance that doesn't exist + await alerting.alerts.muteAlertInstance(alert.id, 'eu-east'); + + // refresh to see alert + await browser.refresh(); + + const instancesList = await pageObjects.alertDetailsUI.getAlertInstancesList(); + expect(instancesList.filter(alertInstance => alertInstance.instance === 'eu-east')).to.eql([ + { + instance: 'eu-east', + status: 'Inactive', + start: '', + duration: '', + }, + ]); + }); - const muteSwitchAfterTryingToMute = await testSubjects.find('muteSwitch'); - const isDisabledMuteAfterDisabling = await muteSwitchAfterTryingToMute.getAttribute( - 'aria-checked' - ); - expect(isDisabledMuteAfterDisabling).to.eql('false'); - }); + it('allows the user to mute a specific instance', async () => { + // Verify content + await testSubjects.existOrFail('alertInstancesList'); - it('should reenable a disabled the alert', async () => { - const enableSwitch = await testSubjects.find('enableSwitch'); + log.debug(`Ensuring us-central is not muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('us-central', false); - const isChecked = await enableSwitch.getAttribute('aria-checked'); - expect(isChecked).to.eql('false'); + log.debug(`Muting us-central`); + await pageObjects.alertDetailsUI.clickAlertInstanceMuteButton('us-central'); - await enableSwitch.click(); + log.debug(`Ensuring us-central is muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('us-central', true); + }); - const enabledSwitchAfterReenabling = await testSubjects.find('enableSwitch'); - const isCheckedAfterDisabling = await enabledSwitchAfterReenabling.getAttribute( - 'aria-checked' - ); - expect(isCheckedAfterDisabling).to.eql('true'); - }); + it('allows the user to unmute a specific instance', async () => { + // Verify content + await testSubjects.existOrFail('alertInstancesList'); - it('should mute the alert', async () => { - const muteSwitch = await testSubjects.find('muteSwitch'); + log.debug(`Ensuring us-east is not muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('us-east', false); - const isChecked = await muteSwitch.getAttribute('aria-checked'); - expect(isChecked).to.eql('false'); + log.debug(`Muting us-east`); + await pageObjects.alertDetailsUI.clickAlertInstanceMuteButton('us-east'); - await muteSwitch.click(); + log.debug(`Ensuring us-east is muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('us-east', true); - const muteSwitchAfterDisabling = await testSubjects.find('muteSwitch'); - const isCheckedAfterDisabling = await muteSwitchAfterDisabling.getAttribute('aria-checked'); - expect(isCheckedAfterDisabling).to.eql('true'); - }); + log.debug(`Unmuting us-east`); + await pageObjects.alertDetailsUI.clickAlertInstanceMuteButton('us-east'); - it('should unmute the alert', async () => { - const muteSwitch = await testSubjects.find('muteSwitch'); + log.debug(`Ensuring us-east is not muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('us-east', false); + }); - const isChecked = await muteSwitch.getAttribute('aria-checked'); - expect(isChecked).to.eql('true'); + it('allows the user unmute an inactive instance', async () => { + log.debug(`Ensuring eu-east is muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('eu-east', true); - await muteSwitch.click(); + log.debug(`Unmuting eu-east`); + await pageObjects.alertDetailsUI.clickAlertInstanceMuteButton('eu-east'); - const muteSwitchAfterUnmuting = await testSubjects.find('muteSwitch'); - const isCheckedAfterDisabling = await muteSwitchAfterUnmuting.getAttribute('aria-checked'); - expect(isCheckedAfterDisabling).to.eql('false'); + log.debug(`Ensuring eu-east is removed from list`); + await pageObjects.alertDetailsUI.ensureAlertInstanceExistance('eu-east', false); + }); }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts index 43162e9256370..15d1baadf7806 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts @@ -35,14 +35,15 @@ function createAlwaysFiringAlertType(setupContract: any) { name: 'Always Firing', actionGroups: ['default', 'other'], async executor(alertExecutorOptions: any) { - const { services, state } = alertExecutorOptions; + const { services, state, params } = alertExecutorOptions; + + (params.instances || []).forEach((instance: { id: string; state: any }) => { + services + .alertInstanceFactory(instance.id) + .replaceState({ instanceStateValue: true, ...(instance.state || {}) }) + .scheduleActions('default'); + }); - services - .alertInstanceFactory('1') - .replaceState({ instanceStateValue: true }) - .scheduleActions('default', { - instanceContextValue: true, - }); return { globalStateValue: true, groupInSeriesIndex: (state.groupInSeriesIndex || 0) + 1, diff --git a/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts b/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts index 6d2038a6ba04c..fd936b3738677 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts @@ -4,10 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function AlertDetailsPageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const find = getService('find'); + const log = getService('log'); + const retry = getService('retry'); return { async getHeadingText() { @@ -22,5 +26,71 @@ export function AlertDetailsPageProvider({ getService }: FtrProviderContext) { actionCount: await testSubjects.getVisibleText('actionCountLabel'), }; }, + async getAlertInstancesList() { + const table = await find.byCssSelector( + '.euiBasicTable[data-test-subj="alertInstancesList"]:not(.euiBasicTable-loading)' + ); + const $ = await table.parseDomContent(); + return $.findTestSubjects('alert-instance-row') + .toArray() + .map(row => { + return { + instance: $(row) + .findTestSubject('alertInstancesTableCell-instance') + .find('.euiTableCellContent') + .text(), + status: $(row) + .findTestSubject('alertInstancesTableCell-status') + .find('.euiTableCellContent') + .text(), + start: $(row) + .findTestSubject('alertInstancesTableCell-start') + .find('.euiTableCellContent') + .text(), + duration: $(row) + .findTestSubject('alertInstancesTableCell-duration') + .find('.euiTableCellContent') + .text(), + }; + }); + }, + async clickAlertInstanceMuteButton(instance: string) { + const muteAlertInstanceButton = await testSubjects.find( + `muteAlertInstanceButton_${instance}` + ); + await muteAlertInstanceButton.click(); + }, + async ensureAlertInstanceMute(instance: string, isMuted: boolean) { + await retry.try(async () => { + const muteAlertInstanceButton = await testSubjects.find( + `muteAlertInstanceButton_${instance}` + ); + log.debug(`checked:${await muteAlertInstanceButton.getAttribute('checked')}`); + expect(await muteAlertInstanceButton.getAttribute('checked')).to.eql( + isMuted ? 'true' : null + ); + + expect(await testSubjects.exists(`mutedAlertInstanceLabel_${instance}`)).to.eql(isMuted); + }); + }, + async ensureAlertInstanceExistance(instance: string, shouldExist: boolean) { + await retry.try(async () => { + const table = await find.byCssSelector( + '.euiBasicTable[data-test-subj="alertInstancesList"]:not(.euiBasicTable-loading)' + ); + const $ = await table.parseDomContent(); + expect( + $.findTestSubjects('alert-instance-row') + .toArray() + .filter( + row => + $(row) + .findTestSubject('alertInstancesTableCell-instance') + .find('.euiTableCellContent') + .text() === instance + ) + ).to.eql(shouldExist ? 1 : 0); + }); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts index 1a31d4796d5bc..695751cf5ac49 100644 --- a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts @@ -28,7 +28,8 @@ export class Alerts { id: string; group: string; params: Record; - }> + }>, + params: Record = {} ) { this.log.debug(`creating alert ${name}`); @@ -41,7 +42,7 @@ export class Alerts { schedule: { interval: '1m' }, throttle: '1m', actions, - params: {}, + params, }); if (status !== 200) { throw new Error( @@ -76,4 +77,25 @@ export class Alerts { } this.log.debug(`deleted alert ${alert.id}`); } + + public async getAlertState(id: string) { + this.log.debug(`getting alert ${id} state`); + + const { data } = await this.axios.get(`/api/alert/${id}/state`); + return data; + } + + public async muteAlertInstance(id: string, instanceId: string) { + this.log.debug(`muting instance ${instanceId} under alert ${id}`); + + const { data: alert, status, statusText } = await this.axios.post( + `/api/alert/${id}/alert_instance/${instanceId}/_mute` + ); + if (status !== 204) { + throw new Error( + `Expected status code of 204, received ${status} ${statusText}: ${util.inspect(alert)}` + ); + } + this.log.debug(`muted alert instance ${instanceId}`); + } }