[Alerting] Update rules detail page to resolve SO IDs if necessary (#108091)

* Adding internal resolve API to resolve rules given an ID

* Updating after merge

* Updating after merge

* Adding resolveRule api to client and adding spacesOss plugin dependency

* Handling 404 errors by calling resolve. Updating unit tests

* Handling aliasMatch and conflict results

* Fixing types

* Unit tests for spaces oss components

* Adding audit event for resolve

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
ymao1 2021-08-18 22:32:19 -04:00 committed by GitHub
parent 09f122b478
commit 48ce73db15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1296 additions and 121 deletions

View file

@ -5,7 +5,11 @@
* 2.0.
*/
import { SavedObjectAttribute, SavedObjectAttributes } from 'kibana/server';
import {
SavedObjectAttribute,
SavedObjectAttributes,
SavedObjectsResolveResponse,
} from 'kibana/server';
import { AlertNotifyWhenType } from './alert_notify_when_type';
export type AlertTypeState = Record<string, unknown>;
@ -76,6 +80,8 @@ export interface Alert<Params extends AlertTypeParams = never> {
}
export type SanitizedAlert<Params extends AlertTypeParams = never> = Omit<Alert<Params>, 'apiKey'>;
export type ResolvedSanitizedRule<Params extends AlertTypeParams = never> = SanitizedAlert<Params> &
Omit<SavedObjectsResolveResponse, 'saved_object'>;
export type SanitizedRuleConfig = Pick<
SanitizedAlert,

View file

@ -22,6 +22,7 @@ import { findRulesRoute } from './find_rules';
import { getRuleAlertSummaryRoute } from './get_rule_alert_summary';
import { getRuleStateRoute } from './get_rule_state';
import { healthRoute } from './health';
import { resolveRuleRoute } from './resolve_rule';
import { ruleTypesRoute } from './rule_types';
import { muteAllRuleRoute } from './mute_all_rule';
import { muteAlertRoute } from './mute_alert';
@ -42,6 +43,7 @@ export function defineRoutes(opts: RouteOptions) {
defineLegacyRoutes(opts);
createRuleRoute(opts);
getRuleRoute(router, licenseState);
resolveRuleRoute(router, licenseState);
updateRuleRoute(router, licenseState);
deleteRuleRoute(router, licenseState);
aggregateRulesRoute(router, licenseState);

View file

@ -0,0 +1,182 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { pick } from 'lodash';
import { resolveRuleRoute } from './resolve_rule';
import { httpServiceMock } from 'src/core/server/mocks';
import { licenseStateMock } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib/license_api_access';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { rulesClientMock } from '../rules_client.mock';
import { ResolvedSanitizedRule } from '../types';
import { AsApiContract } from './lib';
const rulesClient = rulesClientMock.create();
jest.mock('../lib/license_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
});
describe('resolveRuleRoute', () => {
const mockedRule: ResolvedSanitizedRule<{
bar: boolean;
}> = {
id: '1',
alertTypeId: '1',
schedule: { interval: '10s' },
params: {
bar: true,
},
createdAt: new Date(),
updatedAt: new Date(),
actions: [
{
group: 'default',
id: '2',
actionTypeId: 'test',
params: {
foo: true,
},
},
],
consumer: 'bar',
name: 'abc',
tags: ['foo'],
enabled: true,
muteAll: false,
notifyWhen: 'onActionGroupChange',
createdBy: '',
updatedBy: '',
apiKeyOwner: '',
throttle: '30s',
mutedInstanceIds: [],
executionStatus: {
status: 'unknown',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
},
outcome: 'aliasMatch',
alias_target_id: '2',
};
const resolveResult: AsApiContract<ResolvedSanitizedRule<{ bar: boolean }>> = {
...pick(
mockedRule,
'consumer',
'name',
'schedule',
'tags',
'params',
'throttle',
'enabled',
'alias_target_id'
),
rule_type_id: mockedRule.alertTypeId,
notify_when: mockedRule.notifyWhen,
mute_all: mockedRule.muteAll,
created_by: mockedRule.createdBy,
updated_by: mockedRule.updatedBy,
api_key_owner: mockedRule.apiKeyOwner,
muted_alert_ids: mockedRule.mutedInstanceIds,
created_at: mockedRule.createdAt,
updated_at: mockedRule.updatedAt,
id: mockedRule.id,
execution_status: {
status: mockedRule.executionStatus.status,
last_execution_date: mockedRule.executionStatus.lastExecutionDate,
},
actions: [
{
group: mockedRule.actions[0].group,
id: mockedRule.actions[0].id,
params: mockedRule.actions[0].params,
connector_type_id: mockedRule.actions[0].actionTypeId,
},
],
outcome: 'aliasMatch',
};
it('resolves a rule with proper parameters', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
resolveRuleRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rule/{id}/_resolve"`);
rulesClient.resolve.mockResolvedValueOnce(mockedRule);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
params: { id: '1' },
},
['ok']
);
await handler(context, req, res);
expect(rulesClient.resolve).toHaveBeenCalledTimes(1);
expect(rulesClient.resolve.mock.calls[0][0].id).toEqual('1');
expect(res.ok).toHaveBeenCalledWith({
body: resolveResult,
});
});
it('ensures the license allows resolving rules', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
resolveRuleRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
rulesClient.resolve.mockResolvedValueOnce(mockedRule);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
params: { id: '1' },
},
['ok']
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
it('ensures the license check prevents getting rules', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('OMG');
});
resolveRuleRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
rulesClient.resolve.mockResolvedValueOnce(mockedRule);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
params: { id: '1' },
},
['ok']
);
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
});

View file

