mirror of
https://github.com/elastic/kibana.git
synced 2025-06-28 03:01:21 -04:00
[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)  ### Rules settings settings button  ### Rules settings modal  ### Disabled  ### Rules settings settings button with insufficient permissions  ### Rules settings modal with insufficient write subfeature permissions  ### Rules settings modal with insufficient read subfeature permissions  Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
a0ac890f23
commit
dc28138d00
44 changed files with 2944 additions and 4 deletions
|
@ -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",
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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';
|
||||||
|
|
51
x-pack/plugins/alerting/common/rules_settings.ts
Normal file
51
x-pack/plugins/alerting/common/rules_settings.ts
Normal 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,
|
||||||
|
};
|
|
@ -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(), {
|
||||||
|
|
|
@ -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'])),
|
||||||
|
|
|
@ -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),
|
||||||
},
|
},
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 });
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
32
x-pack/plugins/alerting/server/rules_settings_client.mock.ts
Normal file
32
x-pack/plugins/alerting/server/rules_settings_client.mock.ts
Normal 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,
|
||||||
|
};
|
|
@ -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).'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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';
|
|
@ -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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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');
|
||||||
|
});
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
91
x-pack/plugins/alerting/server/rules_settings_feature.ts
Normal file
91
x-pack/plugins/alerting/server/rules_settings_feature.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -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;
|
||||||
|
|
|
@ -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}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -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?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
|
@ -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`
|
||||||
|
);
|
||||||
|
};
|
|
@ -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';
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
|
@ -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();
|
||||||
|
|
|
@ -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)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -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'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -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'],
|
||||||
},
|
},
|
||||||
|
|
|
@ -118,6 +118,7 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
'logs',
|
'logs',
|
||||||
'maps',
|
'maps',
|
||||||
'osquery',
|
'osquery',
|
||||||
|
'rulesSettings',
|
||||||
'uptime',
|
'uptime',
|
||||||
'siem',
|
'siem',
|
||||||
'securitySolutionCases',
|
'securitySolutionCases',
|
||||||
|
|
|
@ -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'],
|
||||||
};
|
};
|
||||||
|
|
|
@ -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'],
|
||||||
};
|
};
|
||||||
|
|
|
@ -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'));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue