displays Alert Instance state on Alert Details page (#56842) (#57387)

Adds a list of Alert Instances to the Alert Details page based off of the current state of the Alert.

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Gidi Meir Morris 2020-02-12 16:25:07 +13:00 committed by GitHub
parent 7339d02ccb
commit 9017c99bbd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1394 additions and 176 deletions

View file

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

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;
* 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<typeof metaSchema>;
const stateSchema = t.record(t.string, t.unknown);
export type AlertInstanceState = t.TypeOf<typeof stateSchema>;
export const rawAlertInstance = t.partial({
state: stateSchema,
meta: metaSchema,
});
export type RawAlertInstance = t.TypeOf<typeof rawAlertInstance>;

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;
* 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<typeof alertStateSchema>;
export const alertParamsSchema = t.intersection([
t.type({
alertId: t.string,
}),
t.partial({
spaceId: t.string,
}),
]);
export type AlertTaskParams = t.TypeOf<typeof alertParamsSchema>;

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;
* 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);
});
});

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;
* 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<Date, string, unknown>(
'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()
);

View file

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

View file

@ -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<typeof metaSchema>;
const stateSchema = t.record(t.string, t.unknown);
type AlertInstanceState = t.TypeOf<typeof stateSchema>;
export const rawAlertInstance = t.partial({
state: stateSchema,
meta: metaSchema,
});
export type RawAlertInstance = t.TypeOf<typeof rawAlertInstance>;
export type AlertInstances = Record<string, AlertInstance>;
export class AlertInstance {
private scheduledExecutionOptions?: ScheduledExecutionOptions;
private meta: AlertInstanceMeta;

View file

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

View file

@ -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<AlertAction, 'actionTypeId'>;
export type CreateAPIKeyResult =

View file

@ -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<string, AlertInstance>;
export type AlertTaskState = t.TypeOf<typeof alertStateSchema>;
const alertParamsSchema = t.intersection([
t.type({
alertId: t.string,
}),
t.partial({
spaceId: t.string,
}),
]);
export type AlertTaskParams = t.TypeOf<typeof alertParamsSchema>;
const enumerateErrorFields = (e: t.Errors) =>
`${e.map(({ context }) => context.map(({ key }) => key).join('.'))}`;

View file

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

View file

@ -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<string, any>;
@ -53,8 +52,6 @@ export interface AlertType {
executor: ({ services, params, state }: AlertExecutorOptions) => Promise<State | void>;
}
export type AlertActionParams = SavedObjectAttributes;
export interface RawAlertAction extends SavedObjectAttributes {
group: string;
actionRef: string;

View file

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

View file

@ -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<AlertType[]> {
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<AlertTaskState> {
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<void> {
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<void> {
await http.post(`${BASE_ALERT_API_PATH}/${id}/alert_instance/${instanceId}/_unmute`);
}
export async function muteAlert({ id, http }: { id: string; http: HttpSetup }): Promise<void> {
await http.post(`${BASE_ALERT_API_PATH}/${id}/_mute_all`);
}

View file

@ -32,6 +32,7 @@ const mockAlertApis = {
unmuteAlert: jest.fn(),
enableAlert: jest.fn(),
disableAlert: jest.fn(),
requestRefresh: jest.fn(),
};
// const AlertDetails = withBulkAlertOperations(RawAlertDetails);

View file

@ -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<void>;
} & Pick<BulkOperationsComponentOpts, 'disableAlert' | 'enableAlert' | 'unmuteAlert' | 'muteAlert'>;
export const AlertDetails: React.FunctionComponent<AlertDetailsProps> = ({
@ -43,6 +47,7 @@ export const AlertDetails: React.FunctionComponent<AlertDetailsProps> = ({
enableAlert,
unmuteAlert,
muteAlert,
requestRefresh,
}) => {
const { capabilities } = useAppDependencies();
@ -131,10 +136,11 @@ export const AlertDetails: React.FunctionComponent<AlertDetailsProps> = ({
setIsEnabled(true);
await enableAlert(alert);
}
requestRefresh();
}}
label={
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.enableTitle"
id="xpack.triggersActionsUI.sections.alertDetails.collapsedItemActons.enableTitle"
defaultMessage="Enable"
/>
}
@ -154,10 +160,11 @@ export const AlertDetails: React.FunctionComponent<AlertDetailsProps> = ({
setIsMuted(true);
await muteAlert(alert);
}
requestRefresh();
}}
label={
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.muteTitle"
id="xpack.triggersActionsUI.sections.alertDetails.collapsedItemActons.muteTitle"
defaultMessage="Mute"
/>
}
@ -166,6 +173,23 @@ export const AlertDetails: React.FunctionComponent<AlertDetailsProps> = ({
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexItem>
{alert.enabled ? (
<AlertInstancesRouteWithApi requestRefresh={requestRefresh} alert={alert} />
) : (
<EuiCallOut title="Disabled Alert" color="warning" iconType="help">
<p>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertDetails.alertInstances.disabledAlert"
defaultMessage="Disabled Alerts do not have an active state, hence Alert Instances cannot be displayed."
/>
</p>
</EuiCallOut>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>

View file

@ -41,7 +41,7 @@ export const AlertDetailsRoute: React.FunctionComponent<AlertDetailsRouteProps>
const [alert, setAlert] = useState<Alert | null>(null);
const [alertType, setAlertType] = useState<AlertType | null>(null);
const [actionTypes, setActionTypes] = useState<ActionType[] | null>(null);
const [refreshToken, requestRefresh] = React.useState<number>();
useEffect(() => {
getAlertData(
alertId,
@ -53,10 +53,15 @@ export const AlertDetailsRoute: React.FunctionComponent<AlertDetailsRouteProps>
setActionTypes,
toastNotifications
);
}, [alertId, http, loadActionTypes, loadAlert, loadAlertTypes, toastNotifications]);
}, [alertId, http, loadActionTypes, loadAlert, loadAlertTypes, toastNotifications, refreshToken]);
return alert && alertType && actionTypes ? (
<AlertDetails alert={alert} alertType={alertType} actionTypes={actionTypes} />
<AlertDetails
alert={alert}
alertType={alertType}
actionTypes={actionTypes}
requestRefresh={async () => requestRefresh(Date.now())}
/>
) : (
<div
style={{

View file

@ -0,0 +1,236 @@
/*
* 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 { AlertInstances, AlertInstanceListItem, alertInstanceToListItem } from './alert_instances';
import { Alert, AlertTaskState, RawAlertInstance } from '../../../../types';
import { EuiBasicTable } from '@elastic/eui';
const fakeNow = new Date('2020-02-09T23:15:41.941Z');
const fake2MinutesAgo = new Date('2020-02-09T23:13:41.941Z');
const mockAPIs = {
muteAlertInstance: jest.fn(),
unmuteAlertInstance: jest.fn(),
requestRefresh: jest.fn(),
};
beforeAll(() => {
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(<AlertInstances {...mockAPIs} alert={alert} alertState={alertState} />)
.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(
<AlertInstances
{...mockAPIs}
alert={alert}
alertState={mockAlertState({
alertInstances: instances,
})}
/>
)
.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(
<AlertInstances
{...mockAPIs}
alert={alert}
alertState={mockAlertState({
alertInstances: {},
})}
/>
)
.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> = {}): 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> = {}): AlertTaskState {
return {
alertTypeState: {
some: 'value',
},
alertInstances: {
first_instance: {
state: {},
meta: {
lastScheduledActions: {
group: 'first_group',
date: new Date(),
},
},
},
second_instance: {},
},
...overloads,
};
}

View file

@ -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<void>;
} & Pick<AlertApis, 'muteAlertInstance' | 'unmuteAlertInstance'>;
export const alertInstancesTableColumns = (
onMuteAction: (instance: AlertInstanceListItem) => Promise<void>
) => [
{
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 <EuiHealth color={value.healthColor}>{value.label}</EuiHealth>;
},
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 (
<Fragment>
{alertInstance.isMuted ? (
<EuiBadge data-test-subj={`mutedAlertInstanceLabel_${alertInstance.instance}`}>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertDetails.alertInstances.mutedAlert"
defaultMessage="Muted"
/>
</EuiBadge>
) : (
<Fragment />
)}
<EuiButtonToggle
label={
alertInstance.isMuted
? i18n.translate(
'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.actions.unmute',
{ defaultMessage: 'Unmute' }
)
: i18n.translate(
'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.actions.mute',
{ defaultMessage: 'Mute' }
)
}
data-test-subj={`muteAlertInstanceButton_${alertInstance.instance}`}
iconType={alertInstance.isMuted ? 'eyeClosed' : 'eye'}
onChange={() => onMuteAction(alertInstance)}
isSelected={alertInstance.isMuted}
isEmpty
isIconOnly
/>
</Fragment>
);
},
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 (
<EuiBasicTable
items={[
...Object.entries(alertInstances).map(([instanceId, instance]) =>
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,
};
}

View file

@ -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(<AlertInstancesRoute alert={alert} {...mockApis()} />).containsMatchingElement(
<EuiLoadingSpinner size="l" />
)
).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> = {}): 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> = {}): any {
return {
alertTypeState: {
some: 'value',
},
alertInstances: {
first_instance: {
state: {},
meta: {
lastScheduledActions: {
group: 'first_group',
date: new Date(),
},
},
},
second_instance: {},
},
};
}

View file

@ -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<void>;
} & Pick<AlertApis, 'loadAlertState'>;
export const AlertInstancesRoute: React.FunctionComponent<WithAlertStateProps> = ({
alert,
requestRefresh,
loadAlertState,
}) => {
const { http, toastNotifications } = useAppDependencies();
const [alertState, setAlertState] = useState<AlertTaskState | null>(null);
useEffect(() => {
getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications);
}, [alert, http, loadAlertState, toastNotifications]);
return alertState ? (
<AlertInstances requestRefresh={requestRefresh} alert={alert} alertState={alertState} />
) : (
<div
style={{
textAlign: 'center',
margin: '4em 0em',
}}
>
<EuiLoadingSpinner size="l" />
</div>
);
};
export async function getAlertState(
alertId: string,
loadAlertState: AlertApis['loadAlertState'],
setAlertState: React.Dispatch<React.SetStateAction<AlertTaskState | null>>,
toastNotifications: Pick<ToastsApi, 'addDanger'>
) {
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);

View file

@ -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<void>;
muteAlert: (alert: Alert) => Promise<void>;
unmuteAlert: (alert: Alert) => Promise<void>;
muteAlertInstance: (alert: Alert, alertInstanceId: string) => Promise<void>;
unmuteAlertInstance: (alert: Alert, alertInstanceId: string) => Promise<void>;
enableAlert: (alert: Alert) => Promise<void>;
disableAlert: (alert: Alert) => Promise<void>;
deleteAlert: (alert: Alert) => Promise<void>;
loadAlert: (id: Alert['id']) => Promise<Alert>;
loadAlertState: (id: Alert['id']) => Promise<AlertTaskState>;
loadAlertTypes: () => Promise<AlertType[]>;
}
@ -76,6 +82,16 @@ export function withBulkAlertOperations<T>(
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<T>(
}}
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;
}

View file

@ -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<string, ActionType>;

View file

@ -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();
describe('Header', function() {
const testRunUuid = uuid.v4();
before(async () => {
await pageObjects.common.navigateToApp('triggersActions');
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 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',
},
}))
);
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 unmute the alert', async () => {
const muteSwitch = await testSubjects.find('muteSwitch');
const isChecked = await muteSwitch.getAttribute('aria-checked');
expect(isChecked).to.eql('true');
await muteSwitch.click();
const muteSwitchAfterUnmuting = await testSubjects.find('muteSwitch');
const isCheckedAfterDisabling = await muteSwitchAfterUnmuting.getAttribute('aria-checked');
expect(isCheckedAfterDisabling).to.eql('false');
});
});
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);
// refresh to see alert
await browser.refresh();
const dateOnAllInstances = moment(date)
.utc()
.format('D MMM YYYY @ HH:mm:ss');
await pageObjects.header.waitUntilLoadingHasFinished();
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,
},
]);
// Verify content
await testSubjects.existOrFail('alertsList');
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')
);
});
});
// click on first alert
await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name);
});
it('renders the muted inactive alert instances', async () => {
// mute an alert instance that doesn't exist
await alerting.alerts.muteAlertInstance(alert.id, 'eu-east');
it('renders the alert details', async () => {
const headingText = await pageObjects.alertDetailsUI.getHeadingText();
expect(headingText).to.be(`test-alert-${testRunUuid}`);
// refresh to see alert
await browser.refresh();
const alertType = await pageObjects.alertDetailsUI.getAlertType();
expect(alertType).to.be(`Always Firing`);
const instancesList = await pageObjects.alertDetailsUI.getAlertInstancesList();
expect(instancesList.filter(alertInstance => alertInstance.instance === 'eu-east')).to.eql([
{
instance: 'eu-east',
status: 'Inactive',
start: '',
duration: '',
},
]);
});
const { actionType, actionCount } = await pageObjects.alertDetailsUI.getActionsLabels();
expect(actionType).to.be(`Server log`);
expect(actionCount).to.be(`+1`);
});
it('allows the user to mute a specific instance', async () => {
// Verify content
await testSubjects.existOrFail('alertInstancesList');
it('should disable 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('true');
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 enabledSwitchAfterDisabling = await testSubjects.find('enableSwitch');
const isCheckedAfterDisabling = await enabledSwitchAfterDisabling.getAttribute(
'aria-checked'
);
expect(isCheckedAfterDisabling).to.eql('false');
});
it('allows the user to unmute a specific instance', async () => {
// Verify content
await testSubjects.existOrFail('alertInstancesList');
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');
log.debug(`Ensuring us-east is not muted`);
await pageObjects.alertDetailsUI.ensureAlertInstanceMute('us-east', false);
const muteSwitch = await testSubjects.find('muteSwitch');
expect(await muteSwitch.getAttribute('aria-checked')).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 muteSwitchAfterTryingToMute = await testSubjects.find('muteSwitch');
const isDisabledMuteAfterDisabling = await muteSwitchAfterTryingToMute.getAttribute(
'aria-checked'
);
expect(isDisabledMuteAfterDisabling).to.eql('false');
});
log.debug(`Unmuting us-east`);
await pageObjects.alertDetailsUI.clickAlertInstanceMuteButton('us-east');
it('should reenable a disabled the alert', async () => {
const enableSwitch = await testSubjects.find('enableSwitch');
log.debug(`Ensuring us-east is not muted`);
await pageObjects.alertDetailsUI.ensureAlertInstanceMute('us-east', false);
});
const isChecked = await enableSwitch.getAttribute('aria-checked');
expect(isChecked).to.eql('false');
it('allows the user unmute an inactive instance', async () => {
log.debug(`Ensuring eu-east is muted`);
await pageObjects.alertDetailsUI.ensureAlertInstanceMute('eu-east', true);
await enableSwitch.click();
log.debug(`Unmuting eu-east`);
await pageObjects.alertDetailsUI.clickAlertInstanceMuteButton('eu-east');
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 unmute the alert', async () => {
const muteSwitch = await testSubjects.find('muteSwitch');
const isChecked = await muteSwitch.getAttribute('aria-checked');
expect(isChecked).to.eql('true');
await muteSwitch.click();
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);
});
});
});
};

View file

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

View file

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

View file

@ -28,7 +28,8 @@ export class Alerts {
id: string;
group: string;
params: Record<string, any>;
}>
}>,
params: Record<string, any> = {}
) {
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}`);
}
}