@ -0,0 +1,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { omit } from 'lodash';
import { schema } from '@kbn/config-schema';
import { IRouter } from 'kibana/server';
import { ILicenseState } from '../lib';
import { verifyAccessAndContext, RewriteResponseCase } from './lib';
import {
AlertTypeParams,
AlertingRequestHandlerContext,
INTERNAL_BASE_ALERTING_API_PATH,
ResolvedSanitizedRule,
} from '../types';
const paramSchema = schema.object({
id: schema.string(),
});
const rewriteBodyRes: RewriteResponseCase<ResolvedSanitizedRule<AlertTypeParams>> = ({
alertTypeId,
createdBy,
updatedBy,
createdAt,
updatedAt,
apiKeyOwner,
notifyWhen,
muteAll,
mutedInstanceIds,
executionStatus,
actions,
scheduledTaskId,
...rest
}) => ({
...rest,
rule_type_id: alertTypeId,
created_by: createdBy,
updated_by: updatedBy,
created_at: createdAt,
updated_at: updatedAt,
api_key_owner: apiKeyOwner,
notify_when: notifyWhen,
mute_all: muteAll,
muted_alert_ids: mutedInstanceIds,
scheduled_task_id: scheduledTaskId,
execution_status: executionStatus && {
...omit(executionStatus, 'lastExecutionDate'),
last_execution_date: executionStatus.lastExecutionDate,
},
actions: actions.map(({ group, id, actionTypeId, params }) => ({
group,
id,
params,
connector_type_id: actionTypeId,
})),
});
export const resolveRuleRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.get(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rule/{id}/_resolve`,
validate: {
params: paramSchema,
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const rulesClient = context.alerting.getRulesClient();
const { id } = req.params;
const rule = await rulesClient.resolve({ id });
return res.ok({
body: rewriteBodyRes(rule),
});
})
)
);
};

View file

@ -16,6 +16,7 @@ const createRulesClientMock = () => {
aggregate: jest.fn(),
create: jest.fn(),
get: jest.fn(),
resolve: jest.fn(),
getAlertState: jest.fn(),
find: jest.fn(),
delete: jest.fn(),

View file

@ -11,6 +11,7 @@ import { AuditEvent } from '../../../security/server';
export enum RuleAuditAction {
CREATE = 'rule_create',
GET = 'rule_get',
RESOLVE = 'rule_resolve',
UPDATE = 'rule_update',
UPDATE_API_KEY = 'rule_update_api_key',
ENABLE = 'rule_enable',
@ -28,6 +29,7 @@ type VerbsTuple = [string, string, string];
const eventVerbs: Record<RuleAuditAction, VerbsTuple> = {
rule_create: ['create', 'creating', 'created'],
rule_get: ['access', 'accessing', 'accessed'],
rule_resolve: ['access', 'accessing', 'accessed'],
rule_update: ['update', 'updating', 'updated'],
rule_update_api_key: ['update API key of', 'updating API key of', 'updated API key of'],
rule_enable: ['enable', 'enabling', 'enabled'],
@ -43,6 +45,7 @@ const eventVerbs: Record<RuleAuditAction, VerbsTuple> = {
const eventTypes: Record<RuleAuditAction, EcsEventType> = {
rule_create: 'creation',
rule_get: 'access',
rule_resolve: 'access',
rule_update: 'change',
rule_update_api_key: 'change',
rule_enable: 'change',

View file

@ -33,6 +33,7 @@ import {
AlertExecutionStatusValues,
AlertNotifyWhenType,
AlertTypeParams,
ResolvedSanitizedRule,
} from '../types';
import {
validateAlertTypeParams,
@ -411,6 +412,52 @@ export class RulesClient {
);
}
public async resolve<Params extends AlertTypeParams = never>({
id,
}: {
id: string;
}): Promise<ResolvedSanitizedRule<Params>> {
const {
saved_object: result,
...resolveResponse
} = await this.unsecuredSavedObjectsClient.resolve<RawAlert>('alert', id);
try {
await this.authorization.ensureAuthorized({
ruleTypeId: result.attributes.alertTypeId,
consumer: result.attributes.consumer,
operation: ReadOperations.Get,
entity: AlertingAuthorizationEntity.Rule,
});
} catch (error) {
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.RESOLVE,
savedObject: { type: 'alert', id },
error,
})
);
throw error;
}
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.RESOLVE,
savedObject: { type: 'alert', id },
})
);
const rule = this.getAlertFromRaw<Params>(
result.id,
result.attributes.alertTypeId,
result.attributes,
result.references
);
return {
...rule,
...resolveResponse,
};
}
public async getAlertState({ id }: { id: string }): Promise<AlertTaskState | void> {
const alert = await this.get({ id });
await this.authorization.ensureAuthorized({

View file

@ -0,0 +1,451 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { RulesClient, ConstructorOptions } from '../rules_client';
import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { taskManagerMock } from '../../../../task_manager/server/mocks';
import { ruleTypeRegistryMock } from '../../rule_type_registry.mock';
import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock';
import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks';
import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
import { AlertingAuthorization } from '../../authorization/alerting_authorization';
import { ActionsAuthorization } from '../../../../actions/server';
import { httpServerMock } from '../../../../../../src/core/server/mocks';
import { auditServiceMock } from '../../../../security/server/audit/index.mock';
import { getBeforeSetup, setGlobalDate } from './lib';
import { RecoveredActionGroup } from '../../../common';
const taskManager = taskManagerMock.createStart();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
const authorization = alertingAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const kibanaVersion = 'v7.10.0';
const rulesClientParams: jest.Mocked<ConstructorOptions> = {
taskManager,
ruleTypeRegistry,
unsecuredSavedObjectsClient,
authorization: (authorization as unknown) as AlertingAuthorization,
actionsAuthorization: (actionsAuthorization as unknown) as ActionsAuthorization,
spaceId: 'default',
namespace: 'default',
getUserName: jest.fn(),
createAPIKey: jest.fn(),
logger: loggingSystemMock.create().get(),
encryptedSavedObjectsClient: encryptedSavedObjects,
getActionsClient: jest.fn(),
getEventLogClient: jest.fn(),
kibanaVersion,
};
beforeEach(() => {
getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry);
(auditLogger.log as jest.Mock).mockClear();
});
setGlobalDate();
describe('resolve()', () => {
test('calls saved objects client with given params', async () => {
const rulesClient = new RulesClient(rulesClientParams);
unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({
saved_object: {
id: '1',
type: 'alert',
attributes: {
alertTypeId: '123',
schedule: { interval: '10s' },
params: {
bar: true,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
foo: true,
},
},
],
notifyWhen: 'onActiveAlert',
},
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
],
},
outcome: 'aliasMatch',
alias_target_id: '2',
});
const result = await rulesClient.resolve({ id: '1' });
expect(result).toMatchInlineSnapshot(`
Object {
"actions": Array [
Object {
"group": "default",
"id": "1",
"params": Object {
"foo": true,
},
},
],
"alertTypeId": "123",
"alias_target_id": "2",
"createdAt": 2019-02-12T21:01:22.479Z,
"id": "1",
"notifyWhen": "onActiveAlert",
"outcome": "aliasMatch",
"params": Object {
"bar": true,
},
"schedule": Object {
"interval": "10s",
},
"updatedAt": 2019-02-12T21:01:22.479Z,
}
`);
expect(unsecuredSavedObjectsClient.resolve).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.resolve.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"alert",
"1",
]
`);
});
test('should call useSavedObjectReferences.injectReferences if defined for rule type', async () => {
const injectReferencesFn = jest.fn().mockReturnValue({
bar: true,
parameterThatIsSavedObjectId: '9',
});
ruleTypeRegistry.get.mockImplementation(() => ({
id: '123',
name: 'Test',
actionGroups: [{ id: 'default', name: 'Default' }],
recoveryActionGroup: RecoveredActionGroup,
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
async executor() {},
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
injectReferences: injectReferencesFn,
},
}));
const rulesClient = new RulesClient(rulesClientParams);
unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({
saved_object: {
id: '1',
type: 'alert',
attributes: {
alertTypeId: '123',
schedule: { interval: '10s' },
params: {
bar: true,
parameterThatIsSavedObjectRef: 'soRef_0',
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
foo: true,
},
},
],
notifyWhen: 'onActiveAlert',
},
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
{
name: 'param:soRef_0',
type: 'someSavedObjectType',
id: '9',
},
],
},
outcome: 'aliasMatch',
alias_target_id: '2',
});
const result = await rulesClient.resolve({ id: '1' });
expect(injectReferencesFn).toHaveBeenCalledWith(
{
bar: true,
parameterThatIsSavedObjectRef: 'soRef_0',
},
[{ id: '9', name: 'soRef_0', type: 'someSavedObjectType' }]
);
expect(result).toMatchInlineSnapshot(`
Object {
"actions": Array [
Object {
"group": "default",
"id": "1",
"params": Object {
"foo": true,
},
},
],
"alertTypeId": "123",
"alias_target_id": "2",
"createdAt": 2019-02-12T21:01:22.479Z,
"id": "1",
"notifyWhen": "onActiveAlert",
"outcome": "aliasMatch",
"params": Object {
"bar": true,
"parameterThatIsSavedObjectId": "9",
},
"schedule": Object {
"interval": "10s",
},
"updatedAt": 2019-02-12T21:01:22.479Z,
}
`);
});
test(`throws an error when references aren't found`, async () => {
const rulesClient = new RulesClient(rulesClientParams);
unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({
saved_object: {
id: '1',
type: 'alert',
attributes: {
alertTypeId: '123',
schedule: { interval: '10s' },
params: {
bar: true,
},
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
foo: true,
},
},
],
},
references: [],
},
outcome: 'aliasMatch',
alias_target_id: '2',
});
await expect(rulesClient.resolve({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Action reference \\"action_0\\" not found in alert id: 1"`
);
});
test('throws an error if useSavedObjectReferences.injectReferences throws an error', async () => {
const injectReferencesFn = jest.fn().mockImplementation(() => {
throw new Error('something went wrong!');
});
ruleTypeRegistry.get.mockImplementation(() => ({
id: '123',
name: 'Test',
actionGroups: [{ id: 'default', name: 'Default' }],
recoveryActionGroup: RecoveredActionGroup,
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
async executor() {},
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
injectReferences: injectReferencesFn,
},
}));
const rulesClient = new RulesClient(rulesClientParams);
unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({
saved_object: {
id: '1',
type: 'alert',
attributes: {
alertTypeId: '123',
schedule: { interval: '10s' },
params: {
bar: true,
parameterThatIsSavedObjectRef: 'soRef_0',
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
foo: true,
},
},
],
notifyWhen: 'onActiveAlert',
},
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
{
name: 'soRef_0',
type: 'someSavedObjectType',
id: '9',
},
],
},
outcome: 'aliasMatch',
alias_target_id: '2',
});
await expect(rulesClient.resolve({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Error injecting reference into rule params for rule id 1 - something went wrong!"`
);
});
describe('authorization', () => {
beforeEach(() => {
unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({
saved_object: {
id: '1',
type: 'alert',
attributes: {
alertTypeId: 'myType',
consumer: 'myApp',
schedule: { interval: '10s' },
params: {
bar: true,
},
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
foo: true,
},
},
],
},
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
],
},
outcome: 'aliasMatch',
alias_target_id: '2',
});
});
test('ensures user is authorised to resolve this type of rule under the consumer', async () => {
const rulesClient = new RulesClient(rulesClientParams);
await rulesClient.resolve({ id: '1' });
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
entity: 'rule',
consumer: 'myApp',
operation: 'get',
ruleTypeId: 'myType',
});
});
test('throws when user is not authorised to get this type of alert', async () => {
const rulesClient = new RulesClient(rulesClientParams);
authorization.ensureAuthorized.mockRejectedValue(
new Error(`Unauthorized to get a "myType" alert for "myApp"`)
);
await expect(rulesClient.resolve({ id: '1' })).rejects.toMatchInlineSnapshot(
`[Error: Unauthorized to get a "myType" alert for "myApp"]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
entity: 'rule',
consumer: 'myApp',
operation: 'get',
ruleTypeId: 'myType',
});
});
});
describe('auditLogger', () => {
beforeEach(() => {
unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({
saved_object: {
id: '1',
type: 'alert',
attributes: {
alertTypeId: '123',
schedule: { interval: '10s' },
params: {
bar: true,
},
actions: [],
},
references: [],
},
outcome: 'aliasMatch',
alias_target_id: '2',
});
});
test('logs audit event when getting a rule', async () => {
const rulesClient = new RulesClient({ ...rulesClientParams, auditLogger });
await rulesClient.resolve({ id: '1' });
expect(auditLogger.log).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
action: 'rule_resolve',
outcome: 'success',
}),
kibana: { saved_object: { id: '1', type: 'alert' } },
})
);
});
test('logs audit event when not authorised to get a rule', async () => {
const rulesClient = new RulesClient({ ...rulesClientParams, auditLogger });
authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
await expect(rulesClient.resolve({ id: '1' })).rejects.toThrow();
expect(auditLogger.log).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
action: 'rule_resolve',
outcome: 'failure',
}),
kibana: {
saved_object: {
id: '1',
type: 'alert',
},
},
error: {
code: 'Error',
message: 'Unauthorized',
},
})
);
});
});
});

View file

@ -8,7 +8,7 @@
"server": true,
"ui": true,
"optionalPlugins": ["alerting", "features", "home", "spaces"],
"requiredPlugins": ["management", "charts", "data", "kibanaReact", "kibanaUtils", "savedObjects"],
"requiredPlugins": ["management", "charts", "data", "kibanaReact", "kibanaUtils", "savedObjects", "spacesOss"],
"configPath": ["xpack", "trigger_actions_ui"],
"extraPublicDirs": ["public/common", "public/common/constants"],
"requiredBundles": ["home", "alerting", "esUiShared", "kibanaReact", "kibanaUtils"]

View file

@ -18,6 +18,7 @@ import { ChartsPluginStart } from '../../../../../src/plugins/charts/public';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { PluginStartContract as AlertingStart } from '../../../alerting/public';
import type { SpacesPluginStart } from '../../../spaces/public';
import type { SpacesOssPluginStart } from '../../../../../src/plugins/spaces_oss/public';
import { suspendedComponentWithProps } from './lib/suspended_component_with_props';
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
@ -36,6 +37,7 @@ export interface TriggersAndActionsUiServices extends CoreStart {
charts: ChartsPluginStart;
alerting?: AlertingStart;
spaces?: SpacesPluginStart;
spacesOss: SpacesOssPluginStart;
storage?: Storage;
setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void;
actionTypeRegistry: ActionTypeRegistryContract;

View file

@ -6,7 +6,7 @@
*/
import { AlertExecutionStatus } from '../../../../../alerting/common';
import { AsApiContract, RewriteRequestCase } from '../../../../../actions/common';
import { Alert, AlertAction } from '../../../types';
import { Alert, AlertAction, ResolvedRule } from '../../../types';
const transformAction: RewriteRequestCase<AlertAction> = ({
group,
@ -59,3 +59,16 @@ export const transformAlert: RewriteRequestCase<Alert> = ({
scheduledTaskId,
...rest,
});
export const transformResolvedRule: RewriteRequestCase<ResolvedRule> = ({
// eslint-disable-next-line @typescript-eslint/naming-convention
alias_target_id,
outcome,
...rest
}: any) => {
return {
...transformAlert(rest),
alias_target_id,
outcome,
};
};

View file

@ -22,3 +22,4 @@ export { loadAlertState } from './state';
export { unmuteAlertInstance } from './unmute_alert';
export { unmuteAlert, unmuteAlerts } from './unmute';
export { updateAlert } from './update';
export { resolveRule } from './resolve_rule';

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { httpServiceMock } from '../../../../../../../src/core/public/mocks';
import { resolveRule } from './resolve_rule';
import uuid from 'uuid';
const http = httpServiceMock.createStartContract();
describe('resolveRule', () => {
test('should call get API with base parameters', async () => {
const ruleId = `${uuid.v4()}/`;
const ruleIdEncoded = encodeURIComponent(ruleId);
const resolvedValue = {
id: '1/',
params: {
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
groupBy: 'all',
threshold: [1000],
index: ['.kibana'],
timeField: 'canvas-element.@created',
},
consumer: 'alerts',
schedule: { interval: '1m' },
tags: ['sdfsdf'],
name: 'dfsdfdsf',
enabled: true,
throttle: '1h',
rule_type_id: '.index-threshold',
created_by: 'elastic',
updated_by: 'elastic',
created_at: '2021-04-01T20:29:18.652Z',
updated_at: '2021-04-01T20:33:38.260Z',
api_key_owner: 'elastic',
notify_when: 'onThrottleInterval',
mute_all: false,
muted_alert_ids: [],
scheduled_task_id: '1',
execution_status: { status: 'ok', last_execution_date: '2021-04-01T21:16:46.709Z' },
actions: [
{
group: 'threshold met',
id: '1',
params: { documents: [{ dsfsdf: 1212 }] },
connector_type_id: '.index',
},
],
outcome: 'aliasMatch',
alias_target_id: '2',
};
http.get.mockResolvedValueOnce(resolvedValue);
expect(await resolveRule({ http, ruleId })).toEqual({
id: '1/',
params: {
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
groupBy: 'all',
threshold: [1000],
index: ['.kibana'],
timeField: 'canvas-element.@created',
},
consumer: 'alerts',
schedule: { interval: '1m' },
tags: ['sdfsdf'],
name: 'dfsdfdsf',
enabled: true,
throttle: '1h',
alertTypeId: '.index-threshold',
createdBy: 'elastic',
updatedBy: 'elastic',
createdAt: '2021-04-01T20:29:18.652Z',
updatedAt: '2021-04-01T20:33:38.260Z',
apiKeyOwner: 'elastic',
notifyWhen: 'onThrottleInterval',
muteAll: false,
mutedInstanceIds: [],
scheduledTaskId: '1',
executionStatus: { status: 'ok', lastExecutionDate: '2021-04-01T21:16:46.709Z' },
actions: [
{
group: 'threshold met',
id: '1',
params: { documents: [{ dsfsdf: 1212 }] },
actionTypeId: '.index',
},
],
outcome: 'aliasMatch',
alias_target_id: '2',
});
expect(http.get).toHaveBeenCalledWith(`/internal/alerting/rule/${ruleIdEncoded}/_resolve`);
});
});

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { HttpSetup } from 'kibana/public';
import { ResolvedRule } from '../../../types';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants';
import { transformResolvedRule } from './common_transformations';
export async function resolveRule({
http,
ruleId,
}: {
http: HttpSetup;
ruleId: string;
}): Promise<ResolvedRule> {
const res = await http.get(
`${INTERNAL_BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(ruleId)}/_resolve`
);
return transformResolvedRule(res);
}

View file

@ -8,45 +8,150 @@
import * as React from 'react';
import uuid from 'uuid';
import { shallow } from 'enzyme';
import { mountWithIntl, nextTick } from '@kbn/test/jest';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory, createLocation } from 'history';
import { ToastsApi } from 'kibana/public';
import { AlertDetailsRoute, getAlertData } from './alert_details_route';
import { AlertDetailsRoute, getRuleData } from './alert_details_route';
import { Alert } from '../../../../types';
import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner';
import { spacesOssPluginMock } from 'src/plugins/spaces_oss/public/mocks';
import { useKibana } from '../../../../common/lib/kibana';
jest.mock('../../../../common/lib/kibana');
class NotFoundError extends Error {
public readonly body: {
statusCode: number;
name: string;
} = {
statusCode: 404,
name: 'Not found',
};
constructor(message: string | undefined) {
super(message);
}
}
describe('alert_details_route', () => {
it('render a loader while fetching data', () => {
const alert = mockAlert();
expect(
shallow(
<AlertDetailsRoute {...mockRouterProps(alert)} {...mockApis()} />
).containsMatchingElement(<CenterJustifiedSpinner />)
).toBeTruthy();
});
});
describe('getAlertData useEffect handler', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('fetches alert', async () => {
const alert = mockAlert();
const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis();
const spacesOssMock = spacesOssPluginMock.createStartContract();
async function setup() {
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
// eslint-disable-next-line react-hooks/rules-of-hooks
useKibanaMock().services.spacesOss = spacesOssMock;
}
it('render a loader while fetching data', () => {
const rule = mockRule();
expect(
shallow(
<AlertDetailsRoute {...mockRouterProps(rule)} {...mockApis()} />
).containsMatchingElement(<CenterJustifiedSpinner />)
).toBeTruthy();
});
it('redirects to another page if fetched rule is an aliasMatch', async () => {
await setup();
const rule = mockRule();
const { loadAlert, resolveRule } = mockApis();
loadAlert.mockImplementationOnce(async () => {
throw new NotFoundError('OMG');
});
resolveRule.mockImplementationOnce(async () => ({
...rule,
id: 'new_id',
outcome: 'aliasMatch',
alias_target_id: rule.id,
}));
const wrapper = mountWithIntl(
<AlertDetailsRoute
{...mockRouterProps(rule)}
{...{ ...mockApis(), loadAlert, resolveRule }}
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadAlert).toHaveBeenCalledWith(rule.id);
expect(resolveRule).toHaveBeenCalledWith(rule.id);
expect((spacesOssMock as any).ui.redirectLegacyUrl).toHaveBeenCalledWith(
`insightsAndAlerting/triggersActions/rule/new_id`,
`rule`
);
});
it('shows warning callout if fetched rule is a conflict', async () => {
await setup();
const rule = mockRule();
const ruleType = {
id: rule.alertTypeId,
name: 'type name',
authorizedConsumers: ['consumer'],
};
const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis();
loadAlert.mockImplementationOnce(async () => {
throw new NotFoundError('OMG');
});
loadAlertTypes.mockImplementationOnce(async () => [ruleType]);
loadActionTypes.mockImplementation(async () => []);
resolveRule.mockImplementationOnce(async () => ({
...rule,
id: 'new_id',
outcome: 'conflict',
alias_target_id: rule.id,
}));
const wrapper = mountWithIntl(
<AlertDetailsRoute
{...mockRouterProps(rule)}
{...{ ...mockApis(), loadAlert, loadAlertTypes, loadActionTypes, resolveRule }}
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadAlert).toHaveBeenCalledWith(rule.id);
expect(resolveRule).toHaveBeenCalledWith(rule.id);
expect((spacesOssMock as any).ui.components.getLegacyUrlConflict).toHaveBeenCalledWith({
currentObjectId: 'new_id',
objectNoun: 'rule',
otherObjectId: rule.id,
otherObjectPath: `insightsAndAlerting/triggersActions/rule/${rule.id}`,
});
});
});
describe('getRuleData useEffect handler', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('fetches rule', async () => {
const rule = mockRule();
const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis();
const { setAlert, setAlertType, setActionTypes } = mockStateSetter();
loadAlert.mockImplementationOnce(async () => alert);
loadAlert.mockImplementationOnce(async () => rule);
const toastNotifications = ({
addDanger: jest.fn(),
} as unknown) as ToastsApi;
await getAlertData(
alert.id,
await getRuleData(
rule.id,
loadAlert,
loadAlertTypes,
resolveRule,
loadActionTypes,
setAlert,
setAlertType,
@ -54,45 +159,47 @@ describe('getAlertData useEffect handler', () => {
toastNotifications
);
expect(loadAlert).toHaveBeenCalledWith(alert.id);
expect(setAlert).toHaveBeenCalledWith(alert);
expect(loadAlert).toHaveBeenCalledWith(rule.id);
expect(resolveRule).not.toHaveBeenCalled();
expect(setAlert).toHaveBeenCalledWith(rule);
});
it('fetches alert and action types', async () => {
const actionType = {
it('fetches rule and connector types', async () => {
const connectorType = {
id: '.server-log',
name: 'Server log',
enabled: true,
};
const alert = mockAlert({
const rule = mockRule({
actions: [
{
group: '',
id: uuid.v4(),
actionTypeId: actionType.id,
actionTypeId: connectorType.id,
params: {},
},
],
});
const alertType = {
id: alert.alertTypeId,
const ruleType = {
id: rule.alertTypeId,
name: 'type name',
};
const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis();
const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis();
const { setAlert, setAlertType, setActionTypes } = mockStateSetter();
loadAlert.mockImplementation(async () => alert);
loadAlertTypes.mockImplementation(async () => [alertType]);
loadActionTypes.mockImplementation(async () => [actionType]);
loadAlert.mockImplementation(async () => rule);
loadAlertTypes.mockImplementation(async () => [ruleType]);
loadActionTypes.mockImplementation(async () => [connectorType]);
const toastNotifications = ({
addDanger: jest.fn(),
} as unknown) as ToastsApi;
await getAlertData(
alert.id,
await getRuleData(
rule.id,
loadAlert,
loadAlertTypes,
resolveRule,
loadActionTypes,
setAlert,
setAlertType,
@ -102,29 +209,76 @@ describe('getAlertData useEffect handler', () => {
expect(loadAlertTypes).toHaveBeenCalledTimes(1);
expect(loadActionTypes).toHaveBeenCalledTimes(1);
expect(resolveRule).not.toHaveBeenCalled();
expect(setAlertType).toHaveBeenCalledWith(alertType);
expect(setActionTypes).toHaveBeenCalledWith([actionType]);
expect(setAlert).toHaveBeenCalledWith(rule);
expect(setAlertType).toHaveBeenCalledWith(ruleType);
expect(setActionTypes).toHaveBeenCalledWith([connectorType]);
});
it('displays an error if the alert isnt found', async () => {
const actionType = {
it('fetches rule using resolve if initial GET results in a 404 error', async () => {
const connectorType = {
id: '.server-log',
name: 'Server log',
enabled: true,
};
const alert = mockAlert({
const rule = mockRule({
actions: [
{
group: '',
id: uuid.v4(),
actionTypeId: actionType.id,
actionTypeId: connectorType.id,
params: {},
},
],
});
const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis();
const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis();
const { setAlert, setAlertType, setActionTypes } = mockStateSetter();
loadAlert.mockImplementationOnce(async () => {
throw new NotFoundError('OMG');
});
resolveRule.mockImplementationOnce(async () => rule);
const toastNotifications = ({
addDanger: jest.fn(),
} as unknown) as ToastsApi;
await getRuleData(
rule.id,
loadAlert,
loadAlertTypes,
resolveRule,
loadActionTypes,
setAlert,
setAlertType,
setActionTypes,
toastNotifications
);
expect(loadAlert).toHaveBeenCalledWith(rule.id);
expect(resolveRule).toHaveBeenCalledWith(rule.id);
expect(setAlert).toHaveBeenCalledWith(rule);
});
it('displays an error if fetching the rule results in a non-404 error', async () => {
const connectorType = {
id: '.server-log',
name: 'Server log',
enabled: true,
};
const rule = mockRule({
actions: [
{
group: '',
id: uuid.v4(),
actionTypeId: connectorType.id,
params: {},
},
],
});
const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis();
const { setAlert, setAlertType, setActionTypes } = mockStateSetter();
loadAlert.mockImplementation(async () => {
@ -134,10 +288,11 @@ describe('getAlertData useEffect handler', () => {
const toastNotifications = ({
addDanger: jest.fn(),
} as unknown) as ToastsApi;
await getAlertData(
alert.id,
await getRuleData(
rule.id,
loadAlert,
loadAlertTypes,
resolveRule,
loadActionTypes,
setAlert,
setAlertType,
@ -150,40 +305,41 @@ describe('getAlertData useEffect handler', () => {
});
});
it('displays an error if the alert type isnt loaded', async () => {
const actionType = {
it('displays an error if the rule type isnt loaded', async () => {
const connectorType = {
id: '.server-log',
name: 'Server log',
enabled: true,
};
const alert = mockAlert({
const rule = mockRule({
actions: [
{
group: '',
id: uuid.v4(),
actionTypeId: actionType.id,
actionTypeId: connectorType.id,
params: {},
},
],
});
const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis();
const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis();
const { setAlert, setAlertType, setActionTypes } = mockStateSetter();
loadAlert.mockImplementation(async () => alert);
loadAlert.mockImplementation(async () => rule);
loadAlertTypes.mockImplementation(async () => {
throw new Error('OMG no alert type');
throw new Error('OMG no rule type');
});
loadActionTypes.mockImplementation(async () => [actionType]);
loadActionTypes.mockImplementation(async () => [connectorType]);
const toastNotifications = ({
addDanger: jest.fn(),
} as unknown) as ToastsApi;
await getAlertData(
alert.id,
await getRuleData(
rule.id,
loadAlert,
loadAlertTypes,
resolveRule,
loadActionTypes,
setAlert,
setAlertType,
@ -192,48 +348,49 @@ describe('getAlertData useEffect handler', () => {
);
expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1);
expect(toastNotifications.addDanger).toHaveBeenCalledWith({
title: 'Unable to load rule: OMG no alert type',
title: 'Unable to load rule: OMG no rule type',
});
});
it('displays an error if the action type isnt loaded', async () => {
const actionType = {
it('displays an error if the connector type isnt loaded', async () => {
const connectorType = {
id: '.server-log',
name: 'Server log',
enabled: true,
};
const alert = mockAlert({
const rule = mockRule({
actions: [
{
group: '',
id: uuid.v4(),
actionTypeId: actionType.id,
actionTypeId: connectorType.id,
params: {},
},
],
});
const alertType = {
id: alert.alertTypeId,
const ruleType = {
id: rule.alertTypeId,
name: 'type name',
};
const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis();
const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis();
const { setAlert, setAlertType, setActionTypes } = mockStateSetter();
loadAlert.mockImplementation(async () => alert);
loadAlert.mockImplementation(async () => rule);
loadAlertTypes.mockImplementation(async () => [alertType]);
loadAlertTypes.mockImplementation(async () => [ruleType]);
loadActionTypes.mockImplementation(async () => {
throw new Error('OMG no action type');
throw new Error('OMG no connector type');
});
const toastNotifications = ({
addDanger: jest.fn(),
} as unknown) as ToastsApi;
await getAlertData(
alert.id,
await getRuleData(
rule.id,
loadAlert,
loadAlertTypes,
resolveRule,
loadActionTypes,
setAlert,
setAlertType,
@ -242,46 +399,47 @@ describe('getAlertData useEffect handler', () => {
);
expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1);
expect(toastNotifications.addDanger).toHaveBeenCalledWith({
title: 'Unable to load rule: OMG no action type',
title: 'Unable to load rule: OMG no connector type',
});
});
it('displays an error if the alert type isnt found', async () => {
const actionType = {
it('displays an error if the rule type isnt found', async () => {
const connectorType = {
id: '.server-log',
name: 'Server log',
enabled: true,
};
const alert = mockAlert({
const rule = mockRule({
actions: [
{
group: '',
id: uuid.v4(),
actionTypeId: actionType.id,
actionTypeId: connectorType.id,
params: {},
},
],
});
const alertType = {
const ruleType = {
id: uuid.v4(),
name: 'type name',
};
const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis();
const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis();
const { setAlert, setAlertType, setActionTypes } = mockStateSetter();
loadAlert.mockImplementation(async () => alert);
loadAlertTypes.mockImplementation(async () => [alertType]);
loadActionTypes.mockImplementation(async () => [actionType]);
loadAlert.mockImplementation(async () => rule);
loadAlertTypes.mockImplementation(async () => [ruleType]);
loadActionTypes.mockImplementation(async () => [connectorType]);
const toastNotifications = ({
addDanger: jest.fn(),
} as unknown) as ToastsApi;
await getAlertData(
alert.id,
await getRuleData(
rule.id,
loadAlert,
loadAlertTypes,
resolveRule,
loadActionTypes,
setAlert,
setAlertType,
@ -290,57 +448,58 @@ describe('getAlertData useEffect handler', () => {
);
expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1);
expect(toastNotifications.addDanger).toHaveBeenCalledWith({
title: `Unable to load rule: Invalid Alert Type: ${alert.alertTypeId}`,
title: `Unable to load rule: Invalid Rule Type: ${rule.alertTypeId}`,
});
});
it('displays an error if an action type isnt found', async () => {
const availableActionType = {
const availableConnectorType = {
id: '.server-log',
name: 'Server log',
enabled: true,
};
const missingActionType = {
const missingConnectorType = {
id: '.noop',
name: 'No Op',
enabled: true,
};
const alert = mockAlert({
const rule = mockRule({
actions: [
{
group: '',
id: uuid.v4(),
actionTypeId: availableActionType.id,
actionTypeId: availableConnectorType.id,
params: {},
},
{
group: '',
id: uuid.v4(),
actionTypeId: missingActionType.id,
actionTypeId: missingConnectorType.id,
params: {},
},
],
});
const alertType = {
const ruleType = {
id: uuid.v4(),
name: 'type name',
};
const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis();
const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis();
const { setAlert, setAlertType, setActionTypes } = mockStateSetter();
loadAlert.mockImplementation(async () => alert);
loadAlertTypes.mockImplementation(async () => [alertType]);
loadActionTypes.mockImplementation(async () => [availableActionType]);
loadAlert.mockImplementation(async () => rule);
loadAlertTypes.mockImplementation(async () => [ruleType]);
loadActionTypes.mockImplementation(async () => [availableConnectorType]);
const toastNotifications = ({
addDanger: jest.fn(),
} as unknown) as ToastsApi;
await getAlertData(
alert.id,
await getRuleData(
rule.id,
loadAlert,
loadAlertTypes,
resolveRule,
loadActionTypes,
setAlert,
setAlertType,
@ -349,7 +508,7 @@ describe('getAlertData useEffect handler', () => {
);
expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1);
expect(toastNotifications.addDanger).toHaveBeenCalledWith({
title: `Unable to load rule: Invalid Action Type: ${missingActionType.id}`,
title: `Unable to load rule: Invalid Connector Type: ${missingConnectorType.id}`,
});
});
});
@ -359,6 +518,7 @@ function mockApis() {
loadAlert: jest.fn(),
loadAlertTypes: jest.fn(),
loadActionTypes: jest.fn(),
resolveRule: jest.fn(),
};
}
@ -370,23 +530,23 @@ function mockStateSetter() {
};
}
function mockRouterProps(alert: Alert) {
function mockRouterProps(rule: Alert) {
return {
match: {
isExact: false,
path: `/rule/${alert.id}`,
path: `/rule/${rule.id}`,
url: '',
params: { ruleId: alert.id },
params: { ruleId: rule.id },
},
history: createMemoryHistory(),
location: createLocation(`/rule/${alert.id}`),
location: createLocation(`/rule/${rule.id}`),
};
}
function mockAlert(overloads: Partial<Alert> = {}): Alert {
function mockRule(overloads: Partial<Alert> = {}): Alert {
return {
id: uuid.v4(),
enabled: true,
name: `alert-${uuid.v4()}`,
name: `rule-${uuid.v4()}`,
tags: [],
alertTypeId: '.noop',
consumer: 'consumer',

View file

@ -9,7 +9,8 @@ import { i18n } from '@kbn/i18n';
import React, { useState, useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { ToastsApi } from 'kibana/public';
import { Alert, AlertType, ActionType } from '../../../../types';
import { EuiSpacer } from '@elastic/eui';
import { Alert, AlertType, ActionType, ResolvedRule } from '../../../../types';
import { AlertDetailsWithApi as AlertDetails } from './alert_details';
import { throwIfAbsent, throwIfIsntContained } from '../../../lib/value_validators';
import {
@ -27,7 +28,7 @@ type AlertDetailsRouteProps = RouteComponentProps<{
ruleId: string;
}> &
Pick<ActionApis, 'loadActionTypes'> &
Pick<AlertApis, 'loadAlert' | 'loadAlertTypes'>;
Pick<AlertApis, 'loadAlert' | 'loadAlertTypes' | 'resolveRule'>;
export const AlertDetailsRoute: React.FunctionComponent<AlertDetailsRouteProps> = ({
match: {
@ -36,63 +37,127 @@ export const AlertDetailsRoute: React.FunctionComponent<AlertDetailsRouteProps>
loadAlert,
loadAlertTypes,
loadActionTypes,
resolveRule,
}) => {
const {
http,
notifications: { toasts },
spacesOss,
} = useKibana().services;
const [alert, setAlert] = useState<Alert | null>(null);
const { basePath } = http;
const [alert, setAlert] = useState<Alert | ResolvedRule | null>(null);
const [alertType, setAlertType] = useState<AlertType | null>(null);
const [actionTypes, setActionTypes] = useState<ActionType[] | null>(null);
const [refreshToken, requestRefresh] = React.useState<number>();
useEffect(() => {
getAlertData(
getRuleData(
ruleId,
loadAlert,
loadAlertTypes,
resolveRule,
loadActionTypes,
setAlert,
setAlertType,
setActionTypes,
toasts
);
}, [ruleId, http, loadActionTypes, loadAlert, loadAlertTypes, toasts, refreshToken]);
}, [ruleId, http, loadActionTypes, loadAlert, loadAlertTypes, resolveRule, toasts, refreshToken]);
useEffect(() => {
if (alert) {
const outcome = (alert as ResolvedRule).outcome;
if (outcome === 'aliasMatch' && spacesOss.isSpacesAvailable) {
// This rule has been resolved from a legacy URL - redirect the user to the new URL and display a toast.
const path = basePath.prepend(`insightsAndAlerting/triggersActions/rule/${alert.id}`);
spacesOss.ui.redirectLegacyUrl(
path,
i18n.translate('xpack.triggersActionsUI.sections.alertDetails.redirectObjectNoun', {
defaultMessage: 'rule',
})
);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [alert]);
const getLegacyUrlConflictCallout = () => {
const outcome = (alert as ResolvedRule).outcome;
const aliasTargetId = (alert as ResolvedRule).alias_target_id;
if (outcome === 'conflict' && aliasTargetId && spacesOss.isSpacesAvailable) {
// We have resolved to one rule, but there is another one with a legacy URL associated with this page. Display a
// callout with a warning for the user, and provide a way for them to navigate to the other rule.
const otherRulePath = basePath.prepend(
`insightsAndAlerting/triggersActions/rule/${aliasTargetId}`
);
return (
<>
<EuiSpacer />
{spacesOss.ui.components.getLegacyUrlConflict({
objectNoun: i18n.translate(
'xpack.triggersActionsUI.sections.alertDetails.redirectObjectNoun',
{
defaultMessage: 'rule',
}
),
currentObjectId: alert?.id!,
otherObjectId: aliasTargetId,
otherObjectPath: otherRulePath,
})}
</>
);
}
return null;
};
return alert && alertType && actionTypes ? (
<AlertDetails
alert={alert}
alertType={alertType}
actionTypes={actionTypes}
requestRefresh={async () => requestRefresh(Date.now())}
/>
<>
{getLegacyUrlConflictCallout()}
<AlertDetails
alert={alert}
alertType={alertType}
actionTypes={actionTypes}
requestRefresh={async () => requestRefresh(Date.now())}
/>
</>
) : (
<CenterJustifiedSpinner />
);
};
export async function getAlertData(
alertId: string,
export async function getRuleData(
ruleId: string,
loadAlert: AlertApis['loadAlert'],
loadAlertTypes: AlertApis['loadAlertTypes'],
resolveRule: AlertApis['resolveRule'],
loadActionTypes: ActionApis['loadActionTypes'],
setAlert: React.Dispatch<React.SetStateAction<Alert | null>>,
setAlert: React.Dispatch<React.SetStateAction<Alert | ResolvedRule | null>>,
setAlertType: React.Dispatch<React.SetStateAction<AlertType | null>>,
setActionTypes: React.Dispatch<React.SetStateAction<ActionType[] | null>>,
toasts: Pick<ToastsApi, 'addDanger'>
) {
try {
const loadedAlert = await loadAlert(alertId);
setAlert(loadedAlert);
let loadedRule: Alert | ResolvedRule;
try {
loadedRule = await loadAlert(ruleId);
} catch (err) {
// Try resolving this rule id if the error is a 404, otherwise re-throw
if (err?.body?.statusCode !== 404) {
throw err;
}
loadedRule = await resolveRule(ruleId);
}
setAlert(loadedRule);
const [loadedAlertType, loadedActionTypes] = await Promise.all<AlertType, ActionType[]>([
loadAlertTypes()
.then((types) => types.find((type) => type.id === loadedAlert.alertTypeId))
.then(throwIfAbsent(`Invalid Alert Type: ${loadedAlert.alertTypeId}`)),
.then((types) => types.find((type) => type.id === loadedRule.alertTypeId))
.then(throwIfAbsent(`Invalid Rule Type: ${loadedRule.alertTypeId}`)),
loadActionTypes().then(
throwIfIsntContained(
new Set(loadedAlert.actions.map((action) => action.actionTypeId)),
(requiredActionType: string) => `Invalid Action Type: ${requiredActionType}`,
new Set(loadedRule.actions.map((action) => action.actionTypeId)),
(requiredActionType: string) => `Invalid Connector Type: ${requiredActionType}`,
(action: ActionType) => action.id
)
),

View file

@ -36,6 +36,7 @@ describe('with_bulk_alert_api_operations', () => {
expect(typeof props.deleteAlert).toEqual('function');
expect(typeof props.loadAlert).toEqual('function');
expect(typeof props.loadAlertTypes).toEqual('function');
expect(typeof props.resolveRule).toEqual('function');
return <div />;
};
@ -220,6 +221,24 @@ describe('with_bulk_alert_api_operations', () => {
expect(alertApi.loadAlert).toHaveBeenCalledWith({ alertId, http });
});
it('resolveRule calls the resolveRule api', () => {
const { http } = useKibanaMock().services;
const ComponentToExtend = ({
resolveRule,
ruleId,
}: ComponentOpts & { ruleId: Alert['id'] }) => {
return <button onClick={() => resolveRule(ruleId)}>{'call api'}</button>;
};
const ExtendedComponent = withBulkAlertOperations(ComponentToExtend);
const ruleId = uuid.v4();
const component = mount(<ExtendedComponent ruleId={ruleId} />);
component.find('button').simulate('click');
expect(alertApi.resolveRule).toHaveBeenCalledTimes(1);
expect(alertApi.resolveRule).toHaveBeenCalledWith({ ruleId, http });
});
it('loadAlertTypes calls the loadAlertTypes api', () => {
const { http } = useKibanaMock().services;
const ComponentToExtend = ({ loadAlertTypes }: ComponentOpts) => {

View file

@ -13,6 +13,7 @@ import {
AlertTaskState,
AlertInstanceSummary,
AlertingFrameworkHealth,
ResolvedRule,
} from '../../../../types';
import {
deleteAlerts,
@ -31,6 +32,7 @@ import {
loadAlertInstanceSummary,
loadAlertTypes,
alertingFrameworkHealth,
resolveRule,
} from '../../../lib/alert_api';
import { useKibana } from '../../../../common/lib/kibana';
@ -62,6 +64,7 @@ export interface ComponentOpts {
loadAlertInstanceSummary: (id: Alert['id']) => Promise<AlertInstanceSummary>;
loadAlertTypes: () => Promise<AlertType[]>;
getHealth: () => Promise<AlertingFrameworkHealth>;
resolveRule: (id: Alert['id']) => Promise<ResolvedRule>;
}
export type PropsWithOptionalApiHandlers<T> = Omit<T, keyof ComponentOpts> & Partial<ComponentOpts>;
@ -132,6 +135,7 @@ export function withBulkAlertOperations<T>(
loadAlertInstanceSummary({ http, alertId })
}
loadAlertTypes={async () => loadAlertTypes({ http })}
resolveRule={async (ruleId: Alert['id']) => resolveRule({ http, ruleId })}
getHealth={async () => alertingFrameworkHealth({ http })}
/>
);

View file

@ -8,7 +8,7 @@
import React from 'react';
import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
import { spacesOssPluginMock } from '../../../../../../../src/plugins/spaces_oss/public/mocks';
import { coreMock, scopedHistoryMock } from '../../../../../../../src/core/public/mocks';
import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public';
import { TriggersAndActionsUiServices } from '../../../application/app';
@ -45,6 +45,7 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => {
element: ({
style: { cursor: 'pointer' },
} as unknown) as HTMLElement,
spacesOss: spacesOssPluginMock.createStartContract(),
} as TriggersAndActionsUiServices;
};

View file

@ -26,6 +26,7 @@ import { PluginStartContract as AlertingStart } from '../../alerting/public';
import { DataPublicPluginStart } from '../../../../src/plugins/data/public';
import { Storage } from '../../../../src/plugins/kibana_utils/public';
import type { SpacesPluginStart } from '../../spaces/public';
import type { SpacesOssPluginStart } from '../../../../src/plugins/spaces_oss/public';
import { getAddConnectorFlyoutLazy } from './common/get_add_connector_flyout';
import { getEditConnectorFlyoutLazy } from './common/get_edit_connector_flyout';
@ -73,6 +74,7 @@ interface PluginsStart {
charts: ChartsPluginStart;
alerting?: AlertingStart;
spaces?: SpacesPluginStart;
spacesOss: SpacesOssPluginStart;
navigateToApp: CoreStart['application']['navigateToApp'];
features: FeaturesPluginStart;
}
@ -148,6 +150,7 @@ export class Plugin
charts: pluginsStart.charts,
alerting: pluginsStart.alerting,
spaces: pluginsStart.spaces,
spacesOss: pluginsStart.spacesOss,
element: params.element,
storage: new Storage(window.localStorage),
setBreadcrumbs: params.setBreadcrumbs,

View file

@ -24,6 +24,7 @@ import {
ActionGroup,
AlertActionParam,
SanitizedAlert,
ResolvedSanitizedRule,
AlertAction,
AlertAggregations,
AlertTaskState,
@ -40,6 +41,7 @@ import {
// In Triggers and Actions we treat all `Alert`s as `SanitizedAlert<AlertTypeParams>`
// so the `Params` is a black-box of Record<string, unknown>
type Alert = SanitizedAlert<AlertTypeParams>;
type ResolvedRule = ResolvedSanitizedRule<AlertTypeParams>;
export {
Alert,
@ -52,6 +54,7 @@ export {
AlertingFrameworkHealth,
AlertNotifyWhenType,
AlertTypeParams,
ResolvedRule,
};
export {
ActionType,

View file

@ -24,5 +24,6 @@
{ "path": "../../../src/plugins/kibana_react/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_utils/tsconfig.json" },
{ "path": "../../../src/plugins/management/tsconfig.json" },
{ "path": "../../../src/plugins/spaces_oss/tsconfig.json" },
]
}