[RAM] [Flapping] Add Flapping Rules Settings (#147774)

## Summary
Resolves: https://github.com/elastic/kibana/issues/143529
This PR adds a new saved object `rules-settings` with the schema:
```
  properties: {
    flapping: {
      properties: {
        enabled: {
          type: 'boolean',
        },
        lookBackWindow: {
          type: 'long',
        },
        statusChangeThreshold: {
          type: 'long',
        },
        createdBy: {
          type: 'keyword',
        },
        updatedBy: {
          type: 'keyword',
        },
        createdAt: {
          type: 'date',
        },
        updatedAt: {
          type: 'date',
        },
      },
    },
  },
```
It also adds 2 new endpoints:
`GET /rules/settings/_flapping`
`POST /rules/settings/_flapping`

The new rules settings saved object is instantiated per space, using a
predetermined ID to enable OCC. This new saved object allows the user to
control rules flapping settings for a given space. Access control to the
new saved object is done through the kibana features API. A new
`RulesSettingsClient` was created and can be used to interact with the
settings saved object. This saved object is instantiated lazily. When
the code calls `rulesSettingsClient.flapping().get` or
`rulesSettingsClient.flapping().update`, we will lazily create a new
saved object if one does not exist for the current space. (I have
explored bootstrapping this saved object elsewhere but I think this is
the easiest solution, I am open to change on this).

We have set up the rules settings to support future rule settings
sections by making the settings client and permissions modular. Since
permission control can be easily extended by using sub features.

This PR doesn't contain integration for the `task_runner` to use the
flapping settings, but I can do that in this PR if needed.

### Rules settings feature and sub feature (under management)

![rulessettingsprivileges](https://user-images.githubusercontent.com/74562234/210391168-f8dd53d8-21b6-43b1-b653-116c04ad69ed.png)

### Rules settings settings button

![with_permission_rules_config](https://user-images.githubusercontent.com/74562234/208450003-167521de-4222-4705-86cf-8909a6525b18.png)

### Rules settings modal

![rule_config_modal](https://user-images.githubusercontent.com/74562234/208449115-a08150d6-de93-4be7-a19e-7da91496c4a3.png)

### Disabled

![rules_config_modal_disabled](https://user-images.githubusercontent.com/74562234/208450225-8784fcdb-fa27-48cc-9785-e4a8e6360c0e.png)

### Rules settings settings button with insufficient permissions

![no_permission_rules_config](https://user-images.githubusercontent.com/74562234/208450117-9116ecaf-0ca0-4861-b0be-08554587e385.png)

### Rules settings modal with insufficient write subfeature permissions

![no_flapping_permission](https://user-images.githubusercontent.com/74562234/208450263-24a45395-9960-4b55-bbc9-8dbf88646f62.png)

### Rules settings modal with insufficient read subfeature permissions
![Screenshot from 2023-01-03
23-01-48](https://user-images.githubusercontent.com/74562234/210501223-06c9c5cd-73c2-4a11-9889-3a7505e6e0d5.png)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jiawei Wu 2023-01-17 16:40:02 -08:00 committed by GitHub
parent a0ac890f23
commit dc28138d00
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 2944 additions and 4 deletions

View file

@ -124,6 +124,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"osquery-pack-asset": "de8783298eb33a577bf1fa0caacd42121dcfae91", "osquery-pack-asset": "de8783298eb33a577bf1fa0caacd42121dcfae91",
"osquery-saved-query": "7b213b4b7a3e59350e99c50e8df9948662ed493a", "osquery-saved-query": "7b213b4b7a3e59350e99c50e8df9948662ed493a",
"query": "4640ef356321500a678869f24117b7091a911cb6", "query": "4640ef356321500a678869f24117b7091a911cb6",
"rules-settings": "1af4c9abd4b40a154e233c2af4867df7aab7ac24",
"sample-data-telemetry": "8b10336d9efae6f3d5593c4cc89fb4abcdf84e04", "sample-data-telemetry": "8b10336d9efae6f3d5593c4cc89fb4abcdf84e04",
"search": "c48f5ab5d94545780ea98de1bff9e39f17f3606b", "search": "c48f5ab5d94545780ea98de1bff9e39f17f3606b",
"search-session": "ba383309da68a15be3765977f7a44c84f0ec7964", "search-session": "ba383309da68a15be3765977f7a44c84f0ec7964",

View file

@ -92,6 +92,7 @@ const previouslyRegisteredTypes = [
'osquery-usage-metric', 'osquery-usage-metric',
'osquery-manager-usage-metric', 'osquery-manager-usage-metric',
'query', 'query',
'rules-settings',
'sample-data-telemetry', 'sample-data-telemetry',
'search', 'search',
'search-session', 'search-session',

View file

@ -11,6 +11,7 @@
import { AlertsHealth } from './rule'; import { AlertsHealth } from './rule';
export * from './rule'; export * from './rule';
export * from './rules_settings';
export * from './rule_type'; export * from './rule_type';
export * from './rule_task_instance'; export * from './rule_task_instance';
export * from './rule_navigation'; export * from './rule_navigation';

View file

@ -0,0 +1,51 @@
/*
* 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.
*/
export interface RulesSettingsModificationMetadata {
createdBy: string | null;
updatedBy: string | null;
createdAt: string;
updatedAt: string;
}
export interface RulesSettingsFlappingProperties {
enabled: boolean;
lookBackWindow: number;
statusChangeThreshold: number;
}
export type RulesSettingsFlapping = RulesSettingsFlappingProperties &
RulesSettingsModificationMetadata;
export interface RulesSettings {
flapping: RulesSettingsFlapping;
}
export const MIN_LOOK_BACK_WINDOW = 2;
export const MAX_LOOK_BACK_WINDOW = 20;
export const MIN_STATUS_CHANGE_THRESHOLD = 2;
export const MAX_STATUS_CHANGE_THRESHOLD = 20;
export const RULES_SETTINGS_FEATURE_ID = 'rulesSettings';
export const ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID = 'allFlappingSettings';
export const READ_FLAPPING_SETTINGS_SUB_FEATURE_ID = 'readFlappingSettings';
export const API_PRIVILEGES = {
READ_FLAPPING_SETTINGS: 'read-flapping-settings',
WRITE_FLAPPING_SETTINGS: 'write-flapping-settings',
};
export const RULES_SETTINGS_SAVED_OBJECT_TYPE = 'rules-settings';
export const RULES_SETTINGS_SAVED_OBJECT_ID = 'rules-settings';
export const DEFAULT_LOOK_BACK_WINDOW = 20;
export const DEFAULT_STATUS_CHANGE_THRESHOLD = 4;
export const DEFAULT_FLAPPING_SETTINGS = {
enabled: true,
lookBackWindow: 20,
statusChangeThreshold: 4,
};

View file

@ -78,6 +78,7 @@ describe('Alerting Plugin', () => {
statusService: statusServiceMock.createSetupContract(), statusService: statusServiceMock.createSetupContract(),
monitoringCollection: monitoringCollectionMock.createSetup(), monitoringCollection: monitoringCollectionMock.createSetup(),
data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup,
features: featuresPluginMock.createSetup(),
}; };
let plugin: AlertingPlugin; let plugin: AlertingPlugin;
@ -221,6 +222,7 @@ describe('Alerting Plugin', () => {
statusService: statusServiceMock.createSetupContract(), statusService: statusServiceMock.createSetupContract(),
monitoringCollection: monitoringCollectionMock.createSetup(), monitoringCollection: monitoringCollectionMock.createSetup(),
data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup,
features: featuresPluginMock.createSetup(),
}); });
const startContract = plugin.start(coreMock.createStart(), { const startContract = plugin.start(coreMock.createStart(), {
@ -267,6 +269,7 @@ describe('Alerting Plugin', () => {
statusService: statusServiceMock.createSetupContract(), statusService: statusServiceMock.createSetupContract(),
monitoringCollection: monitoringCollectionMock.createSetup(), monitoringCollection: monitoringCollectionMock.createSetup(),
data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup,
features: featuresPluginMock.createSetup(),
}); });
const startContract = plugin.start(coreMock.createStart(), { const startContract = plugin.start(coreMock.createStart(), {
@ -324,6 +327,7 @@ describe('Alerting Plugin', () => {
statusService: statusServiceMock.createSetupContract(), statusService: statusServiceMock.createSetupContract(),
monitoringCollection: monitoringCollectionMock.createSetup(), monitoringCollection: monitoringCollectionMock.createSetup(),
data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup,
features: featuresPluginMock.createSetup(),
}); });
const startContract = plugin.start(coreMock.createStart(), { const startContract = plugin.start(coreMock.createStart(), {

View file

@ -47,13 +47,17 @@ import {
IEventLogService, IEventLogService,
IEventLogClientService, IEventLogClientService,
} from '@kbn/event-log-plugin/server'; } from '@kbn/event-log-plugin/server';
import { PluginStartContract as FeaturesPluginStart } from '@kbn/features-plugin/server'; import {
PluginStartContract as FeaturesPluginStart,
PluginSetupContract as FeaturesPluginSetup,
} from '@kbn/features-plugin/server';
import { PluginStart as DataPluginStart } from '@kbn/data-plugin/server'; import { PluginStart as DataPluginStart } from '@kbn/data-plugin/server';
import { MonitoringCollectionSetup } from '@kbn/monitoring-collection-plugin/server'; import { MonitoringCollectionSetup } from '@kbn/monitoring-collection-plugin/server';
import { SharePluginStart } from '@kbn/share-plugin/server'; import { SharePluginStart } from '@kbn/share-plugin/server';
import { RuleTypeRegistry } from './rule_type_registry'; import { RuleTypeRegistry } from './rule_type_registry';
import { TaskRunnerFactory } from './task_runner'; import { TaskRunnerFactory } from './task_runner';
import { RulesClientFactory } from './rules_client_factory'; import { RulesClientFactory } from './rules_client_factory';
import { RulesSettingsClientFactory } from './rules_settings_client_factory';
import { ILicenseState, LicenseState } from './lib/license_state'; import { ILicenseState, LicenseState } from './lib/license_state';
import { AlertingRequestHandlerContext, ALERTS_FEATURE_ID } from './types'; import { AlertingRequestHandlerContext, ALERTS_FEATURE_ID } from './types';
import { defineRoutes } from './routes'; import { defineRoutes } from './routes';
@ -82,6 +86,7 @@ import { getSecurityHealth, SecurityHealth } from './lib/get_security_health';
import { registerNodeCollector, registerClusterCollector, InMemoryMetrics } from './monitoring'; import { registerNodeCollector, registerClusterCollector, InMemoryMetrics } from './monitoring';
import { getRuleTaskTimeout } from './lib/get_rule_task_timeout'; import { getRuleTaskTimeout } from './lib/get_rule_task_timeout';
import { getActionsConfigMap } from './lib/get_actions_config_map'; import { getActionsConfigMap } from './lib/get_actions_config_map';
import { rulesSettingsFeature } from './rules_settings_feature';
export const EVENT_LOG_PROVIDER = 'alerting'; export const EVENT_LOG_PROVIDER = 'alerting';
export const EVENT_LOG_ACTIONS = { export const EVENT_LOG_ACTIONS = {
@ -146,6 +151,7 @@ export interface AlertingPluginsSetup {
statusService: StatusServiceSetup; statusService: StatusServiceSetup;
monitoringCollection: MonitoringCollectionSetup; monitoringCollection: MonitoringCollectionSetup;
data: DataPluginSetup; data: DataPluginSetup;
features: FeaturesPluginSetup;
} }
export interface AlertingPluginsStart { export interface AlertingPluginsStart {
@ -172,6 +178,7 @@ export class AlertingPlugin {
private security?: SecurityPluginSetup; private security?: SecurityPluginSetup;
private readonly rulesClientFactory: RulesClientFactory; private readonly rulesClientFactory: RulesClientFactory;
private readonly alertingAuthorizationClientFactory: AlertingAuthorizationClientFactory; private readonly alertingAuthorizationClientFactory: AlertingAuthorizationClientFactory;
private readonly rulesSettingsClientFactory: RulesSettingsClientFactory;
private readonly telemetryLogger: Logger; private readonly telemetryLogger: Logger;
private readonly kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; private readonly kibanaVersion: PluginInitializerContext['env']['packageInfo']['version'];
private eventLogService?: IEventLogService; private eventLogService?: IEventLogService;
@ -186,6 +193,7 @@ export class AlertingPlugin {
this.taskRunnerFactory = new TaskRunnerFactory(); this.taskRunnerFactory = new TaskRunnerFactory();
this.rulesClientFactory = new RulesClientFactory(); this.rulesClientFactory = new RulesClientFactory();
this.alertingAuthorizationClientFactory = new AlertingAuthorizationClientFactory(); this.alertingAuthorizationClientFactory = new AlertingAuthorizationClientFactory();
this.rulesSettingsClientFactory = new RulesSettingsClientFactory();
this.telemetryLogger = initializerContext.logger.get('usage'); this.telemetryLogger = initializerContext.logger.get('usage');
this.kibanaVersion = initializerContext.env.packageInfo.version; this.kibanaVersion = initializerContext.env.packageInfo.version;
this.inMemoryMetrics = new InMemoryMetrics(initializerContext.logger.get('in_memory_metrics')); this.inMemoryMetrics = new InMemoryMetrics(initializerContext.logger.get('in_memory_metrics'));
@ -210,6 +218,8 @@ export class AlertingPlugin {
}; };
}); });
plugins.features.registerKibanaFeature(rulesSettingsFeature);
this.isESOCanEncrypt = plugins.encryptedSavedObjects.canEncrypt; this.isESOCanEncrypt = plugins.encryptedSavedObjects.canEncrypt;
if (!this.isESOCanEncrypt) { if (!this.isESOCanEncrypt) {
@ -368,6 +378,7 @@ export class AlertingPlugin {
ruleTypeRegistry, ruleTypeRegistry,
rulesClientFactory, rulesClientFactory,
alertingAuthorizationClientFactory, alertingAuthorizationClientFactory,
rulesSettingsClientFactory,
security, security,
licenseState, licenseState,
} = this; } = this;
@ -416,6 +427,12 @@ export class AlertingPlugin {
minimumScheduleInterval: this.config.rules.minimumScheduleInterval, minimumScheduleInterval: this.config.rules.minimumScheduleInterval,
}); });
rulesSettingsClientFactory.initialize({
logger: this.logger,
savedObjectsService: core.savedObjects,
securityPluginStart: plugins.security,
});
const getRulesClientWithRequest = (request: KibanaRequest) => { const getRulesClientWithRequest = (request: KibanaRequest) => {
if (isESOCanEncrypt !== true) { if (isESOCanEncrypt !== true) {
throw new Error( throw new Error(
@ -483,13 +500,16 @@ export class AlertingPlugin {
private createRouteHandlerContext = ( private createRouteHandlerContext = (
core: CoreSetup<AlertingPluginsStart, unknown> core: CoreSetup<AlertingPluginsStart, unknown>
): IContextProvider<AlertingRequestHandlerContext, 'alerting'> => { ): IContextProvider<AlertingRequestHandlerContext, 'alerting'> => {
const { ruleTypeRegistry, rulesClientFactory } = this; const { ruleTypeRegistry, rulesClientFactory, rulesSettingsClientFactory } = this;
return async function alertsRouteHandlerContext(context, request) { return async function alertsRouteHandlerContext(context, request) {
const [{ savedObjects }] = await core.getStartServices(); const [{ savedObjects }] = await core.getStartServices();
return { return {
getRulesClient: () => { getRulesClient: () => {
return rulesClientFactory!.create(request, savedObjects); return rulesClientFactory!.create(request, savedObjects);
}, },
getRulesSettingsClient: () => {
return rulesSettingsClientFactory.createWithAuthorization(request);
},
listTypes: ruleTypeRegistry!.list.bind(ruleTypeRegistry!), listTypes: ruleTypeRegistry!.list.bind(ruleTypeRegistry!),
getFrameworkHealth: async () => getFrameworkHealth: async () =>
await getHealth(savedObjects.createInternalRepository(['alert'])), await getHealth(savedObjects.createInternalRepository(['alert'])),

View file

@ -10,17 +10,20 @@ import { identity } from 'lodash';
import type { MethodKeysOf } from '@kbn/utility-types'; import type { MethodKeysOf } from '@kbn/utility-types';
import { httpServerMock } from '@kbn/core/server/mocks'; import { httpServerMock } from '@kbn/core/server/mocks';
import { rulesClientMock, RulesClientMock } from '../rules_client.mock'; import { rulesClientMock, RulesClientMock } from '../rules_client.mock';
import { rulesSettingsClientMock, RulesSettingsClientMock } from '../rules_settings_client.mock';
import { AlertsHealth, RuleType } from '../../common'; import { AlertsHealth, RuleType } from '../../common';
import type { AlertingRequestHandlerContext } from '../types'; import type { AlertingRequestHandlerContext } from '../types';
export function mockHandlerArguments( export function mockHandlerArguments(
{ {
rulesClient = rulesClientMock.create(), rulesClient = rulesClientMock.create(),
rulesSettingsClient = rulesSettingsClientMock.create(),
listTypes: listTypesRes = [], listTypes: listTypesRes = [],
getFrameworkHealth, getFrameworkHealth,
areApiKeysEnabled, areApiKeysEnabled,
}: { }: {
rulesClient?: RulesClientMock; rulesClient?: RulesClientMock;
rulesSettingsClient?: RulesSettingsClientMock;
listTypes?: RuleType[]; listTypes?: RuleType[];
getFrameworkHealth?: jest.MockInstance<Promise<AlertsHealth>, []> & getFrameworkHealth?: jest.MockInstance<Promise<AlertsHealth>, []> &
(() => Promise<AlertsHealth>); (() => Promise<AlertsHealth>);
@ -41,6 +44,9 @@ export function mockHandlerArguments(
getRulesClient() { getRulesClient() {
return rulesClient || rulesClientMock.create(); return rulesClient || rulesClientMock.create();
}, },
getRulesSettingsClient() {
return rulesSettingsClient || rulesSettingsClientMock.create();
},
getFrameworkHealth, getFrameworkHealth,
areApiKeysEnabled: areApiKeysEnabled ? areApiKeysEnabled : () => Promise.resolve(true), areApiKeysEnabled: areApiKeysEnabled ? areApiKeysEnabled : () => Promise.resolve(true),
}, },

View file

@ -0,0 +1,63 @@
/*
* 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 '@kbn/core/server/mocks';
import { licenseStateMock } from '../lib/license_state.mock';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { rulesSettingsClientMock, RulesSettingsClientMock } from '../rules_settings_client.mock';
import { getFlappingSettingsRoute } from './get_flapping_settings';
let rulesSettingsClient: RulesSettingsClientMock;
jest.mock('../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
rulesSettingsClient = rulesSettingsClientMock.create();
});
describe('getFlappingSettingsRoute', () => {
test('gets flapping settings', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getFlappingSettingsRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config).toMatchInlineSnapshot(`
Object {
"options": Object {
"tags": Array [
"access:read-flapping-settings",
],
},
"path": "/internal/alerting/rules/settings/_flapping",
"validate": false,
}
`);
(rulesSettingsClient.flapping().get as jest.Mock).mockResolvedValue({
enabled: true,
lookBackWindow: 10,
statusChangeThreshold: 10,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const [context, req, res] = mockHandlerArguments({ rulesSettingsClient }, {}, ['ok']);
await handler(context, req, res);
expect(rulesSettingsClient.flapping().get).toHaveBeenCalledTimes(1);
expect(res.ok).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IRouter } from '@kbn/core/server';
import { ILicenseState } from '../lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
import { verifyAccessAndContext } from './lib';
import { API_PRIVILEGES } from '../../common';
export const getFlappingSettingsRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.get(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_flapping`,
validate: false,
options: {
tags: [`access:${API_PRIVILEGES.READ_FLAPPING_SETTINGS}`],
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const rulesSettingsClient = (await context.alerting).getRulesSettingsClient();
const flappingSettings = await rulesSettingsClient.flapping().get();
return res.ok({ body: flappingSettings });
})
)
);
};

View file

@ -42,6 +42,8 @@ import { bulkDeleteRulesRoute } from './bulk_delete_rules';
import { bulkEnableRulesRoute } from './bulk_enable_rules'; import { bulkEnableRulesRoute } from './bulk_enable_rules';
import { bulkDisableRulesRoute } from './bulk_disable_rules'; import { bulkDisableRulesRoute } from './bulk_disable_rules';
import { cloneRuleRoute } from './clone_rule'; import { cloneRuleRoute } from './clone_rule';
import { getFlappingSettingsRoute } from './get_flapping_settings';
import { updateFlappingSettingsRoute } from './update_flapping_settings';
export interface RouteOptions { export interface RouteOptions {
router: IRouter<AlertingRequestHandlerContext>; router: IRouter<AlertingRequestHandlerContext>;
@ -87,4 +89,6 @@ export function defineRoutes(opts: RouteOptions) {
unsnoozeRuleRoute(router, licenseState); unsnoozeRuleRoute(router, licenseState);
runSoonRoute(router, licenseState); runSoonRoute(router, licenseState);
cloneRuleRoute(router, licenseState); cloneRuleRoute(router, licenseState);
getFlappingSettingsRoute(router, licenseState);
updateFlappingSettingsRoute(router, licenseState);
} }

View file

@ -0,0 +1,82 @@
/*
* 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 '@kbn/core/server/mocks';
import { licenseStateMock } from '../lib/license_state.mock';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { rulesSettingsClientMock, RulesSettingsClientMock } from '../rules_settings_client.mock';
import { updateFlappingSettingsRoute } from './update_flapping_settings';
let rulesSettingsClient: RulesSettingsClientMock;
jest.mock('../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
rulesSettingsClient = rulesSettingsClientMock.create();
});
describe('updateFlappingSettingsRoute', () => {
test('updates flapping settings', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
updateFlappingSettingsRoute(router, licenseState);
const [config, handler] = router.post.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rules/settings/_flapping"`);
expect(config.options).toMatchInlineSnapshot(`
Object {
"tags": Array [
"access:write-flapping-settings",
],
}
`);
(rulesSettingsClient.flapping().get as jest.Mock).mockResolvedValue({
enabled: true,
lookBackWindow: 10,
statusChangeThreshold: 10,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const updateResult = {
enabled: false,
lookBackWindow: 6,
statusChangeThreshold: 5,
};
const [context, req, res] = mockHandlerArguments(
{ rulesSettingsClient },
{
body: updateResult,
},
['ok']
);
await handler(context, req, res);
expect(rulesSettingsClient.flapping().update).toHaveBeenCalledTimes(1);
expect((rulesSettingsClient.flapping().update as jest.Mock).mock.calls[0])
.toMatchInlineSnapshot(`
Array [
Object {
"enabled": false,
"lookBackWindow": 6,
"statusChangeThreshold": 5,
},
]
`);
expect(res.ok).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState } from '../lib';
import { verifyAccessAndContext } from './lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
import { API_PRIVILEGES } from '../../common';
const bodySchema = schema.object({
enabled: schema.boolean(),
lookBackWindow: schema.number(),
statusChangeThreshold: schema.number(),
});
export const updateFlappingSettingsRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.post(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_flapping`,
validate: {
body: bodySchema,
},
options: {
tags: [`access:${API_PRIVILEGES.WRITE_FLAPPING_SETTINGS}`],
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const rulesSettingsClient = (await context.alerting).getRulesSettingsClient();
const updatedFlappingSettings = await rulesSettingsClient.flapping().update(req.body);
return res.ok({
body: updatedFlappingSettings,
});
})
)
);
};

View file

@ -0,0 +1,32 @@
/*
* 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 { RulesSettingsClientApi, RulesSettingsFlappingClientApi } from './types';
export type RulesSettingsClientMock = jest.Mocked<RulesSettingsClientApi>;
export type RulesSettingsFlappingClientMock = jest.Mocked<RulesSettingsFlappingClientApi>;
// Warning: Becareful when resetting all mocks in tests as it would clear
// the mock return value on the flapping
const createRulesSettingsClientMock = () => {
const flappingMocked: RulesSettingsFlappingClientMock = {
get: jest.fn(),
update: jest.fn(),
};
const mocked: RulesSettingsClientMock = {
get: jest.fn(),
create: jest.fn(),
flapping: jest.fn().mockReturnValue(flappingMocked),
};
return mocked;
};
export const rulesSettingsClientMock: {
create: () => RulesSettingsClientMock;
} = {
create: createRulesSettingsClientMock,
};

View file

@ -0,0 +1,185 @@
/*
* 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 {
RulesSettingsFlappingClient,
RulesSettingsFlappingClientConstructorOptions,
} from './rules_settings_flapping_client';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import {
RULES_SETTINGS_FEATURE_ID,
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
DEFAULT_FLAPPING_SETTINGS,
RulesSettings,
} from '../../../common';
const mockDateString = '2019-02-12T21:01:22.479Z';
const savedObjectsClient = savedObjectsClientMock.create();
const getMockRulesSettings = (): RulesSettings => {
return {
flapping: {
enabled: DEFAULT_FLAPPING_SETTINGS.enabled,
lookBackWindow: DEFAULT_FLAPPING_SETTINGS.lookBackWindow,
statusChangeThreshold: DEFAULT_FLAPPING_SETTINGS.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
};
};
const rulesSettingsFlappingClientParams: jest.Mocked<RulesSettingsFlappingClientConstructorOptions> =
{
logger: loggingSystemMock.create().get(),
getOrCreate: jest.fn().mockReturnValue({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: getMockRulesSettings(),
references: [],
version: '123',
}),
getModificationMetadata: jest.fn(),
savedObjectsClient,
};
describe('RulesSettingsFlappingClient', () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date(mockDateString));
});
afterAll(() => {
jest.clearAllMocks();
jest.useRealTimers();
});
test('can get flapping settings', async () => {
const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams);
const result = await client.get();
expect(result).toEqual(
expect.objectContaining({
enabled: DEFAULT_FLAPPING_SETTINGS.enabled,
lookBackWindow: DEFAULT_FLAPPING_SETTINGS.lookBackWindow,
statusChangeThreshold: DEFAULT_FLAPPING_SETTINGS.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
})
);
});
test('can update flapping settings', async () => {
const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams);
const mockResolve = {
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: getMockRulesSettings(),
references: [],
version: '123',
};
savedObjectsClient.update.mockResolvedValueOnce({
...mockResolve,
attributes: {
flapping: {
...mockResolve.attributes.flapping,
enabled: false,
lookBackWindow: 19,
statusChangeThreshold: 3,
},
},
});
const result = await client.update({
enabled: false,
lookBackWindow: 19,
statusChangeThreshold: 3,
});
expect(savedObjectsClient.update).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
{
flapping: expect.objectContaining({
enabled: false,
lookBackWindow: 19,
statusChangeThreshold: 3,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{ version: '123' }
);
expect(result).toEqual(
expect.objectContaining({
enabled: false,
lookBackWindow: 19,
statusChangeThreshold: 3,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
})
);
});
test('throws if savedObjectsClient failed to update', async () => {
const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams);
savedObjectsClient.update.mockRejectedValueOnce(new Error('failed!!'));
await expect(
client.update({
enabled: false,
lookBackWindow: 19,
statusChangeThreshold: 3,
})
).rejects.toThrowError(
'savedObjectsClient errored trying to update flapping settings: failed!!'
);
});
test('throws if new flapping setting fails verification', async () => {
const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams);
await expect(
client.update({
enabled: true,
lookBackWindow: 200,
statusChangeThreshold: 500,
})
).rejects.toThrowError('Invalid lookBackWindow value, must be between 2 and 20, but got: 200.');
await expect(
client.update({
enabled: true,
lookBackWindow: 20,
statusChangeThreshold: 500,
})
).rejects.toThrowError(
'Invalid statusChangeThreshold value, must be between 2 and 20, but got: 500.'
);
await expect(
client.update({
enabled: true,
lookBackWindow: 10,
statusChangeThreshold: 20,
})
).rejects.toThrowError(
'Invalid values,lookBackWindow (10) must be equal to or greater than statusChangeThreshold (20).'
);
});
});

View file

@ -0,0 +1,109 @@
/*
* 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 Boom from '@hapi/boom';
import { Logger, SavedObjectsClientContract, SavedObject } from '@kbn/core/server';
import {
RulesSettings,
RulesSettingsFlapping,
RulesSettingsFlappingProperties,
RulesSettingsModificationMetadata,
MIN_LOOK_BACK_WINDOW,
MAX_LOOK_BACK_WINDOW,
MIN_STATUS_CHANGE_THRESHOLD,
MAX_STATUS_CHANGE_THRESHOLD,
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
} from '../../../common';
const verifyFlappingSettings = (flappingSettings: RulesSettingsFlappingProperties) => {
const { lookBackWindow, statusChangeThreshold } = flappingSettings;
if (lookBackWindow < MIN_LOOK_BACK_WINDOW || lookBackWindow > MAX_LOOK_BACK_WINDOW) {
throw Boom.badRequest(
`Invalid lookBackWindow value, must be between ${MIN_LOOK_BACK_WINDOW} and ${MAX_LOOK_BACK_WINDOW}, but got: ${lookBackWindow}.`
);
}
if (
statusChangeThreshold < MIN_STATUS_CHANGE_THRESHOLD ||
statusChangeThreshold > MAX_STATUS_CHANGE_THRESHOLD
) {
throw Boom.badRequest(
`Invalid statusChangeThreshold value, must be between ${MIN_STATUS_CHANGE_THRESHOLD} and ${MAX_STATUS_CHANGE_THRESHOLD}, but got: ${statusChangeThreshold}.`
);
}
if (lookBackWindow < statusChangeThreshold) {
throw Boom.badRequest(
`Invalid values,lookBackWindow (${lookBackWindow}) must be equal to or greater than statusChangeThreshold (${statusChangeThreshold}).`
);
}
};
export interface RulesSettingsFlappingClientConstructorOptions {
readonly logger: Logger;
readonly savedObjectsClient: SavedObjectsClientContract;
readonly getOrCreate: () => Promise<SavedObject<RulesSettings>>;
readonly getModificationMetadata: () => Promise<RulesSettingsModificationMetadata>;
}
export class RulesSettingsFlappingClient {
private readonly logger: Logger;
private readonly savedObjectsClient: SavedObjectsClientContract;
private readonly getOrCreate: () => Promise<SavedObject<RulesSettings>>;
private readonly getModificationMetadata: () => Promise<RulesSettingsModificationMetadata>;
constructor(options: RulesSettingsFlappingClientConstructorOptions) {
this.logger = options.logger;
this.savedObjectsClient = options.savedObjectsClient;
this.getOrCreate = options.getOrCreate;
this.getModificationMetadata = options.getModificationMetadata;
}
public async get(): Promise<RulesSettingsFlapping> {
const rulesSettings = await this.getOrCreate();
return rulesSettings.attributes.flapping;
}
public async update(newFlappingProperties: RulesSettingsFlappingProperties) {
try {
verifyFlappingSettings(newFlappingProperties);
} catch (e) {
this.logger.error(
`Failed to verify new flapping settings properties when updating. Error: ${e}`
);
throw e;
}
const { attributes, version } = await this.getOrCreate();
const modificationMetadata = await this.getModificationMetadata();
try {
const result = await this.savedObjectsClient.update(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
{
...attributes,
flapping: {
...attributes.flapping,
...newFlappingProperties,
...modificationMetadata,
},
},
{
version,
}
);
return result.attributes.flapping;
} catch (e) {
const errorMessage = 'savedObjectsClient errored trying to update flapping settings';
this.logger.error(`${errorMessage}: ${e}`);
throw Boom.boomify(e, { message: errorMessage });
}
}
}

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export * from './rules_settings_client';
export * from './flapping/rules_settings_flapping_client';

View file

@ -0,0 +1,285 @@
/*
* 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 {
RulesSettingsClient,
RulesSettingsClientConstructorOptions,
} from './rules_settings_client';
import { RulesSettingsFlappingClient } from './flapping/rules_settings_flapping_client';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
import {
RULES_SETTINGS_FEATURE_ID,
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
DEFAULT_FLAPPING_SETTINGS,
RulesSettings,
} from '../../common';
const mockDateString = '2019-02-12T21:01:22.479Z';
const savedObjectsClient = savedObjectsClientMock.create();
const rulesSettingsClientParams: jest.Mocked<RulesSettingsClientConstructorOptions> = {
logger: loggingSystemMock.create().get(),
getUserName: jest.fn(),
savedObjectsClient,
};
const getMockRulesSettings = (): RulesSettings => {
return {
flapping: {
enabled: DEFAULT_FLAPPING_SETTINGS.enabled,
lookBackWindow: DEFAULT_FLAPPING_SETTINGS.lookBackWindow,
statusChangeThreshold: DEFAULT_FLAPPING_SETTINGS.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
};
};
describe('RulesSettingsClient', () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date(mockDateString));
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
jest.resetAllMocks();
rulesSettingsClientParams.getUserName.mockResolvedValue('test name');
});
test('can initialize correctly', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
expect(client.flapping()).toEqual(expect.any(RulesSettingsFlappingClient));
});
test('can create a new rules settings saved object', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.create.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.create();
expect(savedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.create).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
flapping: expect.objectContaining({
enabled: mockAttributes.flapping.enabled,
lookBackWindow: mockAttributes.flapping.lookBackWindow,
statusChangeThreshold: mockAttributes.flapping.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{
id: RULES_SETTINGS_SAVED_OBJECT_ID,
overwrite: true,
}
);
expect(result.attributes).toEqual(mockAttributes);
});
test('can get existing rules settings saved object', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.get();
expect(result.attributes).toEqual(mockAttributes);
});
test('throws if there is no existing saved object to get', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
savedObjectsClient.get.mockRejectedValueOnce(
SavedObjectsErrorHelpers.createGenericNotFoundError(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
)
);
await expect(client.get()).rejects.toThrowError();
});
test('can persist flapping settings when saved object does not exist', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockRejectedValueOnce(
SavedObjectsErrorHelpers.createGenericNotFoundError(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
)
);
savedObjectsClient.create.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.flapping().get();
expect(savedObjectsClient.get).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
);
expect(savedObjectsClient.create).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
flapping: expect.objectContaining({
enabled: mockAttributes.flapping.enabled,
lookBackWindow: mockAttributes.flapping.lookBackWindow,
statusChangeThreshold: mockAttributes.flapping.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{
id: RULES_SETTINGS_SAVED_OBJECT_ID,
overwrite: true,
}
);
expect(result).toEqual(mockAttributes.flapping);
});
test('can persist flapping settings when saved object already exists', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.flapping().get();
expect(savedObjectsClient.get).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
);
expect(savedObjectsClient.create).not.toHaveBeenCalled();
expect(result).toEqual(mockAttributes.flapping);
});
test('can update flapping settings when saved object does not exist', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockRejectedValueOnce(
SavedObjectsErrorHelpers.createGenericNotFoundError(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
)
);
const mockResolve = {
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
version: '123',
};
savedObjectsClient.create.mockResolvedValueOnce(mockResolve);
savedObjectsClient.update.mockResolvedValueOnce({
...mockResolve,
attributes: {
flapping: {
...mockResolve.attributes.flapping,
enabled: false,
lookBackWindow: 5,
statusChangeThreshold: 5,
},
},
});
// Try to update with new values
const result = await client.flapping().update({
enabled: false,
lookBackWindow: 5,
statusChangeThreshold: 5,
});
// Tried to get first, but no results
expect(savedObjectsClient.get).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
);
// So create a new entry
expect(savedObjectsClient.create).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
flapping: expect.objectContaining({
enabled: mockAttributes.flapping.enabled,
lookBackWindow: mockAttributes.flapping.lookBackWindow,
statusChangeThreshold: mockAttributes.flapping.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{
id: RULES_SETTINGS_SAVED_OBJECT_ID,
overwrite: true,
}
);
// Try to update with version
expect(savedObjectsClient.update).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
{
flapping: expect.objectContaining({
enabled: false,
lookBackWindow: 5,
statusChangeThreshold: 5,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{ version: '123' }
);
expect(result).toEqual(
expect.objectContaining({
enabled: false,
lookBackWindow: 5,
statusChangeThreshold: 5,
})
);
});
});

View file

@ -0,0 +1,114 @@
/*
* 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 {
Logger,
SavedObjectsClientContract,
SavedObject,
SavedObjectsErrorHelpers,
} from '@kbn/core/server';
import { RulesSettingsFlappingClient } from './flapping/rules_settings_flapping_client';
import {
RulesSettings,
DEFAULT_FLAPPING_SETTINGS,
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
} from '../../common';
export interface RulesSettingsClientConstructorOptions {
readonly logger: Logger;
readonly savedObjectsClient: SavedObjectsClientContract;
readonly getUserName: () => Promise<string | null>;
}
export class RulesSettingsClient {
private readonly logger: Logger;
private readonly savedObjectsClient: SavedObjectsClientContract;
private readonly getUserName: () => Promise<string | null>;
private readonly _flapping: RulesSettingsFlappingClient;
constructor(options: RulesSettingsClientConstructorOptions) {
this.logger = options.logger;
this.savedObjectsClient = options.savedObjectsClient;
this.getUserName = options.getUserName;
this._flapping = new RulesSettingsFlappingClient({
logger: this.logger,
savedObjectsClient: this.savedObjectsClient,
getOrCreate: this.getOrCreate.bind(this),
getModificationMetadata: this.getModificationMetadata.bind(this),
});
}
private async getModificationMetadata() {
const createTime = Date.now();
const userName = await this.getUserName();
return {
createdBy: userName,
updatedBy: userName,
createdAt: new Date(createTime).toISOString(),
updatedAt: new Date(createTime).toISOString(),
};
}
public async get(): Promise<SavedObject<RulesSettings>> {
try {
return await this.savedObjectsClient.get<RulesSettings>(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
);
} catch (e) {
this.logger.error(`Failed to get rules setting for current space. Error: ${e}`);
throw e;
}
}
public async create(): Promise<SavedObject<RulesSettings>> {
const modificationMetadata = await this.getModificationMetadata();
try {
return await this.savedObjectsClient.create<RulesSettings>(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
flapping: {
...DEFAULT_FLAPPING_SETTINGS,
...modificationMetadata,
},
},
{
id: RULES_SETTINGS_SAVED_OBJECT_ID,
overwrite: true,
}
);
} catch (e) {
this.logger.error(`Failed to create rules setting for current space. Error: ${e}`);
throw e;
}
}
/**
* Helper function to ensure that a rules-settings saved object always exists.
* Enabled the creation of the saved object is done lazily during retrieval.
*/
private async getOrCreate(): Promise<SavedObject<RulesSettings>> {
try {
return await this.get();
} catch (e) {
if (SavedObjectsErrorHelpers.isNotFoundError(e)) {
this.logger.info('Creating new default rules settings for current space.');
return await this.create();
}
this.logger.error(`Failed to persist rules setting for current space. Error: ${e}`);
throw e;
}
}
public flapping(): RulesSettingsFlappingClient {
return this._flapping;
}
}

View file

@ -0,0 +1,161 @@
/*
* 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 { Request } from '@hapi/hapi';
import { CoreKibanaRequest } from '@kbn/core/server';
import {
RulesSettingsClientFactory,
RulesSettingsClientFactoryOpts,
} from './rules_settings_client_factory';
import {
savedObjectsClientMock,
savedObjectsServiceMock,
loggingSystemMock,
} from '@kbn/core/server/mocks';
import { AuthenticatedUser } from '@kbn/security-plugin/common/model';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server';
import { RULES_SETTINGS_SAVED_OBJECT_TYPE } from '../common';
jest.mock('./rules_settings_client');
const savedObjectsClient = savedObjectsClientMock.create();
const savedObjectsService = savedObjectsServiceMock.createInternalStartContract();
const securityPluginStart = securityMock.createStart();
const rulesSettingsClientFactoryParams: jest.Mocked<RulesSettingsClientFactoryOpts> = {
logger: loggingSystemMock.create().get(),
savedObjectsService,
};
const fakeRequest = {
app: {},
headers: {},
getBasePath: () => '',
path: '/',
route: { settings: {} },
url: {
href: '/',
},
raw: {
req: {
url: '/',
},
},
getSavedObjectsClient: () => savedObjectsClient,
} as unknown as Request;
beforeEach(() => {
jest.resetAllMocks();
});
test('creates a rules settings client with proper constructor arguments when security is enabled', async () => {
const factory = new RulesSettingsClientFactory();
factory.initialize({
securityPluginStart,
...rulesSettingsClientFactoryParams,
});
const request = CoreKibanaRequest.from(fakeRequest);
savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient);
factory.createWithAuthorization(request);
expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, {
includedHiddenTypes: [RULES_SETTINGS_SAVED_OBJECT_TYPE],
});
const { RulesSettingsClient } = jest.requireMock('./rules_settings_client');
expect(RulesSettingsClient).toHaveBeenCalledWith({
logger: rulesSettingsClientFactoryParams.logger,
savedObjectsClient,
getUserName: expect.any(Function),
});
});
test('creates a rules settings client with proper constructor arguments', async () => {
const factory = new RulesSettingsClientFactory();
factory.initialize(rulesSettingsClientFactoryParams);
const request = CoreKibanaRequest.from(fakeRequest);
savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient);
factory.createWithAuthorization(request);
expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, {
includedHiddenTypes: [RULES_SETTINGS_SAVED_OBJECT_TYPE],
});
const { RulesSettingsClient } = jest.requireMock('./rules_settings_client');
expect(RulesSettingsClient).toHaveBeenCalledWith({
logger: rulesSettingsClientFactoryParams.logger,
savedObjectsClient,
getUserName: expect.any(Function),
});
});
test('creates an unauthorized rules settings client', async () => {
const factory = new RulesSettingsClientFactory();
factory.initialize({
securityPluginStart,
...rulesSettingsClientFactoryParams,
});
const request = CoreKibanaRequest.from(fakeRequest);
savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient);
factory.create(request);
expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, {
excludedExtensions: [SECURITY_EXTENSION_ID],
includedHiddenTypes: [RULES_SETTINGS_SAVED_OBJECT_TYPE],
});
const { RulesSettingsClient } = jest.requireMock('./rules_settings_client');
expect(RulesSettingsClient).toHaveBeenCalledWith({
logger: rulesSettingsClientFactoryParams.logger,
savedObjectsClient,
getUserName: expect.any(Function),
});
});
test('getUserName() returns null when security is disabled', async () => {
const factory = new RulesSettingsClientFactory();
factory.initialize(rulesSettingsClientFactoryParams);
const request = CoreKibanaRequest.from(fakeRequest);
factory.createWithAuthorization(request);
const constructorCall =
jest.requireMock('./rules_settings_client').RulesSettingsClient.mock.calls[0][0];
const userNameResult = await constructorCall.getUserName();
expect(userNameResult).toEqual(null);
});
test('getUserName() returns a name when security is enabled', async () => {
const factory = new RulesSettingsClientFactory();
factory.initialize({
securityPluginStart,
...rulesSettingsClientFactoryParams,
});
const request = CoreKibanaRequest.from(fakeRequest);
factory.createWithAuthorization(request);
const constructorCall =
jest.requireMock('./rules_settings_client').RulesSettingsClient.mock.calls[0][0];
securityPluginStart.authc.getCurrentUser.mockReturnValueOnce({
username: 'testname',
} as unknown as AuthenticatedUser);
const userNameResult = await constructorCall.getUserName();
expect(userNameResult).toEqual('testname');
});

View file

@ -0,0 +1,67 @@
/*
* 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 {
KibanaRequest,
Logger,
SavedObjectsServiceStart,
SECURITY_EXTENSION_ID,
} from '@kbn/core/server';
import { SecurityPluginStart } from '@kbn/security-plugin/server';
import { RulesSettingsClient } from './rules_settings_client';
import { RULES_SETTINGS_SAVED_OBJECT_TYPE } from '../common';
export interface RulesSettingsClientFactoryOpts {
logger: Logger;
savedObjectsService: SavedObjectsServiceStart;
securityPluginStart?: SecurityPluginStart;
}
export class RulesSettingsClientFactory {
private isInitialized = false;
private logger!: Logger;
private savedObjectsService!: SavedObjectsServiceStart;
private securityPluginStart?: SecurityPluginStart;
public initialize(options: RulesSettingsClientFactoryOpts) {
if (this.isInitialized) {
throw new Error('RulesSettingsClientFactory already initialized');
}
this.isInitialized = true;
this.logger = options.logger;
this.savedObjectsService = options.savedObjectsService;
this.securityPluginStart = options.securityPluginStart;
}
private createRulesSettingsClient(request: KibanaRequest, withAuth: boolean) {
const { securityPluginStart } = this;
const savedObjectsClient = this.savedObjectsService.getScopedClient(request, {
includedHiddenTypes: [RULES_SETTINGS_SAVED_OBJECT_TYPE],
...(withAuth ? {} : { excludedExtensions: [SECURITY_EXTENSION_ID] }),
});
return new RulesSettingsClient({
logger: this.logger,
savedObjectsClient,
async getUserName() {
if (!securityPluginStart || !request) {
return null;
}
const user = securityPluginStart.authc.getCurrentUser(request);
return user ? user.username : null;
},
});
}
public createWithAuthorization(request: KibanaRequest) {
return this.createRulesSettingsClient(request, true);
}
public create(request: KibanaRequest) {
return this.createRulesSettingsClient(request, false);
}
}

View file

@ -0,0 +1,91 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { KibanaFeatureConfig } from '@kbn/features-plugin/common';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import {
RULES_SETTINGS_FEATURE_ID,
READ_FLAPPING_SETTINGS_SUB_FEATURE_ID,
ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID,
API_PRIVILEGES,
RULES_SETTINGS_SAVED_OBJECT_TYPE,
} from '../common';
export const rulesSettingsFeature: KibanaFeatureConfig = {
id: RULES_SETTINGS_FEATURE_ID,
name: i18n.translate('xpack.alerting.feature.rulesSettingsFeatureName', {
defaultMessage: 'Rules Settings',
}),
category: DEFAULT_APP_CATEGORIES.management,
app: [],
management: {
insightsAndAlerting: ['triggersActions'],
},
privileges: {
all: {
app: [],
api: [],
management: {
insightsAndAlerting: ['triggersActions'],
},
savedObject: {
all: [RULES_SETTINGS_SAVED_OBJECT_TYPE],
read: [],
},
ui: ['show', 'save'],
},
read: {
app: [],
api: [],
management: {
insightsAndAlerting: ['triggersActions'],
},
savedObject: {
all: [],
read: [RULES_SETTINGS_SAVED_OBJECT_TYPE],
},
ui: ['show'],
},
},
subFeatures: [
{
name: i18n.translate('xpack.alerting.feature.flappingSettingsSubFeatureName', {
defaultMessage: 'Flapping Detection',
}),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [API_PRIVILEGES.READ_FLAPPING_SETTINGS, API_PRIVILEGES.WRITE_FLAPPING_SETTINGS],
name: 'All',
id: ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID,
includeIn: 'all',
savedObject: {
all: [RULES_SETTINGS_SAVED_OBJECT_TYPE],
read: [],
},
ui: ['writeFlappingSettingsUI', 'readFlappingSettingsUI'],
},
{
api: [API_PRIVILEGES.READ_FLAPPING_SETTINGS],
name: 'Read',
id: READ_FLAPPING_SETTINGS_SUB_FEATURE_ID,
includeIn: 'read',
savedObject: {
all: [],
read: [RULES_SETTINGS_SAVED_OBJECT_TYPE],
},
ui: ['readFlappingSettingsUI'],
},
],
},
],
},
],
};

View file

@ -14,6 +14,7 @@ import type {
import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server';
import { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common'; import { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common';
import { alertMappings } from './mappings'; import { alertMappings } from './mappings';
import { rulesSettingsMappings } from './rules_settings_mappings';
import { getMigrations } from './migrations'; import { getMigrations } from './migrations';
import { transformRulesForExport } from './transform_rule_for_export'; import { transformRulesForExport } from './transform_rule_for_export';
import { RawRule } from '../types'; import { RawRule } from '../types';
@ -21,6 +22,7 @@ import { getImportWarnings } from './get_import_warnings';
import { isRuleExportable } from './is_rule_exportable'; import { isRuleExportable } from './is_rule_exportable';
import { RuleTypeRegistry } from '../rule_type_registry'; import { RuleTypeRegistry } from '../rule_type_registry';
export { partiallyUpdateAlert } from './partially_update_alert'; export { partiallyUpdateAlert } from './partially_update_alert';
import { RULES_SETTINGS_SAVED_OBJECT_TYPE } from '../../common';
// Use caution when removing items from this array! Any field which has // Use caution when removing items from this array! Any field which has
// ever existed in the rule SO must be included in this array to prevent // ever existed in the rule SO must be included in this array to prevent
@ -114,6 +116,13 @@ export function setupSavedObjects(
}, },
}); });
savedObjects.registerType({
name: RULES_SETTINGS_SAVED_OBJECT_TYPE,
hidden: true,
namespaceType: 'single',
mappings: rulesSettingsMappings,
});
// Encrypted attributes // Encrypted attributes
encryptedSavedObjects.registerType({ encryptedSavedObjects.registerType({
type: 'alert', type: 'alert',

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SavedObjectsTypeMappingDefinition } from '@kbn/core/server';
export const rulesSettingsMappings: SavedObjectsTypeMappingDefinition = {
properties: {
flapping: {
properties: {
enabled: {
type: 'boolean',
index: false,
},
lookBackWindow: {
type: 'long',
index: false,
},
statusChangeThreshold: {
type: 'long',
index: false,
},
createdBy: {
type: 'keyword',
index: false,
},
updatedBy: {
type: 'keyword',
index: false,
},
createdAt: {
type: 'date',
index: false,
},
updatedAt: {
type: 'date',
index: false,
},
},
},
},
};

View file

@ -25,6 +25,7 @@ import { SharePluginStart } from '@kbn/share-plugin/server';
import { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry'; import { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry';
import { PluginSetupContract, PluginStartContract } from './plugin'; import { PluginSetupContract, PluginStartContract } from './plugin';
import { RulesClient } from './rules_client'; import { RulesClient } from './rules_client';
import { RulesSettingsClient, RulesSettingsFlappingClient } from './rules_settings_client';
export * from '../common'; export * from '../common';
import { import {
Rule, Rule,
@ -57,6 +58,7 @@ export type { RuleTypeParams };
*/ */
export interface AlertingApiRequestHandlerContext { export interface AlertingApiRequestHandlerContext {
getRulesClient: () => RulesClient; getRulesClient: () => RulesClient;
getRulesSettingsClient: () => RulesSettingsClient;
listTypes: RuleTypeRegistry['list']; listTypes: RuleTypeRegistry['list'];
getFrameworkHealth: () => Promise<AlertsHealth>; getFrameworkHealth: () => Promise<AlertsHealth>;
areApiKeysEnabled: () => Promise<boolean>; areApiKeysEnabled: () => Promise<boolean>;
@ -320,6 +322,9 @@ export type RuleTypeRegistry = PublicMethodsOf<OrigruleTypeRegistry>;
export type RulesClientApi = PublicMethodsOf<RulesClient>; export type RulesClientApi = PublicMethodsOf<RulesClient>;
export type RulesSettingsClientApi = PublicMethodsOf<RulesSettingsClient>;
export type RulesSettingsFlappingClientApi = PublicMethodsOf<RulesSettingsFlappingClient>;
export interface PublicMetricsSetters { export interface PublicMetricsSetters {
setLastRunMetricsTotalSearchDurationMs: (totalSearchDurationMs: number) => void; setLastRunMetricsTotalSearchDurationMs: (totalSearchDurationMs: number) => void;
setLastRunMetricsTotalIndexingDurationMs: (totalIndexingDurationMs: number) => void; setLastRunMetricsTotalIndexingDurationMs: (totalIndexingDurationMs: number) => void;

View file

@ -0,0 +1,213 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiFormRowProps,
EuiIconTip,
EuiRange,
EuiRangeProps,
EuiSpacer,
EuiTitle,
EuiText,
EuiPanel,
} from '@elastic/eui';
import {
RulesSettingsFlappingProperties,
MIN_LOOK_BACK_WINDOW,
MIN_STATUS_CHANGE_THRESHOLD,
MAX_LOOK_BACK_WINDOW,
MAX_STATUS_CHANGE_THRESHOLD,
} from '@kbn/alerting-plugin/common';
import { useKibana } from '../../../common/lib/kibana';
type OnChangeKey = keyof Omit<RulesSettingsFlappingProperties, 'enabled'>;
const lookBackWindowLabel = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabel',
{
defaultMessage: 'Rule run look back window',
}
);
const statusChangeThresholdLabel = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdLabel',
{
defaultMessage: 'Alert status change threshold',
}
);
const getLookBackWindowLabelRuleRuns = (amount: number) => {
return i18n.translate(
'xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabelRuleRuns',
{
defaultMessage: '{amount, number} rule {amount, plural, one {run} other {runs}}',
values: { amount },
}
);
};
const getStatusChangeThresholdRuleRuns = (amount: number) => {
return i18n.translate(
'xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdTimes',
{
defaultMessage: '{amount, number} {amount, plural, one {time} other {times}}',
values: { amount },
}
);
};
export interface RulesSettingsRangeProps {
label: EuiFormRowProps['label'];
labelPopoverText?: string;
min: number;
max: number;
value: number;
disabled?: EuiRangeProps['disabled'];
onChange?: EuiRangeProps['onChange'];
}
export const RulesSettingsFlappingTitle = () => {
return (
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.flapping.alertFlappingDetection"
defaultMessage="Alert Flapping Detection"
/>
</h5>
</EuiTitle>
);
};
export const RulesSettingsFlappingDescription = () => {
return (
<EuiText color="subdued" size="s">
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.flapping.alertFlappingDetectionDescription"
defaultMessage="Modify the frequency that an alert can go between active and recovered over a period of rule runs."
/>
</EuiText>
);
};
export const RulesSettingsRange = memo((props: RulesSettingsRangeProps) => {
const { label, labelPopoverText, min, max, value, disabled, onChange, ...rest } = props;
const renderLabel = () => {
return (
<div>
{label}
&nbsp;
<EuiIconTip color="subdued" size="s" type="questionInCircle" content={labelPopoverText} />
</div>
);
};
return (
<EuiFormRow label={renderLabel()}>
<EuiRange
min={min}
max={max}
step={1}
value={value}
disabled={disabled}
onChange={onChange}
showLabels
showValue
{...rest}
/>
</EuiFormRow>
);
});
export interface RulesSettingsFlappingFormSectionProps {
flappingSettings: RulesSettingsFlappingProperties;
compressed?: boolean;
onChange: (key: OnChangeKey, value: number) => void;
}
export const RulesSettingsFlappingFormSection = memo(
(props: RulesSettingsFlappingFormSectionProps) => {
const { flappingSettings, compressed = false, onChange } = props;
const { lookBackWindow, statusChangeThreshold } = flappingSettings;
const {
application: { capabilities },
} = useKibana().services;
const {
rulesSettings: { writeFlappingSettingsUI },
} = capabilities;
const canWriteFlappingSettings = writeFlappingSettingsUI;
return (
<EuiFlexGroup direction="column">
{compressed && (
<>
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<RulesSettingsFlappingTitle />
</EuiFlexItem>
<EuiFlexItem>
<RulesSettingsFlappingDescription />
</EuiFlexItem>
<EuiSpacer size="s" />
</EuiFlexGroup>
</EuiFlexItem>
</>
)}
<EuiFlexItem grow={false}>
<RulesSettingsRange
data-test-subj="lookBackWindowRangeInput"
min={MIN_LOOK_BACK_WINDOW}
max={MAX_LOOK_BACK_WINDOW}
value={lookBackWindow}
onChange={(e) => onChange('lookBackWindow', parseInt(e.currentTarget.value, 10))}
label={lookBackWindowLabel}
disabled={!canWriteFlappingSettings}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RulesSettingsRange
data-test-subj="statusChangeThresholdRangeInput"
min={MIN_STATUS_CHANGE_THRESHOLD}
max={MAX_STATUS_CHANGE_THRESHOLD}
value={statusChangeThreshold}
onChange={(e) => onChange('statusChangeThreshold', parseInt(e.currentTarget.value, 10))}
label={statusChangeThresholdLabel}
disabled={!canWriteFlappingSettings}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel borderRadius="none" color="subdued">
<EuiText size="s">
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.flapping.flappingSettingsDescription"
defaultMessage="An alert will be considered flapping if it changes status {statusChangeThreshold} within the last {lookBackWindow}."
values={{
lookBackWindow: <b>{getLookBackWindowLabelRuleRuns(lookBackWindow)}</b>,
statusChangeThreshold: (
<b>{getStatusChangeThresholdRuleRuns(statusChangeThreshold)}</b>
),
}}
/>
</EuiText>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);

View file

@ -0,0 +1,150 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { render, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { coreMock } from '@kbn/core/public/mocks';
import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common';
import { RulesSettingsLink } from './rules_settings_link';
import { useKibana } from '../../../common/lib/kibana';
import { getFlappingSettings } from '../../lib/rule_api';
import { updateFlappingSettings } from '../../lib/rule_api';
jest.mock('../../../common/lib/kibana');
jest.mock('../../lib/rule_api/get_flapping_settings', () => ({
getFlappingSettings: jest.fn(),
}));
jest.mock('../../lib/rule_api/update_flapping_settings', () => ({
updateFlappingSettings: jest.fn(),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const mocks = coreMock.createSetup();
const getFlappingSettingsMock = getFlappingSettings as unknown as jest.MockedFunction<
typeof getFlappingSettings
>;
const updateFlappingSettingsMock = updateFlappingSettings as unknown as jest.MockedFunction<
typeof updateFlappingSettings
>;
const mockFlappingSetting: RulesSettingsFlapping = {
enabled: true,
lookBackWindow: 10,
statusChangeThreshold: 11,
createdBy: 'test user',
updatedBy: 'test user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const RulesSettingsLinkWithProviders: React.FunctionComponent<{}> = () => (
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<RulesSettingsLink />
</QueryClientProvider>
</IntlProvider>
);
describe('rules_settings_link', () => {
beforeEach(async () => {
const [
{
application: { capabilities },
},
] = await mocks.getStartServices();
useKibanaMock().services.application.capabilities = {
...capabilities,
rulesSettings: {
save: true,
show: true,
writeFlappingSettingsUI: true,
readFlappingSettingsUI: true,
},
};
getFlappingSettingsMock.mockResolvedValue(mockFlappingSetting);
updateFlappingSettingsMock.mockResolvedValue(mockFlappingSetting);
});
afterEach(() => {
jest.clearAllMocks();
queryClient.clear();
cleanup();
});
test('renders the rules setting link correctly', async () => {
const result = render(<RulesSettingsLinkWithProviders />);
await waitFor(() => {
expect(result.getByText('Settings')).toBeInTheDocument();
});
expect(result.getByText('Settings')).not.toBeDisabled();
expect(result.queryByTestId('rulesSettingsModal')).toBe(null);
});
test('clicking the settings link opens the rules settings modal', async () => {
const result = render(<RulesSettingsLinkWithProviders />);
await waitFor(() => {
expect(result.queryByTestId('rulesSettingsModal')).toBe(null);
});
userEvent.click(result.getByText('Settings'));
await waitFor(() => {
expect(result.queryByTestId('rulesSettingsModal')).not.toBe(null);
});
});
test('link is hidden when provided with insufficient read permissions', async () => {
const [
{
application: { capabilities },
},
] = await mocks.getStartServices();
useKibanaMock().services.application.capabilities = {
...capabilities,
rulesSettings: {
save: true,
show: false,
writeFlappingSettingsUI: true,
readFlappingSettingsUI: true,
},
};
let result = render(<RulesSettingsLinkWithProviders />);
await waitFor(() => {
expect(result.queryByTestId('rulesSettingsLink')).toBe(null);
});
useKibanaMock().services.application.capabilities = {
...capabilities,
rulesSettings: {
save: true,
show: true,
writeFlappingSettingsUI: true,
readFlappingSettingsUI: false,
},
};
result = render(<RulesSettingsLinkWithProviders />);
await waitFor(() => {
expect(result.queryByTestId('rulesSettingsLink')).toBe(null);
});
});
});

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { RulesSettingsModal } from './rules_settings_modal';
import { useKibana } from '../../../common/lib/kibana';
export const RulesSettingsLink = () => {
const [isVisible, setIsVisible] = useState<boolean>(false);
const {
application: { capabilities },
} = useKibana().services;
const { show, readFlappingSettingsUI } = capabilities.rulesSettings;
if (!show || !readFlappingSettingsUI) {
return null;
}
return (
<>
<EuiButtonEmpty
onClick={() => setIsVisible(true)}
iconType="gear"
data-test-subj="rulesSettingsLink"
>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.link.title"
defaultMessage="Settings"
/>
</EuiButtonEmpty>
<RulesSettingsModal isVisible={isVisible} onClose={() => setIsVisible(false)} />
</>
);
};

View file

@ -0,0 +1,264 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { render, fireEvent, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { coreMock } from '@kbn/core/public/mocks';
import { IToasts } from '@kbn/core/public';
import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common';
import { RulesSettingsModal, RulesSettingsModalProps } from './rules_settings_modal';
import { useKibana } from '../../../common/lib/kibana';
import { getFlappingSettings } from '../../lib/rule_api/get_flapping_settings';
import { updateFlappingSettings } from '../../lib/rule_api/update_flapping_settings';
jest.mock('../../../common/lib/kibana');
jest.mock('../../lib/rule_api/get_flapping_settings', () => ({
getFlappingSettings: jest.fn(),
}));
jest.mock('../../lib/rule_api/update_flapping_settings', () => ({
updateFlappingSettings: jest.fn(),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const mocks = coreMock.createSetup();
const getFlappingSettingsMock = getFlappingSettings as unknown as jest.MockedFunction<
typeof getFlappingSettings
>;
const updateFlappingSettingsMock = updateFlappingSettings as unknown as jest.MockedFunction<
typeof updateFlappingSettings
>;
const mockFlappingSetting: RulesSettingsFlapping = {
enabled: true,
lookBackWindow: 10,
statusChangeThreshold: 10,
createdBy: 'test user',
updatedBy: 'test user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const modalProps: RulesSettingsModalProps = {
isVisible: true,
setUpdatingRulesSettings: jest.fn(),
onClose: jest.fn(),
onSave: jest.fn(),
};
const RulesSettingsModalWithProviders: React.FunctionComponent<RulesSettingsModalProps> = (
props
) => (
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<RulesSettingsModal {...props} />
</QueryClientProvider>
</IntlProvider>
);
describe('rules_settings_modal', () => {
beforeEach(async () => {
const [
{
application: { capabilities },
},
] = await mocks.getStartServices();
useKibanaMock().services.application.capabilities = {
...capabilities,
rulesSettings: {
save: true,
show: true,
writeFlappingSettingsUI: true,
readFlappingSettingsUI: true,
},
};
useKibanaMock().services.notifications.toasts = {
addSuccess: jest.fn(),
addError: jest.fn(),
addDanger: jest.fn(),
addWarning: jest.fn(),
} as unknown as IToasts;
getFlappingSettingsMock.mockResolvedValue(mockFlappingSetting);
updateFlappingSettingsMock.mockResolvedValue(mockFlappingSetting);
});
afterEach(() => {
jest.clearAllMocks();
queryClient.clear();
cleanup();
});
test('renders flapping settings correctly', async () => {
const result = render(<RulesSettingsModalWithProviders {...modalProps} />);
expect(getFlappingSettingsMock).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
expect(result.getByTestId('rulesSettingsModalEnableSwitch').getAttribute('aria-checked')).toBe(
'true'
);
expect(result.getByTestId('lookBackWindowRangeInput').getAttribute('value')).toBe('10');
expect(result.getByTestId('statusChangeThresholdRangeInput').getAttribute('value')).toBe('10');
expect(result.getByTestId('rulesSettingsModalCancelButton')).toBeInTheDocument();
expect(result.getByTestId('rulesSettingsModalSaveButton').getAttribute('disabled')).toBeFalsy();
});
test('can save flapping settings', async () => {
const result = render(<RulesSettingsModalWithProviders {...modalProps} />);
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
const lookBackWindowInput = result.getByTestId('lookBackWindowRangeInput');
const statusChangeThresholdInput = result.getByTestId('statusChangeThresholdRangeInput');
fireEvent.change(lookBackWindowInput, { target: { value: 20 } });
fireEvent.change(statusChangeThresholdInput, { target: { value: 5 } });
expect(lookBackWindowInput.getAttribute('value')).toBe('20');
expect(statusChangeThresholdInput.getAttribute('value')).toBe('5');
// Try saving
userEvent.click(result.getByTestId('rulesSettingsModalSaveButton'));
await waitFor(() => {
expect(modalProps.setUpdatingRulesSettings).toHaveBeenCalledWith(true);
});
expect(modalProps.onClose).toHaveBeenCalledTimes(1);
expect(updateFlappingSettingsMock).toHaveBeenCalledWith(
expect.objectContaining({
flappingSettings: {
enabled: true,
lookBackWindow: 20,
statusChangeThreshold: 5,
},
})
);
expect(useKibanaMock().services.notifications.toasts.addSuccess).toHaveBeenCalledTimes(1);
expect(modalProps.setUpdatingRulesSettings).toHaveBeenCalledWith(true);
expect(modalProps.onSave).toHaveBeenCalledTimes(1);
});
test('should prevent statusChangeThreshold from being greater than lookBackWindow', async () => {
const result = render(<RulesSettingsModalWithProviders {...modalProps} />);
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
const lookBackWindowInput = result.getByTestId('lookBackWindowRangeInput');
const statusChangeThresholdInput = result.getByTestId('statusChangeThresholdRangeInput');
// Change lookBackWindow to a smaller value
fireEvent.change(lookBackWindowInput, { target: { value: 5 } });
// statusChangeThresholdInput gets pinned to be 5
expect(statusChangeThresholdInput.getAttribute('value')).toBe('5');
// Try making statusChangeThreshold bigger
fireEvent.change(statusChangeThresholdInput, { target: { value: 20 } });
// Still pinned
expect(statusChangeThresholdInput.getAttribute('value')).toBe('5');
fireEvent.change(statusChangeThresholdInput, { target: { value: 3 } });
expect(statusChangeThresholdInput.getAttribute('value')).toBe('3');
});
test('handles errors when saving settings', async () => {
updateFlappingSettingsMock.mockRejectedValue('failed!');
const result = render(<RulesSettingsModalWithProviders {...modalProps} />);
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
// Try saving
userEvent.click(result.getByTestId('rulesSettingsModalSaveButton'));
await waitFor(() => {
expect(modalProps.setUpdatingRulesSettings).toHaveBeenCalledWith(true);
});
expect(modalProps.onClose).toHaveBeenCalledTimes(1);
expect(useKibanaMock().services.notifications.toasts.addDanger).toHaveBeenCalledTimes(1);
expect(modalProps.setUpdatingRulesSettings).toHaveBeenCalledWith(true);
expect(modalProps.onSave).toHaveBeenCalledTimes(1);
});
test('displays flapping detection off prompt when flapping is disabled', async () => {
const result = render(<RulesSettingsModalWithProviders {...modalProps} />);
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
expect(result.queryByTestId('rulesSettingsModalFlappingOffPrompt')).toBe(null);
userEvent.click(result.getByTestId('rulesSettingsModalEnableSwitch'));
expect(result.queryByTestId('rulesSettingsModalFlappingOffPrompt')).not.toBe(null);
});
test('form elements are disabled when provided with insufficient write permissions', async () => {
const [
{
application: { capabilities },
},
] = await mocks.getStartServices();
useKibanaMock().services.application.capabilities = {
...capabilities,
rulesSettings: {
save: true,
show: true,
writeFlappingSettingsUI: false,
readFlappingSettingsUI: true,
},
};
const result = render(<RulesSettingsModalWithProviders {...modalProps} />);
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
expect(result.getByTestId('rulesSettingsModalEnableSwitch')).toBeDisabled();
expect(result.getByTestId('lookBackWindowRangeInput')).toBeDisabled();
expect(result.getByTestId('statusChangeThresholdRangeInput')).toBeDisabled();
expect(result.getByTestId('rulesSettingsModalSaveButton')).toBeDisabled();
});
test('form elements are not visible when provided with insufficient read permissions', async () => {
const [
{
application: { capabilities },
},
] = await mocks.getStartServices();
useKibanaMock().services.application.capabilities = {
...capabilities,
rulesSettings: {
save: true,
show: false,
writeFlappingSettingsUI: true,
readFlappingSettingsUI: false,
},
};
const result = render(<RulesSettingsModalWithProviders {...modalProps} />);
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
expect(result.getByTestId('rulesSettingsErrorPrompt')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,299 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useState } from 'react';
import { RulesSettingsFlappingProperties } from '@kbn/alerting-plugin/common';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiHorizontalRule,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiModal,
EuiModalHeader,
EuiModalBody,
EuiModalFooter,
EuiModalHeaderTitle,
EuiSpacer,
EuiSwitch,
EuiSwitchProps,
EuiPanel,
EuiText,
EuiEmptyPrompt,
} from '@elastic/eui';
import { useKibana } from '../../../common/lib/kibana';
import {
RulesSettingsFlappingFormSection,
RulesSettingsFlappingFormSectionProps,
RulesSettingsFlappingTitle,
} from './rules_settings_flapping_form_section';
import { useGetFlappingSettings } from '../../hooks/use_get_flapping_settings';
import { useUpdateFlappingSettings } from '../../hooks/use_update_flapping_settings';
import { CenterJustifiedSpinner } from '../center_justified_spinner';
const flappingDescription = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.modal.flappingDetectionDescription',
{
defaultMessage:
'Alerts that go quickly go between active and recovered are considered flapping. Detecting these changes and minimizing new alert generation can help reduce unwanted noise in your alerting system.',
}
);
const flappingEnableLabel = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.modal.enableFlappingLabel',
{
defaultMessage: 'Enabled flapping detection (recommended)',
}
);
export const RulesSettingsErrorPrompt = memo(() => {
return (
<EuiEmptyPrompt
data-test-subj="rulesSettingsErrorPrompt"
color="danger"
iconType="alert"
title={
<h4>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.modal.errorPromptTitle"
defaultMessage="Unable to load your rules settings"
/>
</h4>
}
body={
<p>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.modal.errorPromptBody"
defaultMessage="There was an error loading your rules settings. Contact your administrator for help"
/>
</p>
}
/>
);
});
interface RulesSettingsModalFormLeftProps {
settings: RulesSettingsFlappingProperties;
onChange: EuiSwitchProps['onChange'];
isSwitchDisabled: boolean;
}
export const RulesSettingsModalFormLeft = memo((props: RulesSettingsModalFormLeftProps) => {
const { settings, onChange, isSwitchDisabled } = props;
return (
<EuiFlexItem>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiText color="subdued" size="s">
<p>{flappingDescription}</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSwitch
data-test-subj="rulesSettingsModalEnableSwitch"
label={flappingEnableLabel}
checked={settings!.enabled}
disabled={isSwitchDisabled}
onChange={onChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
});
interface RulesSettingsModalFormRightProps {
settings: RulesSettingsFlappingProperties;
onChange: RulesSettingsFlappingFormSectionProps['onChange'];
}
export const RulesSettingsModalFormRight = memo((props: RulesSettingsModalFormRightProps) => {
const { settings, onChange } = props;
if (!settings) {
return null;
}
if (!settings.enabled) {
return (
<EuiFlexItem data-test-subj="rulesSettingsModalFlappingOffPrompt">
<EuiPanel borderRadius="none" color="subdued" grow={false}>
<EuiText size="s">
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.flapping.flappingSettingsOffDescription"
defaultMessage="Alert flapping detection is off. Alerts will be generated based on the rule interval. This may result in higher alert volume."
/>
</EuiText>
</EuiPanel>
</EuiFlexItem>
);
}
return (
<EuiFlexItem>
<RulesSettingsFlappingFormSection flappingSettings={settings} onChange={onChange} />
</EuiFlexItem>
);
});
export interface RulesSettingsModalProps {
isVisible: boolean;
setUpdatingRulesSettings?: (isUpdating: boolean) => void;
onClose: () => void;
onSave?: () => void;
}
export const RulesSettingsModal = memo((props: RulesSettingsModalProps) => {
const { isVisible, onClose, setUpdatingRulesSettings, onSave } = props;
const {
application: { capabilities },
} = useKibana().services;
const {
rulesSettings: { show, save, writeFlappingSettingsUI, readFlappingSettingsUI },
} = capabilities;
const [settings, setSettings] = useState<RulesSettingsFlappingProperties>();
const { isLoading, isError: hasError } = useGetFlappingSettings({
enabled: isVisible,
onSuccess: (fetchedSettings) => {
if (!settings) {
setSettings({
enabled: fetchedSettings.enabled,
lookBackWindow: fetchedSettings.lookBackWindow,
statusChangeThreshold: fetchedSettings.statusChangeThreshold,
});
}
},
});
const { mutate } = useUpdateFlappingSettings({
onSave,
onClose,
setUpdatingRulesSettings,
});
// In the future when we have more settings sub-features, we should
// disassociate the rule settings capabilities (save, show) from the
// sub-feature capabilities (writeXSettingsUI).
const canWriteFlappingSettings = save && writeFlappingSettingsUI && !hasError;
const canShowFlappingSettings = show && readFlappingSettingsUI;
const handleSettingsChange = (
key: keyof RulesSettingsFlappingProperties,
value: number | boolean
) => {
if (!settings) {
return;
}
const newSettings = {
...settings,
[key]: value,
};
setSettings({
...newSettings,
statusChangeThreshold: Math.min(
newSettings.lookBackWindow,
newSettings.statusChangeThreshold
),
});
};
const handleSave = () => {
if (!settings) {
return;
}
mutate(settings);
};
if (!isVisible) {
return null;
}
const maybeRenderForm = () => {
if (hasError || !canShowFlappingSettings) {
return <RulesSettingsErrorPrompt />;
}
if (!settings || isLoading) {
return <CenterJustifiedSpinner />;
}
return (
<EuiForm>
<EuiFlexGroup>
<EuiFlexItem>
<RulesSettingsFlappingTitle />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<RulesSettingsModalFormLeft
isSwitchDisabled={!canWriteFlappingSettings}
settings={settings}
onChange={(e) => handleSettingsChange('enabled', e.target.checked)}
/>
<RulesSettingsModalFormRight
settings={settings}
onChange={(key, value) => handleSettingsChange(key, value)}
/>
</EuiFlexGroup>
</EuiForm>
);
};
return (
<EuiModal data-test-subj="rulesSettingsModal" onClose={onClose} maxWidth={880}>
<EuiModalHeader>
<EuiModalHeaderTitle>
<h3>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.modal.title"
defaultMessage="Rule Settings"
/>
</h3>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiCallOut
size="s"
title={i18n.translate('xpack.triggersActionsUI.rulesSettings.modal.calloutMessage', {
defaultMessage: 'Applies to all rules within the current space',
})}
/>
<EuiHorizontalRule />
{maybeRenderForm()}
<EuiSpacer />
<EuiHorizontalRule margin="none" />
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty data-test-subj="rulesSettingsModalCancelButton" onClick={onClose}>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.modal.cancelButton"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<EuiButton
fill
data-test-subj="rulesSettingsModalSaveButton"
onClick={handleSave}
disabled={!canWriteFlappingSettings}
>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.modal.saveButton"
defaultMessage="Save"
/>
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
});

View file

@ -0,0 +1,53 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { useQuery } from '@tanstack/react-query';
import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../common/lib/kibana';
import { getFlappingSettings } from '../lib/rule_api';
interface UseGetFlappingSettingsProps {
enabled: boolean;
onSuccess: (settings: RulesSettingsFlapping) => void;
}
export const useGetFlappingSettings = (props: UseGetFlappingSettingsProps) => {
const { enabled, onSuccess } = props;
const {
http,
notifications: { toasts },
} = useKibana().services;
const queryFn = () => {
return getFlappingSettings({ http });
};
const onErrorFn = () => {
toasts.addDanger(
i18n.translate('xpack.triggersActionsUI.rulesSettings.modal.getRulesSettingsError', {
defaultMessage: 'Failed to get rules Settings.',
})
);
};
const { data, isFetching, isError, isLoadingError, isLoading } = useQuery({
queryKey: ['getFlappingSettings'],
queryFn,
onError: onErrorFn,
onSuccess,
enabled,
refetchOnWindowFocus: false,
retry: false,
});
return {
isLoading: isLoading || isFetching,
isError: isError || isLoadingError,
data,
};
};

View file

@ -0,0 +1,57 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { useMutation } from '@tanstack/react-query';
import { RulesSettingsFlappingProperties } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../common/lib/kibana';
import { updateFlappingSettings } from '../lib/rule_api';
interface UseUpdateFlappingSettingsProps {
onClose: () => void;
onSave?: () => void;
setUpdatingRulesSettings?: (isUpdating: boolean) => void;
}
export const useUpdateFlappingSettings = (props: UseUpdateFlappingSettingsProps) => {
const { onSave, onClose, setUpdatingRulesSettings } = props;
const {
http,
notifications: { toasts },
} = useKibana().services;
const mutationFn = (flappingSettings: RulesSettingsFlappingProperties) => {
return updateFlappingSettings({ http, flappingSettings });
};
return useMutation({
mutationFn,
onMutate: () => {
onClose();
setUpdatingRulesSettings?.(true);
},
onSuccess: () => {
toasts.addSuccess(
i18n.translate('xpack.triggersActionsUI.rulesSettings.modal.updateRulesSettingsSuccess', {
defaultMessage: 'Rules settings updated successfully.',
})
);
},
onError: () => {
toasts.addDanger(
i18n.translate('xpack.triggersActionsUI.rulesSettings.modal.updateRulesSettingsFailure', {
defaultMessage: 'Failed to update rules settings.',
})
);
},
onSettled: () => {
setUpdatingRulesSettings?.(false);
onSave?.();
},
});
};

View file

@ -0,0 +1,16 @@
/*
* 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 '@kbn/core/public';
import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants';
export const getFlappingSettings = ({ http }: { http: HttpSetup }) => {
return http.get<RulesSettingsFlapping>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_flapping`
);
};

View file

@ -46,3 +46,5 @@ export { runSoon } from './run_soon';
export { bulkDeleteRules } from './bulk_delete'; export { bulkDeleteRules } from './bulk_delete';
export { bulkEnableRules } from './bulk_enable'; export { bulkEnableRules } from './bulk_enable';
export { bulkDisableRules } from './bulk_disable'; export { bulkDisableRules } from './bulk_disable';
export { getFlappingSettings } from './get_flapping_settings';
export { updateFlappingSettings } from './update_flapping_settings';

View file

@ -0,0 +1,34 @@
/*
* 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 '@kbn/core/public';
import {
RulesSettingsFlapping,
RulesSettingsFlappingProperties,
} from '@kbn/alerting-plugin/common';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants';
export const updateFlappingSettings = ({
http,
flappingSettings,
}: {
http: HttpSetup;
flappingSettings: RulesSettingsFlappingProperties;
}) => {
let body: string;
try {
body = JSON.stringify(flappingSettings);
} catch (e) {
throw new Error(`Unable to parse flapping settings update params: ${e}`);
}
return http.post<RulesSettingsFlapping>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_flapping`,
{
body,
}
);
};

View file

@ -97,6 +97,7 @@ import {
MULTIPLE_RULE_TITLE, MULTIPLE_RULE_TITLE,
} from '../translations'; } from '../translations';
import { useBulkOperationToast } from '../../../hooks/use_bulk_operation_toast'; import { useBulkOperationToast } from '../../../hooks/use_bulk_operation_toast';
import { RulesSettingsLink } from '../../../components/rules_setting/rules_settings_link';
import { useRulesListUiState as useUiState } from '../../../hooks/use_rules_list_ui_state'; import { useRulesListUiState as useUiState } from '../../../hooks/use_rules_list_ui_state';
// Directly lazy import the flyouts because the suspendedComponentWithProps component // Directly lazy import the flyouts because the suspendedComponentWithProps component
@ -614,11 +615,15 @@ export const RulesList = ({
if (!setHeaderActions) return; if (!setHeaderActions) return;
if (showHeaderWithoutCreateButton) { if (showHeaderWithoutCreateButton) {
setHeaderActions([<RulesListDocLink />]); setHeaderActions([<RulesListDocLink />, <RulesSettingsLink />]);
return; return;
} }
if (showHeaderWithCreateButton) { if (showHeaderWithCreateButton) {
setHeaderActions([<CreateRuleButton openFlyout={openFlyout} />, <RulesListDocLink />]); setHeaderActions([
<CreateRuleButton openFlyout={openFlyout} />,
<RulesSettingsLink />,
<RulesListDocLink />,
]);
return; return;
} }
setHeaderActions(); setHeaderActions();

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common';
import { UserAtSpaceScenarios } from '../../../scenarios';
import { getUrlPrefix } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function getFlappingSettingsTests({ getService }: FtrProviderContext) {
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('getFlappingSettings', () => {
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
describe(scenario.id, () => {
it('should handle get flapping settings request appropriately', async () => {
const response = await supertestWithoutAuth
.get(`${getUrlPrefix(space.id)}/internal/alerting/rules/settings/_flapping`)
.auth(user.username, user.password);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'space_1_all_with_restricted_fixture at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Forbidden',
statusCode: 403,
});
break;
case 'global_read at space1':
case 'superuser at space1':
case 'space_1_all at space1':
expect(response.statusCode).to.eql(200);
expect(response.body.enabled).to.eql(DEFAULT_FLAPPING_SETTINGS.enabled);
expect(response.body.lookBackWindow).to.eql(DEFAULT_FLAPPING_SETTINGS.lookBackWindow);
expect(response.body.statusChangeThreshold).to.eql(
DEFAULT_FLAPPING_SETTINGS.statusChangeThreshold
);
expect(response.body.createdBy).to.be.a('string');
expect(response.body.updatedBy).to.be.a('string');
expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0);
expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
}

View file

@ -25,6 +25,8 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./bulk_enable')); loadTestFile(require.resolve('./bulk_enable'));
loadTestFile(require.resolve('./bulk_disable')); loadTestFile(require.resolve('./bulk_disable'));
loadTestFile(require.resolve('./clone')); loadTestFile(require.resolve('./clone'));
loadTestFile(require.resolve('./get_flapping_settings'));
loadTestFile(require.resolve('./update_flapping_settings'));
}); });
}); });
} }

View file

@ -0,0 +1,154 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common';
import { UserAtSpaceScenarios, Superuser } from '../../../scenarios';
import { getUrlPrefix } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
const resetRulesSettings = (supertestWithoutAuth: any, space: string) => {
return supertestWithoutAuth
.post(`${getUrlPrefix(space)}/internal/alerting/rules/settings/_flapping`)
.set('kbn-xsrf', 'foo')
.auth(Superuser.username, Superuser.password)
.send(DEFAULT_FLAPPING_SETTINGS);
};
// eslint-disable-next-line import/no-default-export
export default function updateFlappingSettingsTest({ getService }: FtrProviderContext) {
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('updateFlappingSettings', () => {
afterEach(async () => {
await resetRulesSettings(supertestWithoutAuth, 'space1');
await resetRulesSettings(supertestWithoutAuth, 'space2');
});
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
describe(scenario.id, () => {
it('should handle update flapping settings request appropriately', async () => {
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/settings/_flapping`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send({
enabled: false,
lookBackWindow: 20,
statusChangeThreshold: 20,
});
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'global_read at space1':
case 'space_1_all at space2':
case 'space_1_all_with_restricted_fixture at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Forbidden',
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all at space1':
expect(response.statusCode).to.eql(200);
expect(response.body.enabled).to.eql(false);
expect(response.body.lookBackWindow).to.eql(20);
expect(response.body.statusChangeThreshold).to.eql(20);
expect(response.body.createdBy).to.eql(user.username);
expect(response.body.updatedBy).to.eql(user.username);
expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0);
expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
it('should error if provided with invalid inputs', async () => {
let response = await supertestWithoutAuth
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/settings/_flapping`)
.set('kbn-xsrf', 'foo')
.auth(Superuser.username, Superuser.password)
.send({
enabled: true,
lookBackWindow: 200,
statusChangeThreshold: 200,
})
.expect(400);
expect(response.body.message).to.eql(
'Invalid lookBackWindow value, must be between 2 and 20, but got: 200.'
);
response = await supertestWithoutAuth
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/settings/_flapping`)
.set('kbn-xsrf', 'foo')
.auth(Superuser.username, Superuser.password)
.send({
enabled: true,
lookBackWindow: 20,
statusChangeThreshold: 200,
})
.expect(400);
expect(response.body.message).to.eql(
'Invalid statusChangeThreshold value, must be between 2 and 20, but got: 200.'
);
response = await supertestWithoutAuth
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/settings/_flapping`)
.set('kbn-xsrf', 'foo')
.auth(Superuser.username, Superuser.password)
.send({
enabled: true,
lookBackWindow: 5,
statusChangeThreshold: 10,
})
.expect(400);
expect(response.body.message).to.eql(
'Invalid values,lookBackWindow (5) must be equal to or greater than statusChangeThreshold (10).'
);
});
describe('updateFlappingSettings for other spaces', () => {
it('should update specific isolated settings depending on space', async () => {
// Update the rules setting in space1
const postResponse = await supertestWithoutAuth
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/settings/_flapping`)
.set('kbn-xsrf', 'foo')
.auth(Superuser.username, Superuser.password)
.send({
enabled: false,
lookBackWindow: 20,
statusChangeThreshold: 20,
});
expect(postResponse.statusCode).to.eql(200);
expect(postResponse.body.enabled).to.eql(false);
expect(postResponse.body.lookBackWindow).to.eql(20);
expect(postResponse.body.statusChangeThreshold).to.eql(20);
// Get the rules settings in space2
const getResponse = await supertestWithoutAuth
.get(`${getUrlPrefix('space2')}/internal/alerting/rules/settings/_flapping`)
.auth(Superuser.username, Superuser.password);
expect(getResponse.statusCode).to.eql(200);
expect(getResponse.body.enabled).to.eql(true);
expect(getResponse.body.lookBackWindow).to.eql(20);
expect(getResponse.body.statusChangeThreshold).to.eql(4);
});
});
});
}

View file

@ -5,6 +5,10 @@
* 2.0. * 2.0.
*/ */
import {
READ_FLAPPING_SETTINGS_SUB_FEATURE_ID,
ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID,
} from '@kbn/alerting-plugin/common';
import { ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; import { ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers';
import { Space, User } from '../common/types'; import { Space, User } from '../common/types';
@ -51,6 +55,7 @@ const GlobalRead: User = {
alertsFixture: ['read'], alertsFixture: ['read'],
alertsRestrictedFixture: ['read'], alertsRestrictedFixture: ['read'],
actionsSimulators: ['read'], actionsSimulators: ['read'],
rulesSettings: ['read', READ_FLAPPING_SETTINGS_SUB_FEATURE_ID],
}, },
spaces: ['*'], spaces: ['*'],
}, },
@ -78,6 +83,7 @@ const Space1All: User = {
actions: ['all'], actions: ['all'],
alertsFixture: ['all'], alertsFixture: ['all'],
actionsSimulators: ['all'], actionsSimulators: ['all'],
rulesSettings: ['all', ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID],
}, },
spaces: ['space1'], spaces: ['space1'],
}, },

View file

@ -118,6 +118,7 @@ export default function ({ getService }: FtrProviderContext) {
'logs', 'logs',
'maps', 'maps',
'osquery', 'osquery',
'rulesSettings',
'uptime', 'uptime',
'siem', 'siem',
'securitySolutionCases', 'securitySolutionCases',

View file

@ -92,6 +92,14 @@ export default function ({ getService }: FtrProviderContext) {
], ],
filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'], filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'], filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'],
rulesSettings: [
'all',
'read',
'minimal_all',
'minimal_read',
'allFlappingSettings',
'readFlappingSettings',
],
}, },
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
}; };

View file

@ -47,6 +47,7 @@ export default function ({ getService }: FtrProviderContext) {
actions: ['all', 'read', 'minimal_all', 'minimal_read'], actions: ['all', 'read', 'minimal_all', 'minimal_read'],
filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'], filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'], filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'],
rulesSettings: ['all', 'read', 'minimal_all', 'minimal_read'],
}, },
global: ['all', 'read'], global: ['all', 'read'],
space: ['all', 'read'], space: ['all', 'read'],
@ -161,6 +162,14 @@ export default function ({ getService }: FtrProviderContext) {
'packs_all', 'packs_all',
'packs_read', 'packs_read',
], ],
rulesSettings: [
'all',
'read',
'minimal_all',
'minimal_read',
'allFlappingSettings',
'readFlappingSettings',
],
}, },
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
}; };

View file

@ -15,5 +15,6 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => {
loadTestFile(require.resolve('./details')); loadTestFile(require.resolve('./details'));
loadTestFile(require.resolve('./connectors')); loadTestFile(require.resolve('./connectors'));
loadTestFile(require.resolve('./logs_list')); loadTestFile(require.resolve('./logs_list'));
loadTestFile(require.resolve('./rules_settings'));
}); });
}; };

View file

@ -0,0 +1,139 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { createAlert } from '../../lib/alert_api_actions';
import { ObjectRemover } from '../../lib/object_remover';
import { FtrProviderContext } from '../../ftr_provider_context';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
const testSubjects = getService('testSubjects');
const supertest = getService('supertest');
const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header', 'security']);
const browser = getService('browser');
const objectRemover = new ObjectRemover(supertest);
const retry = getService('retry');
async function refreshAlertsList() {
await retry.try(async () => {
await pageObjects.common.navigateToApp('triggersActions');
await testSubjects.click('triggersActions');
const searchResults = await pageObjects.triggersActionsUI.getAlertsList();
expect(searchResults).to.have.length(1);
});
}
async function dragRangeInput(
testId: string,
steps: number = 1,
direction: 'left' | 'right' = 'right'
) {
const inputEl = await testSubjects.find(testId);
await inputEl.focus();
const browserKey = direction === 'left' ? browser.keys.LEFT : browser.keys.RIGHT;
while (steps--) {
await browser.pressKeys(browserKey);
}
}
describe('rules settings modal', () => {
before(async () => {
await supertest
.post(`/internal/alerting/rules/settings/_flapping`)
.set('kbn-xsrf', 'foo')
.send({
enabled: true,
lookBackWindow: 10,
statusChangeThreshold: 10,
})
.expect(200);
});
beforeEach(async () => {
await createAlert({
supertest,
objectRemover,
});
await refreshAlertsList();
});
afterEach(async () => {
await objectRemover.removeAll();
});
it('rules settings link should be enabled', async () => {
await testSubjects.existOrFail('rulesSettingsLink');
const button = await testSubjects.find('rulesSettingsLink');
const isDisabled = await button.getAttribute('disabled');
expect(isDisabled).to.equal(null);
});
it('should allow the user to open up the rules settings modal', async () => {
await testSubjects.click('rulesSettingsLink');
await testSubjects.existOrFail('rulesSettingsModal');
await testSubjects.waitForDeleted('centerJustifiedSpinner');
// Flapping enabled by default
await testSubjects.missingOrFail('rulesSettingsModalFlappingOffPrompt');
await testSubjects.existOrFail('rulesSettingsModalEnableSwitch');
await testSubjects.existOrFail('lookBackWindowRangeInput');
await testSubjects.existOrFail('statusChangeThresholdRangeInput');
const lookBackWindowInput = await testSubjects.find('lookBackWindowRangeInput');
const statusChangeThresholdInput = await testSubjects.find('statusChangeThresholdRangeInput');
const lookBackWindowValue = await lookBackWindowInput.getAttribute('value');
const statusChangeThresholdValue = await statusChangeThresholdInput.getAttribute('value');
expect(lookBackWindowValue).to.eql('10');
expect(statusChangeThresholdValue).to.eql('10');
});
it('should allow the user to modify rules settings', async () => {
await testSubjects.click('rulesSettingsLink');
await testSubjects.waitForDeleted('centerJustifiedSpinner');
await dragRangeInput('lookBackWindowRangeInput', 5, 'right');
await dragRangeInput('statusChangeThresholdRangeInput', 5, 'left');
let lookBackWindowInput = await testSubjects.find('lookBackWindowRangeInput');
let statusChangeThresholdInput = await testSubjects.find('statusChangeThresholdRangeInput');
let lookBackWindowValue = await lookBackWindowInput.getAttribute('value');
let statusChangeThresholdValue = await statusChangeThresholdInput.getAttribute('value');
expect(lookBackWindowValue).to.eql('15');
expect(statusChangeThresholdValue).to.eql('5');
await testSubjects.click('rulesSettingsModalEnableSwitch');
await testSubjects.existOrFail('rulesSettingsModalFlappingOffPrompt');
// Save
await testSubjects.click('rulesSettingsModalSaveButton');
await pageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.missingOrFail('rulesSettingsModal');
// Open up the modal again
await testSubjects.click('rulesSettingsLink');
await testSubjects.waitForDeleted('centerJustifiedSpinner');
// Flapping initially disabled
await testSubjects.existOrFail('rulesSettingsModalFlappingOffPrompt');
await testSubjects.click('rulesSettingsModalEnableSwitch');
lookBackWindowInput = await testSubjects.find('lookBackWindowRangeInput');
statusChangeThresholdInput = await testSubjects.find('statusChangeThresholdRangeInput');
lookBackWindowValue = await lookBackWindowInput.getAttribute('value');
statusChangeThresholdValue = await statusChangeThresholdInput.getAttribute('value');
expect(lookBackWindowValue).to.eql('15');
expect(statusChangeThresholdValue).to.eql('5');
});
});
};