diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index 85230f1b6f70d6..e3d0e16630c5c8 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -1,3 +1,4 @@ +[role="xpack"] [[drilldowns]] == Use drilldowns for dashboard actions diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc index 4919625340da28..e6daf89d727183 100644 --- a/docs/user/dashboard/url-drilldown.asciidoc +++ b/docs/user/dashboard/url-drilldown.asciidoc @@ -1,6 +1,8 @@ [[url-drilldown]] === URL drilldown +beta[] + The URL drilldown allows you to navigate from a dashboard to an internal or external URL. The destination URL can be dynamic, depending on the dashboard context or user’s interaction with a visualization. diff --git a/package.json b/package.json index c88fbc0e5fd071..d258d21413fe45 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "color": "1.0.3", "commander": "3.0.2", "core-js": "^3.6.4", + "cypress-promise": "^1.1.0", "deep-freeze-strict": "^1.1.1", "del": "^5.1.0", "elastic-apm-node": "^3.7.0", diff --git a/x-pack/package.json b/x-pack/package.json index de0bf0d922b423..9a1d424da4a1dc 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -160,6 +160,7 @@ "cronstrue": "^1.51.0", "cypress": "5.0.0", "cypress-multi-reporters": "^1.2.3", + "cypress-promise": "^1.1.0", "d3": "3.5.17", "d3-scale": "1.0.7", "dragselect": "1.13.1", diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 573fb0e1be5809..adef12454f2d52 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -893,7 +893,7 @@ describe('update()', () => { }, references: [], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { @@ -946,7 +946,7 @@ describe('update()', () => { }, references: [], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { @@ -972,17 +972,21 @@ describe('update()', () => { name: 'my name', config: {}, }); - expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", - "my-action", Object { "actionTypeId": "my-action-type", "config": Object {}, "name": "my name", "secrets": Object {}, }, + Object { + "id": "my-action", + "overwrite": true, + "references": Array [], + }, ] `); expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); @@ -1043,7 +1047,7 @@ describe('update()', () => { }, references: [], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { @@ -1081,11 +1085,10 @@ describe('update()', () => { c: true, }, }); - expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", - "my-action", Object { "actionTypeId": "my-action-type", "config": Object { @@ -1096,6 +1099,11 @@ describe('update()', () => { "name": "my name", "secrets": Object {}, }, + Object { + "id": "my-action", + "overwrite": true, + "references": Array [], + }, ] `); }); @@ -1118,7 +1126,7 @@ describe('update()', () => { }, references: [], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index 06c9555f3a18d0..4079a6ddeeb8a7 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -13,6 +13,7 @@ import { } from 'src/core/server'; import { i18n } from '@kbn/i18n'; +import { omitBy, isUndefined } from 'lodash'; import { ActionTypeRegistry } from './action_type_registry'; import { validateConfig, validateSecrets, ActionExecutorContract } from './lib'; import { @@ -30,7 +31,10 @@ import { } from './create_execute_function'; import { ActionsAuthorization } from './authorization/actions_authorization'; import { ActionType } from '../common'; -import { shouldLegacyRbacApplyBySource } from './authorization/should_legacy_rbac_apply_by_source'; +import { + getAuthorizationModeBySource, + AuthorizationMode, +} from './authorization/get_authorization_mode_by_source'; // We are assuming there won't be many actions. This is why we will load // all the actions in advance and assume the total count to not go over 10000. @@ -151,8 +155,10 @@ export class ActionsClient { 'update' ); } - const existingObject = await this.unsecuredSavedObjectsClient.get('action', id); - const { actionTypeId } = existingObject.attributes; + const { attributes, references, version } = await this.unsecuredSavedObjectsClient.get< + RawAction + >('action', id); + const { actionTypeId } = attributes; const { name, config, secrets } = action; const actionType = this.actionTypeRegistry.get(actionTypeId); const validatedActionTypeConfig = validateConfig(actionType, config); @@ -160,12 +166,25 @@ export class ActionsClient { this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); - const result = await this.unsecuredSavedObjectsClient.update('action', id, { - actionTypeId, - name, - config: validatedActionTypeConfig as SavedObjectAttributes, - secrets: validatedActionTypeSecrets as SavedObjectAttributes, - }); + const result = await this.unsecuredSavedObjectsClient.create( + 'action', + { + ...attributes, + actionTypeId, + name, + config: validatedActionTypeConfig as SavedObjectAttributes, + secrets: validatedActionTypeSecrets as SavedObjectAttributes, + }, + omitBy( + { + id, + overwrite: true, + references, + version, + }, + isUndefined + ) + ); return { id, @@ -301,7 +320,10 @@ export class ActionsClient { params, source, }: Omit): Promise> { - if (!(await shouldLegacyRbacApplyBySource(this.unsecuredSavedObjectsClient, source))) { + if ( + (await getAuthorizationModeBySource(this.unsecuredSavedObjectsClient, source)) === + AuthorizationMode.RBAC + ) { await this.authorization.ensureAuthorized('execute'); } return this.actionExecutor.execute({ actionId, params, source, request: this.request }); @@ -309,7 +331,10 @@ export class ActionsClient { public async enqueueExecution(options: EnqueueExecutionOptions): Promise { const { source } = options; - if (!(await shouldLegacyRbacApplyBySource(this.unsecuredSavedObjectsClient, source))) { + if ( + (await getAuthorizationModeBySource(this.unsecuredSavedObjectsClient, source)) === + AuthorizationMode.RBAC + ) { await this.authorization.ensureAuthorized('execute'); } return this.executionEnqueuer(this.unsecuredSavedObjectsClient, options); diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts index 08c4472f8007ba..a19a662f8323cb 100644 --- a/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts @@ -10,6 +10,7 @@ import { actionsAuthorizationAuditLoggerMock } from './audit_logger.mock'; import { ActionsAuthorizationAuditLogger, AuthorizationResult } from './audit_logger'; import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../saved_objects'; import { AuthenticatedUser } from '../../../security/server'; +import { AuthorizationMode } from './get_authorization_mode_by_source'; const request = {} as KibanaRequest; @@ -195,7 +196,7 @@ describe('ensureAuthorized', () => { `); }); - test('exempts users from requiring privileges to execute actions when shouldUseLegacyRbac is true', async () => { + test('exempts users from requiring privileges to execute actions when authorizationMode is Legacy', async () => { const { authorization, authentication } = mockSecurity(); const checkPrivileges: jest.MockedFunction { authorization, authentication, auditLogger, - shouldUseLegacyRbac: true, + authorizationMode: AuthorizationMode.Legacy, }); authentication.getCurrentUser.mockReturnValueOnce(({ diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.ts index bd6e355c2cf9d1..cad58bed50981f 100644 --- a/x-pack/plugins/actions/server/authorization/actions_authorization.ts +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.ts @@ -9,6 +9,7 @@ import { KibanaRequest } from 'src/core/server'; import { SecurityPluginSetup } from '../../../security/server'; import { ActionsAuthorizationAuditLogger } from './audit_logger'; import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { AuthorizationMode } from './get_authorization_mode_by_source'; export interface ConstructorOptions { request: KibanaRequest; @@ -22,7 +23,7 @@ export interface ConstructorOptions { // actions to continue to execute - which requires that we exempt auth on // `get` for Connectors and `execute` for Action execution when used by // these legacy alerts - shouldUseLegacyRbac?: boolean; + authorizationMode?: AuthorizationMode; } const operationAlias: Record< @@ -43,20 +44,19 @@ export class ActionsAuthorization { private readonly authorization?: SecurityPluginSetup['authz']; private readonly authentication?: SecurityPluginSetup['authc']; private readonly auditLogger: ActionsAuthorizationAuditLogger; - private readonly shouldUseLegacyRbac: boolean; - + private readonly authorizationMode: AuthorizationMode; constructor({ request, authorization, authentication, auditLogger, - shouldUseLegacyRbac = false, + authorizationMode = AuthorizationMode.RBAC, }: ConstructorOptions) { this.request = request; this.authorization = authorization; this.authentication = authentication; this.auditLogger = auditLogger; - this.shouldUseLegacyRbac = shouldUseLegacyRbac; + this.authorizationMode = authorizationMode; } public async ensureAuthorized(operation: string, actionTypeId?: string) { @@ -87,6 +87,9 @@ export class ActionsAuthorization { } private isOperationExemptDueToLegacyRbac(operation: string) { - return this.shouldUseLegacyRbac && LEGACY_RBAC_EXEMPT_OPERATIONS.has(operation); + return ( + this.authorizationMode === AuthorizationMode.Legacy && + LEGACY_RBAC_EXEMPT_OPERATIONS.has(operation) + ); } } diff --git a/x-pack/plugins/actions/server/authorization/should_legacy_rbac_apply_by_source.test.ts b/x-pack/plugins/actions/server/authorization/get_authorization_mode_by_source.test.ts similarity index 67% rename from x-pack/plugins/actions/server/authorization/should_legacy_rbac_apply_by_source.test.ts rename to x-pack/plugins/actions/server/authorization/get_authorization_mode_by_source.test.ts index 03062994adeb6a..4980c476e60ea1 100644 --- a/x-pack/plugins/actions/server/authorization/should_legacy_rbac_apply_by_source.test.ts +++ b/x-pack/plugins/actions/server/authorization/get_authorization_mode_by_source.test.ts @@ -3,88 +3,93 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { shouldLegacyRbacApplyBySource } from './should_legacy_rbac_apply_by_source'; +import { + getAuthorizationModeBySource, + AuthorizationMode, +} from './get_authorization_mode_by_source'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import uuid from 'uuid'; import { asSavedObjectExecutionSource } from '../lib'; const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); -describe(`#shouldLegacyRbacApplyBySource`, () => { - test('should return false if no source is provided', async () => { - expect(await shouldLegacyRbacApplyBySource(unsecuredSavedObjectsClient)).toEqual(false); +describe(`#getAuthorizationModeBySource`, () => { + test('should return RBAC if no source is provided', async () => { + expect(await getAuthorizationModeBySource(unsecuredSavedObjectsClient)).toEqual( + AuthorizationMode.RBAC + ); }); - test('should return false if source is not an alert', async () => { + test('should return RBAC if source is not an alert', async () => { expect( - await shouldLegacyRbacApplyBySource( + await getAuthorizationModeBySource( unsecuredSavedObjectsClient, asSavedObjectExecutionSource({ type: 'action', id: uuid.v4(), }) ) - ).toEqual(false); + ).toEqual(AuthorizationMode.RBAC); }); - test('should return false if source alert is not marked as legacy', async () => { + test('should return RBAC if source alert is not marked as legacy', async () => { const id = uuid.v4(); unsecuredSavedObjectsClient.get.mockResolvedValue(mockAlert({ id })); expect( - await shouldLegacyRbacApplyBySource( + await getAuthorizationModeBySource( unsecuredSavedObjectsClient, asSavedObjectExecutionSource({ type: 'alert', id, }) ) - ).toEqual(false); + ).toEqual(AuthorizationMode.RBAC); }); - test('should return true if source alert is marked as legacy', async () => { + test('should return Legacy if source alert is marked as legacy', async () => { const id = uuid.v4(); unsecuredSavedObjectsClient.get.mockResolvedValue( mockAlert({ id, attributes: { meta: { versionApiKeyLastmodified: 'pre-7.10.0' } } }) ); expect( - await shouldLegacyRbacApplyBySource( + await getAuthorizationModeBySource( unsecuredSavedObjectsClient, asSavedObjectExecutionSource({ type: 'alert', id, }) ) - ).toEqual(true); + ).toEqual(AuthorizationMode.Legacy); }); - test('should return false if source alert is marked as modern', async () => { + test('should return RBAC if source alert is marked as modern', async () => { const id = uuid.v4(); unsecuredSavedObjectsClient.get.mockResolvedValue( mockAlert({ id, attributes: { meta: { versionApiKeyLastmodified: '7.10.0' } } }) ); expect( - await shouldLegacyRbacApplyBySource( + await getAuthorizationModeBySource( unsecuredSavedObjectsClient, asSavedObjectExecutionSource({ type: 'alert', id, }) ) - ).toEqual(false); + ).toEqual(AuthorizationMode.RBAC); }); - test('should return false if source alert is marked with a last modified version', async () => { + test('should return RBAC if source alert doesnt have a last modified version', async () => { const id = uuid.v4(); unsecuredSavedObjectsClient.get.mockResolvedValue(mockAlert({ id, attributes: { meta: {} } })); expect( - await shouldLegacyRbacApplyBySource( + await getAuthorizationModeBySource( unsecuredSavedObjectsClient, asSavedObjectExecutionSource({ type: 'alert', id, }) ) - ).toEqual(false); + ).toEqual(AuthorizationMode.RBAC); }); }); diff --git a/x-pack/plugins/actions/server/authorization/should_legacy_rbac_apply_by_source.ts b/x-pack/plugins/actions/server/authorization/get_authorization_mode_by_source.ts similarity index 55% rename from x-pack/plugins/actions/server/authorization/should_legacy_rbac_apply_by_source.ts rename to x-pack/plugins/actions/server/authorization/get_authorization_mode_by_source.ts index 06d5776003ede4..85d646c75defa5 100644 --- a/x-pack/plugins/actions/server/authorization/should_legacy_rbac_apply_by_source.ts +++ b/x-pack/plugins/actions/server/authorization/get_authorization_mode_by_source.ts @@ -10,18 +10,24 @@ import { ALERT_SAVED_OBJECT_TYPE } from '../saved_objects'; const LEGACY_VERSION = 'pre-7.10.0'; -export async function shouldLegacyRbacApplyBySource( +export enum AuthorizationMode { + Legacy, + RBAC, +} + +export async function getAuthorizationModeBySource( unsecuredSavedObjectsClient: SavedObjectsClientContract, executionSource?: ActionExecutionSource -): Promise { +): Promise { return isSavedObjectExecutionSource(executionSource) && - executionSource?.source?.type === ALERT_SAVED_OBJECT_TYPE - ? ( - await unsecuredSavedObjectsClient.get<{ - meta?: { - versionApiKeyLastmodified?: string; - }; - }>(ALERT_SAVED_OBJECT_TYPE, executionSource.source.id) - ).attributes.meta?.versionApiKeyLastmodified === LEGACY_VERSION - : false; + executionSource?.source?.type === ALERT_SAVED_OBJECT_TYPE && + ( + await unsecuredSavedObjectsClient.get<{ + meta?: { + versionApiKeyLastmodified?: string; + }; + }>(ALERT_SAVED_OBJECT_TYPE, executionSource.source.id) + ).attributes.meta?.versionApiKeyLastmodified === LEGACY_VERSION + ? AuthorizationMode.Legacy + : AuthorizationMode.RBAC; } diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index a607dc0de0bda4..73434d5c1eaa2e 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -27,7 +27,7 @@ export interface ActionExecutorContext { getServices: GetServicesFunction; getActionsClientWithRequest: ( request: KibanaRequest, - executionSource?: ActionExecutionSource + authorizationContext?: ActionExecutionSource ) => Promise>; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; actionTypeRegistry: ActionTypeRegistryContract; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 97cefafad4385c..dca1114f0ae44c 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -71,7 +71,10 @@ import { ACTIONS_FEATURE } from './feature'; import { ActionsAuthorization } from './authorization/actions_authorization'; import { ActionsAuthorizationAuditLogger } from './authorization/audit_logger'; import { ActionExecutionSource } from './lib/action_execution_source'; -import { shouldLegacyRbacApplyBySource } from './authorization/should_legacy_rbac_apply_by_source'; +import { + getAuthorizationModeBySource, + AuthorizationMode, +} from './authorization/get_authorization_mode_by_source'; const EVENT_LOG_PROVIDER = 'actions'; export const EVENT_LOG_ACTIONS = { @@ -281,7 +284,7 @@ export class ActionsPlugin implements Plugin, Plugi const getActionsClientWithRequest = async ( request: KibanaRequest, - source?: ActionExecutionSource + authorizationContext?: ActionExecutionSource ) => { if (isESOUsingEphemeralEncryptionKey === true) { throw new Error( @@ -303,7 +306,7 @@ export class ActionsPlugin implements Plugin, Plugi request, authorization: instantiateAuthorization( request, - await shouldLegacyRbacApplyBySource(unsecuredSavedObjectsClient, source) + await getAuthorizationModeBySource(unsecuredSavedObjectsClient, authorizationContext) ), actionExecutor: actionExecutor!, executionEnqueuer: createExecutionEnqueuerFunction({ @@ -316,7 +319,8 @@ export class ActionsPlugin implements Plugin, Plugi }; // Ensure the public API cannot be used to circumvent authorization - // using our legacy exemption mechanism + // using our legacy exemption mechanism by passing in a legacy SO + // as authorizationContext which would then set a Legacy AuthorizationMode const secureGetActionsClientWithRequest = (request: KibanaRequest) => getActionsClientWithRequest(request); @@ -389,11 +393,11 @@ export class ActionsPlugin implements Plugin, Plugi private instantiateAuthorization = ( request: KibanaRequest, - shouldUseLegacyRbac: boolean = false + authorizationMode?: AuthorizationMode ) => { return new ActionsAuthorization({ request, - shouldUseLegacyRbac, + authorizationMode, authorization: this.security?.authz, authentication: this.security?.authc, auditLogger: new ActionsAuthorizationAuditLogger( diff --git a/x-pack/plugins/alerts/server/alerts_client.test.ts b/x-pack/plugins/alerts/server/alerts_client.test.ts index 4b5af942024c0c..a6cffb02848159 100644 --- a/x-pack/plugins/alerts/server/alerts_client.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client.test.ts @@ -220,7 +220,7 @@ describe('create()', () => { params: {}, ownerId: null, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -270,27 +270,33 @@ describe('create()', () => { test('creates an alert', async () => { const data = getMockData(); + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + muteAll: false, + mutedInstanceIds: [], + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', - attributes: { - alertTypeId: '123', - schedule: { interval: '10s' }, - params: { - bar: true, - }, - createdAt: '2019-02-12T21:01:22.479Z', - actions: [ - { - group: 'default', - actionRef: 'action_0', - actionTypeId: 'test', - params: { - foo: true, - }, - }, - ], - }, + attributes: createdAttributes, references: [ { name: 'action_0', @@ -312,11 +318,11 @@ describe('create()', () => { params: {}, ownerId: null, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { - actions: [], + ...createdAttributes, scheduledTaskId: 'task-123', }, references: [ @@ -342,8 +348,14 @@ describe('create()', () => { }, ], "alertTypeId": "123", + "consumer": "bar", "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, "id": "1", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", "params": Object { "bar": true, }, @@ -351,7 +363,12 @@ describe('create()', () => { "interval": "10s", }, "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": null, "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", } `); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); @@ -531,7 +548,7 @@ describe('create()', () => { params: {}, ownerId: null, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -965,7 +982,7 @@ describe('create()', () => { params: {}, ownerId: null, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -1081,7 +1098,7 @@ describe('create()', () => { params: {}, ownerId: null, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -1175,6 +1192,16 @@ describe('enable()', () => { alertsClientParams.createAPIKey.mockResolvedValue({ apiKeysEnabled: false, }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + ...existingAlert, + attributes: { + ...existingAlert.attributes, + enabled: true, + apiKey: null, + apiKeyOwner: null, + updatedBy: 'elastic', + }, + }); taskManager.schedule.mockResolvedValue({ id: 'task-123', scheduledAt: new Date(), @@ -1233,6 +1260,17 @@ describe('enable()', () => { }); test('enables an alert', async () => { + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + ...existingAlert, + attributes: { + ...existingAlert.attributes, + enabled: true, + apiKey: null, + apiKeyOwner: null, + updatedBy: 'elastic', + }, + }); + await alertsClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { @@ -1317,7 +1355,7 @@ describe('enable()', () => { await alertsClient.enable({ id: '1' }); expect(alertsClientParams.getUserName).not.toHaveBeenCalled(); expect(alertsClientParams.createAPIKey).not.toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(taskManager.schedule).not.toHaveBeenCalled(); }); @@ -1384,6 +1422,7 @@ describe('enable()', () => { }); test('throws error when failing to update the first time', async () => { + unsecuredSavedObjectsClient.update.mockReset(); unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Fail to update')); await expect(alertsClient.enable({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( @@ -1396,6 +1435,7 @@ describe('enable()', () => { }); test('throws error when failing to update the second time', async () => { + unsecuredSavedObjectsClient.update.mockReset(); unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ ...existingAlert, attributes: { @@ -1460,6 +1500,8 @@ describe('disable()', () => { ...existingAlert.attributes, apiKey: Buffer.from('123:abc').toString('base64'), }, + version: '123', + references: [], }; beforeEach(() => { @@ -1501,13 +1543,13 @@ describe('disable()', () => { consumer: 'myApp', schedule: { interval: '10s' }, alertTypeId: 'myType', - apiKey: null, - apiKeyOwner: null, enabled: false, meta: { versionApiKeyLastmodified: kibanaVersion, }, scheduledTaskId: null, + apiKey: null, + apiKeyOwner: null, updatedBy: 'elastic', actions: [ { @@ -1544,13 +1586,13 @@ describe('disable()', () => { consumer: 'myApp', schedule: { interval: '10s' }, alertTypeId: 'myType', - apiKey: null, - apiKeyOwner: null, enabled: false, meta: { versionApiKeyLastmodified: kibanaVersion, }, scheduledTaskId: null, + apiKey: null, + apiKeyOwner: null, updatedBy: 'elastic', actions: [ { @@ -1739,6 +1781,7 @@ describe('unmuteAll()', () => { muteAll: true, }, references: [], + version: '123', }); await alertsClient.unmuteAll({ id: '1' }); @@ -1829,7 +1872,9 @@ describe('muteInstance()', () => { mutedInstanceIds: ['2'], updatedBy: 'elastic', }, - { version: '123' } + { + version: '123', + } ); }); @@ -1850,7 +1895,7 @@ describe('muteInstance()', () => { }); await alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); test('skips muting when alert is muted', async () => { @@ -1871,7 +1916,7 @@ describe('muteInstance()', () => { }); await alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); describe('authorization', () => { @@ -1983,7 +2028,7 @@ describe('unmuteInstance()', () => { }); await alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); test('skips unmuting when alert is muted', async () => { @@ -2004,7 +2049,7 @@ describe('unmuteInstance()', () => { }); await alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); describe('authorization', () => { @@ -3052,7 +3097,7 @@ describe('update()', () => { }); test('updates given parameters', async () => { - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -3189,11 +3234,10 @@ describe('update()', () => { namespace: 'default', }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toHaveLength(4); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][1]).toEqual('1'); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` Object { "actions": Array [ Object { @@ -3244,8 +3288,10 @@ describe('update()', () => { "updatedBy": "elastic", } `); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` Object { + "id": "1", + "overwrite": true, "references": Array [ Object { "id": "1", @@ -3286,7 +3332,7 @@ describe('update()', () => { apiKeysEnabled: true, result: { id: '123', name: '123', api_key: 'abc' }, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -3365,11 +3411,10 @@ describe('update()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toHaveLength(4); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][1]).toEqual('1'); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` Object { "actions": Array [ Object { @@ -3404,18 +3449,20 @@ describe('update()', () => { "updatedBy": "elastic", } `); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` - Object { - "references": Array [ - Object { - "id": "1", - "name": "action_0", - "type": "action", - }, - ], - "version": "123", - } - `); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "id": "1", + "overwrite": true, + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + "version": "123", + } + `); }); it(`doesn't call the createAPIKey function when alert is disabled`, async () => { @@ -3439,7 +3486,7 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -3519,11 +3566,10 @@ describe('update()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toHaveLength(4); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][1]).toEqual('1'); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` Object { "actions": Array [ Object { @@ -3558,18 +3604,20 @@ describe('update()', () => { "updatedBy": "elastic", } `); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` - Object { - "references": Array [ - Object { - "id": "1", - "name": "action_0", - "type": "action", - }, - ], - "version": "123", - } - `); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "id": "1", + "overwrite": true, + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + "version": "123", + } + `); }); it('should validate params', async () => { @@ -3627,7 +3675,7 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -3686,7 +3734,7 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -3765,7 +3813,7 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -3919,7 +3967,7 @@ describe('update()', () => { params: {}, ownerId: null, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: alertId, type: 'alert', attributes: { @@ -4091,7 +4139,7 @@ describe('update()', () => { describe('authorization', () => { beforeEach(() => { - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { diff --git a/x-pack/plugins/alerts/server/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client.ts index 0a08ca848c73d7..671b1d6411d7fc 100644 --- a/x-pack/plugins/alerts/server/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client.ts @@ -251,7 +251,7 @@ export class AlertsClient { } throw e; } - await this.unsecuredSavedObjectsClient.update('alert', createdAlert.id, { + await this.unsecuredSavedObjectsClient.update('alert', createdAlert.id, { scheduledTaskId: scheduledTask.id, }); createdAlert.attributes.scheduledTaskId = scheduledTask.id; @@ -488,9 +488,8 @@ export class AlertsClient { : null; const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username); - const updatedObject = await this.unsecuredSavedObjectsClient.update( + const updatedObject = await this.unsecuredSavedObjectsClient.create( 'alert', - id, this.updateMeta({ ...attributes, ...data, @@ -500,6 +499,8 @@ export class AlertsClient { updatedBy: username, }), { + id, + overwrite: true, version, references, } @@ -798,6 +799,7 @@ export class AlertsClient { 'alert', alertId ); + await this.authorization.ensureAuthorized( attributes.alertTypeId, attributes.consumer, @@ -809,7 +811,7 @@ export class AlertsClient { const mutedInstanceIds = attributes.mutedInstanceIds || []; if (!attributes.muteAll && mutedInstanceIds.includes(alertInstanceId)) { - await this.unsecuredSavedObjectsClient.update( + await this.unsecuredSavedObjectsClient.update( 'alert', alertId, this.updateMeta({ diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts index f873b0178ece99..aca447b6adedd7 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts @@ -19,6 +19,7 @@ import { AlertInstanceContext, AlertType, AlertTypeParams, + RawAlert, } from '../types'; interface CreateExecutionHandlerOptions { @@ -28,7 +29,7 @@ interface CreateExecutionHandlerOptions { actionsPlugin: ActionsPluginStartContract; actions: AlertAction[]; spaceId: string; - apiKey: string | null; + apiKey: RawAlert['apiKey']; alertType: AlertType; logger: Logger; eventLogger: IEventLogger; @@ -99,7 +100,7 @@ export function createExecutionHandler({ id: action.id, params: action.params, spaceId, - apiKey, + apiKey: apiKey ?? null, source: asSavedObjectExecutionSource({ id: alertId, type: 'alert', diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 4c16d23b485b5a..5be684eca4651a 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -73,7 +73,7 @@ export class TaskRunner { return apiKey; } - private getFakeKibanaRequest(spaceId: string, apiKey: string | null) { + private getFakeKibanaRequest(spaceId: string, apiKey: RawAlert['apiKey']) { const requestHeaders: Record = {}; if (apiKey) { @@ -98,7 +98,7 @@ export class TaskRunner { private getServicesWithSpaceLevelPermissions( spaceId: string, - apiKey: string | null + apiKey: RawAlert['apiKey'] ): [Services, PublicMethodsOf] { const request = this.getFakeKibanaRequest(spaceId, apiKey); return [this.context.getServices(request), this.context.getAlertsClientWithRequest(request)]; @@ -109,7 +109,7 @@ export class TaskRunner { alertName: string, tags: string[] | undefined, spaceId: string, - apiKey: string | null, + apiKey: RawAlert['apiKey'], actions: Alert['actions'], alertParams: RawAlert['params'] ) { @@ -250,7 +250,11 @@ export class TaskRunner { }; } - async validateAndExecuteAlert(services: Services, apiKey: string | null, alert: SanitizedAlert) { + async validateAndExecuteAlert( + services: Services, + apiKey: RawAlert['apiKey'], + alert: SanitizedAlert + ) { const { params: { alertId, spaceId }, } = this.taskInstance; diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index 15a3c642faf324..a234226d180347 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -5,6 +5,8 @@ */ import { i18n } from '@kbn/i18n'; +import { ValuesType } from 'utility-types'; +import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from '../../ml/common'; export enum AlertType { ErrorCount = 'apm.error_rate', // ErrorRate was renamed to ErrorCount but the key is kept as `error_rate` for backwards-compat. @@ -55,6 +57,41 @@ export const ALERT_TYPES_CONFIG = { }, }; +export const ANOMALY_ALERT_SEVERITY_TYPES = [ + { + type: ANOMALY_SEVERITY.CRITICAL, + label: i18n.translate('xpack.apm.alerts.anomalySeverity.criticalLabel', { + defaultMessage: 'critical', + }), + threshold: ANOMALY_THRESHOLD.CRITICAL, + }, + { + type: ANOMALY_SEVERITY.MAJOR, + label: i18n.translate('xpack.apm.alerts.anomalySeverity.majorLabel', { + defaultMessage: 'major', + }), + threshold: ANOMALY_THRESHOLD.MAJOR, + }, + { + type: ANOMALY_SEVERITY.MINOR, + label: i18n.translate('xpack.apm.alerts.anomalySeverity.minor', { + defaultMessage: 'minor', + }), + threshold: ANOMALY_THRESHOLD.MINOR, + }, + { + type: ANOMALY_SEVERITY.WARNING, + label: i18n.translate('xpack.apm.alerts.anomalySeverity.warningLabel', { + defaultMessage: 'warning', + }), + threshold: ANOMALY_THRESHOLD.WARNING, + }, +] as const; + +export type AnomalyAlertSeverityType = ValuesType< + typeof ANOMALY_ALERT_SEVERITY_TYPES +>['type']; + // Server side registrations // x-pack/plugins/apm/server/lib/alerts/.ts // x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts diff --git a/x-pack/plugins/apm/common/anomaly_detection.test.ts b/x-pack/plugins/apm/common/anomaly_detection.test.ts deleted file mode 100644 index 21963b5300f833..00000000000000 --- a/x-pack/plugins/apm/common/anomaly_detection.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getSeverity, Severity } from './anomaly_detection'; - -describe('getSeverity', () => { - describe('when score is undefined', () => { - it('returns undefined', () => { - expect(getSeverity(undefined)).toEqual(undefined); - }); - }); - - describe('when score < 25', () => { - it('returns warning', () => { - expect(getSeverity(10)).toEqual(Severity.warning); - }); - }); - - describe('when score is between 25 and 50', () => { - it('returns minor', () => { - expect(getSeverity(40)).toEqual(Severity.minor); - }); - }); - - describe('when score is between 50 and 75', () => { - it('returns major', () => { - expect(getSeverity(60)).toEqual(Severity.major); - }); - }); - - describe('when score is 75 or more', () => { - it('returns critical', () => { - expect(getSeverity(100)).toEqual(Severity.critical); - }); - }); -}); diff --git a/x-pack/plugins/apm/common/anomaly_detection.ts b/x-pack/plugins/apm/common/anomaly_detection.ts index 5d80ee63812673..dc5731e88083cd 100644 --- a/x-pack/plugins/apm/common/anomaly_detection.ts +++ b/x-pack/plugins/apm/common/anomaly_detection.ts @@ -5,89 +5,31 @@ */ import { i18n } from '@kbn/i18n'; -import { EuiTheme } from '../../../legacy/common/eui_styled_components'; +import { ANOMALY_SEVERITY } from '../../ml/common'; +import { + getSeverityType, + getSeverityColor as mlGetSeverityColor, +} from '../../ml/common'; +import { ServiceHealthStatus } from './service_health_status'; export interface ServiceAnomalyStats { transactionType?: string; anomalyScore?: number; actualValue?: number; jobId?: string; + healthStatus: ServiceHealthStatus; } -export enum Severity { - critical = 'critical', - major = 'major', - minor = 'minor', - warning = 'warning', -} - -// TODO: Replace with `getSeverity` from: -// https://github.com/elastic/kibana/blob/0f964f66916480f2de1f4b633e5afafc08cf62a0/x-pack/plugins/ml/common/util/anomaly_utils.ts#L129 -export function getSeverity(score?: number) { - if (typeof score !== 'number') { - return undefined; - } else if (score < 25) { - return Severity.warning; - } else if (score >= 25 && score < 50) { - return Severity.minor; - } else if (score >= 50 && score < 75) { - return Severity.major; - } else if (score >= 75) { - return Severity.critical; - } else { - return undefined; +export function getSeverity(score: number | undefined) { + if (score === undefined) { + return ANOMALY_SEVERITY.UNKNOWN; } -} -export function getSeverityColor(theme: EuiTheme, severity?: Severity) { - switch (severity) { - case Severity.warning: - return theme.eui.euiColorVis0; - case Severity.minor: - case Severity.major: - return theme.eui.euiColorVis5; - case Severity.critical: - return theme.eui.euiColorVis9; - default: - return; - } + return getSeverityType(score); } -export function getSeverityLabel(severity?: Severity) { - switch (severity) { - case Severity.critical: - return i18n.translate( - 'xpack.apm.servicesTable.serviceHealthStatus.critical', - { - defaultMessage: 'Critical', - } - ); - - case Severity.major: - case Severity.minor: - return i18n.translate( - 'xpack.apm.servicesTable.serviceHealthStatus.warning', - { - defaultMessage: 'Warning', - } - ); - - case Severity.warning: - return i18n.translate( - 'xpack.apm.servicesTable.serviceHealthStatus.healthy', - { - defaultMessage: 'Healthy', - } - ); - - default: - return i18n.translate( - 'xpack.apm.servicesTable.serviceHealthStatus.unknown', - { - defaultMessage: 'Unknown', - } - ); - } +export function getSeverityColor(score: number) { + return mlGetSeverityColor(score); } export const ML_ERRORS = { diff --git a/x-pack/plugins/apm/common/service_health_status.ts b/x-pack/plugins/apm/common/service_health_status.ts new file mode 100644 index 00000000000000..468f06ab97af8c --- /dev/null +++ b/x-pack/plugins/apm/common/service_health_status.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ANOMALY_SEVERITY } from '../../ml/common'; + +import { EuiTheme } from '../../../legacy/common/eui_styled_components'; + +export enum ServiceHealthStatus { + healthy = 'healthy', + critical = 'critical', + warning = 'warning', + unknown = 'unknown', +} + +export function getServiceHealthStatus({ + severity, +}: { + severity: ANOMALY_SEVERITY; +}) { + switch (severity) { + case ANOMALY_SEVERITY.CRITICAL: + case ANOMALY_SEVERITY.MAJOR: + return ServiceHealthStatus.critical; + + case ANOMALY_SEVERITY.MINOR: + case ANOMALY_SEVERITY.WARNING: + return ServiceHealthStatus.warning; + + case ANOMALY_SEVERITY.LOW: + return ServiceHealthStatus.healthy; + + case ANOMALY_SEVERITY.UNKNOWN: + return ServiceHealthStatus.unknown; + } +} + +export function getServiceHealthStatusColor( + theme: EuiTheme, + status: ServiceHealthStatus +) { + switch (status) { + case ServiceHealthStatus.healthy: + return theme.eui.euiColorVis0; + case ServiceHealthStatus.warning: + return theme.eui.euiColorVis5; + case ServiceHealthStatus.critical: + return theme.eui.euiColorVis9; + case ServiceHealthStatus.unknown: + return theme.eui.euiColorMediumShade; + } +} + +export function getServiceHealthStatusLabel(status: ServiceHealthStatus) { + switch (status) { + case ServiceHealthStatus.critical: + return i18n.translate('xpack.apm.serviceHealthStatus.critical', { + defaultMessage: 'Critical', + }); + + case ServiceHealthStatus.warning: + return i18n.translate('xpack.apm.serviceHealthStatus.warning', { + defaultMessage: 'Warning', + }); + + case ServiceHealthStatus.healthy: + return i18n.translate('xpack.apm.serviceHealthStatus.healthy', { + defaultMessage: 'Healthy', + }); + + case ServiceHealthStatus.unknown: + return i18n.translate('xpack.apm.serviceHealthStatus.unknown', { + defaultMessage: 'Unknown', + }); + } +} diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 8aa4417580337c..bdef0f9786a3f9 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -23,13 +23,19 @@ ], "server": true, "ui": true, - "configPath": ["xpack", "apm"], - "extraPublicDirs": ["public/style/variables"], + "configPath": [ + "xpack", + "apm" + ], + "extraPublicDirs": [ + "public/style/variables" + ], "requiredBundles": [ "kibanaReact", "kibanaUtils", "observability", "home", - "maps" + "maps", + "ml" ] } diff --git a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx index 5bddfc67200b14..468d08339431c3 100644 --- a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx +++ b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx @@ -5,105 +5,60 @@ */ import React from 'react'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; +import { getSeverityColor } from '../../../../common/anomaly_detection'; import { - getSeverityColor, - Severity, -} from '../../../../common/anomaly_detection'; -import { useTheme } from '../../../hooks/useTheme'; + AnomalyAlertSeverityType, + ANOMALY_ALERT_SEVERITY_TYPES, +} from '../../../../common/alert_types'; -type SeverityScore = 0 | 25 | 50 | 75; -const ANOMALY_SCORES: SeverityScore[] = [0, 25, 50, 75]; - -const anomalyScoreSeverityMap: { - [key in SeverityScore]: { label: string; severity: Severity }; -} = { - 0: { - label: i18n.translate('xpack.apm.alerts.anomalySeverity.warningLabel', { - defaultMessage: 'warning', - }), - severity: Severity.warning, - }, - 25: { - label: i18n.translate('xpack.apm.alerts.anomalySeverity.minorLabel', { - defaultMessage: 'minor', - }), - severity: Severity.minor, - }, - 50: { - label: i18n.translate('xpack.apm.alerts.anomalySeverity.majorLabel', { - defaultMessage: 'major', - }), - severity: Severity.major, - }, - 75: { - label: i18n.translate('xpack.apm.alerts.anomalySeverity.criticalLabel', { - defaultMessage: 'critical', - }), - severity: Severity.critical, - }, -}; - -export function AnomalySeverity({ - severityScore, -}: { - severityScore: SeverityScore; -}) { - const theme = useTheme(); - const { label, severity } = anomalyScoreSeverityMap[severityScore]; - const defaultColor = theme.eui.euiColorMediumShade; - const color = getSeverityColor(theme, severity) || defaultColor; +export function AnomalySeverity({ type }: { type: AnomalyAlertSeverityType }) { + const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find( + (option) => option.type === type + )!; return ( - - {label} + + {selectedOption.label} ); } -const getOption = (value: SeverityScore) => { - return { - value: value.toString(10), - inputDisplay: , - dropdownDisplay: ( - <> - - - -

- -

-
- - ), - }; -}; - interface Props { - onChange: (value: SeverityScore) => void; - value: SeverityScore; + onChange: (value: AnomalyAlertSeverityType) => void; + value: AnomalyAlertSeverityType; } export function SelectAnomalySeverity({ onChange, value }: Props) { - const options = ANOMALY_SCORES.map((anomalyScore) => getOption(anomalyScore)); - return ( { - const selectedAnomalyScore = parseInt( - selectedValue, - 10 - ) as SeverityScore; - onChange(selectedAnomalyScore); + options={ANOMALY_ALERT_SEVERITY_TYPES.map((option) => ({ + value: option.type, + inputDisplay: , + dropdownDisplay: ( + <> + + + +

+ +

+
+ + ), + }))} + valueOfSelected={value} + onChange={(selectedValue: AnomalyAlertSeverityType) => { + onChange(selectedValue); }} /> ); diff --git a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx index fb4cda56fce048..ca1f55e9d391a7 100644 --- a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx @@ -7,6 +7,7 @@ import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { ANOMALY_SEVERITY } from '../../../../../ml/common'; import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; import { useEnvironments } from '../../../hooks/useEnvironments'; import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; @@ -34,7 +35,11 @@ interface Params { serviceName: string; transactionType: string; environment: string; - anomalyScore: 0 | 25 | 50 | 75; + anomalySeverityType: + | ANOMALY_SEVERITY.CRITICAL + | ANOMALY_SEVERITY.MAJOR + | ANOMALY_SEVERITY.MINOR + | ANOMALY_SEVERITY.WARNING; } interface Props { @@ -67,7 +72,7 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) { transactionType, serviceName, environment: urlParams.environment || ENVIRONMENT_ALL.value, - anomalyScore: 75, + anomalySeverityType: ANOMALY_SEVERITY.CRITICAL, }; const params = { @@ -84,7 +89,7 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) { onChange={(e) => setAlertParams('environment', e.target.value)} />, } + value={} title={i18n.translate( 'xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity', { @@ -93,9 +98,9 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) { )} > { - setAlertParams('anomalyScore', value); + setAlertParams('anomalySeverityType', value); }} /> , diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx index 5699d0b56219b7..c1192f5f182748 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx @@ -14,6 +14,10 @@ import { EuiIconTip, EuiHealth, } from '@elastic/eui'; +import { + getServiceHealthStatus, + getServiceHealthStatusColor, +} from '../../../../../common/service_health_status'; import { useTheme } from '../../../../hooks/useTheme'; import { fontSize, px } from '../../../../style/variables'; import { asInteger, asDuration } from '../../../../utils/formatters'; @@ -22,7 +26,6 @@ import { popoverWidth } from '../cytoscapeOptions'; import { TRANSACTION_REQUEST } from '../../../../../common/transaction_types'; import { getSeverity, - getSeverityColor, ServiceAnomalyStats, } from '../../../../../common/anomaly_detection'; @@ -59,13 +62,15 @@ export function AnomalyDetection({ serviceName, serviceAnomalyStats }: Props) { const theme = useTheme(); const anomalyScore = serviceAnomalyStats?.anomalyScore; - const anomalySeverity = getSeverity(anomalyScore); + const severity = getSeverity(anomalyScore); const actualValue = serviceAnomalyStats?.actualValue; const mlJobId = serviceAnomalyStats?.jobId; const transactionType = serviceAnomalyStats?.transactionType ?? TRANSACTION_REQUEST; const hasAnomalyDetectionScore = anomalyScore !== undefined; + const healthStatus = getServiceHealthStatus({ severity }); + return ( <>
@@ -81,7 +86,9 @@ export function AnomalyDetection({ serviceName, serviceAnomalyStats }: Props) { - + {ANOMALY_DETECTION_SCORE_METRIC} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 1ac7157cc2aad4..61ac9bd7cd54c8 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -5,26 +5,26 @@ */ import cytoscape from 'cytoscape'; import { CSSProperties } from 'react'; +import { + getServiceHealthStatusColor, + ServiceHealthStatus, +} from '../../../../common/service_health_status'; import { SERVICE_NAME, SPAN_DESTINATION_SERVICE_RESOURCE, } from '../../../../common/elasticsearch_fieldnames'; import { EuiTheme } from '../../../../../observability/public'; import { defaultIcon, iconForNode } from './icons'; -import { - getSeverity, - getSeverityColor, - ServiceAnomalyStats, - Severity, -} from '../../../../common/anomaly_detection'; +import { ServiceAnomalyStats } from '../../../../common/anomaly_detection'; export const popoverWidth = 280; -function getNodeSeverity(el: cytoscape.NodeSingular) { +function getServiceAnomalyStats(el: cytoscape.NodeSingular) { const serviceAnomalyStats: ServiceAnomalyStats | undefined = el.data( 'serviceAnomalyStats' ); - return getSeverity(serviceAnomalyStats?.anomalyScore); + + return serviceAnomalyStats; } function getBorderColorFn( @@ -32,10 +32,11 @@ function getBorderColorFn( ): cytoscape.Css.MapperFunction { return (el: cytoscape.NodeSingular) => { const hasAnomalyDetectionJob = el.data('serviceAnomalyStats') !== undefined; - const nodeSeverity = getNodeSeverity(el); + const anomalyStats = getServiceAnomalyStats(el); if (hasAnomalyDetectionJob) { - return ( - getSeverityColor(theme, nodeSeverity) || theme.eui.euiColorMediumShade + return getServiceHealthStatusColor( + theme, + anomalyStats?.healthStatus ?? ServiceHealthStatus.unknown ); } if (el.hasClass('primary') || el.selected()) { @@ -49,8 +50,8 @@ const getBorderStyle: cytoscape.Css.MapperFunction< cytoscape.NodeSingular, cytoscape.Css.LineStyle > = (el: cytoscape.NodeSingular) => { - const nodeSeverity = getNodeSeverity(el); - if (nodeSeverity === Severity.critical) { + const status = getServiceAnomalyStats(el)?.healthStatus; + if (status === ServiceHealthStatus.critical) { return 'double'; } else { return 'solid'; @@ -58,11 +59,11 @@ const getBorderStyle: cytoscape.Css.MapperFunction< }; function getBorderWidth(el: cytoscape.NodeSingular) { - const nodeSeverity = getNodeSeverity(el); + const status = getServiceAnomalyStats(el)?.healthStatus; - if (nodeSeverity === Severity.minor || nodeSeverity === Severity.major) { + if (status === ServiceHealthStatus.warning) { return 4; - } else if (nodeSeverity === Severity.critical) { + } else if (status === ServiceHealthStatus.critical) { return 8; } else { return 4; diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/HealthBadge.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/HealthBadge.tsx index 94353080bc7d5b..c6be0a352ef666 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/HealthBadge.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/HealthBadge.tsx @@ -6,20 +6,22 @@ import React from 'react'; import { EuiBadge } from '@elastic/eui'; import { - getSeverityColor, - getSeverityLabel, - Severity, -} from '../../../../../common/anomaly_detection'; + getServiceHealthStatusColor, + getServiceHealthStatusLabel, + ServiceHealthStatus, +} from '../../../../../common/service_health_status'; import { useTheme } from '../../../../hooks/useTheme'; -export function HealthBadge({ severity }: { severity?: Severity }) { +export function HealthBadge({ + healthStatus, +}: { + healthStatus: ServiceHealthStatus; +}) { const theme = useTheme(); - const unknownColor = theme.eui.euiColorLightShade; - return ( - - {getSeverityLabel(severity)} + + {getServiceHealthStatusLabel(healthStatus)} ); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js index 519d74827097b4..7c306c16cca1f6 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js @@ -9,6 +9,7 @@ import { shallow } from 'enzyme'; import { ServiceList, SERVICE_COLUMNS } from '../index'; import props from './props.json'; import { mockMoment } from '../../../../../utils/testHelpers'; +import { ServiceHealthStatus } from '../../../../../../common/service_health_status'; describe('ServiceOverview -> List', () => { beforeAll(() => { @@ -52,25 +53,28 @@ describe('ServiceOverview -> List', () => { describe('without ML data', () => { it('does not render health column', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); const columns = wrapper.props().columns; - expect(columns[0].field).not.toBe('severity'); + expect(columns[0].field).not.toBe('healthStatus'); }); }); describe('with ML data', () => { it('renders health column', () => { const wrapper = shallow( - + ({ + ...item, + healthStatus: ServiceHealthStatus.warning, + }))} + /> ); const columns = wrapper.props().columns; - expect(columns[0].field).toBe('severity'); + expect(columns[0].field).toBe('healthStatus'); }); }); }); diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap index da3f6ae89940ad..e6a9823f3ee28a 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap @@ -1,6 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ServiceOverview -> List renders columns correctly 1`] = ``; +exports[`ServiceOverview -> List renders columns correctly 1`] = ` + +`; exports[`ServiceOverview -> List renders empty state 1`] = ` List renders empty state 1`] = ` } initialPageSize={50} initialSortDirection="desc" - initialSortField="severity" + initialSortField="healthStatus" items={Array []} sortFn={[Function]} /> @@ -106,7 +110,7 @@ exports[`ServiceOverview -> List renders with data 1`] = ` } initialPageSize={50} initialSortDirection="desc" - initialSortField="severity" + initialSortField="healthStatus" items={ Array [ Object { diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx index ce256137481cbf..4c7c0824a7c497 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx @@ -10,6 +10,7 @@ import React from 'react'; import styled from 'styled-components'; import { ValuesType } from 'utility-types'; import { orderBy } from 'lodash'; +import { ServiceHealthStatus } from '../../../../../common/service_health_status'; import { asPercent } from '../../../../../common/utils/formatters'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ServiceListAPIResponse } from '../../../../../server/lib/services/get_services'; @@ -20,14 +21,12 @@ import { ManagedTable, ITableColumn } from '../../../shared/ManagedTable'; import { EnvironmentBadge } from '../../../shared/EnvironmentBadge'; import { TransactionOverviewLink } from '../../../shared/Links/apm/TransactionOverviewLink'; import { AgentIcon } from '../../../shared/AgentIcon'; -import { Severity } from '../../../../../common/anomaly_detection'; import { HealthBadge } from './HealthBadge'; import { ServiceListMetric } from './ServiceListMetric'; interface Props { items: ServiceListAPIResponse['items']; noItemsMessage?: React.ReactNode; - displayHealthStatus: boolean; } type ServiceListItem = ValuesType; @@ -53,14 +52,18 @@ const AppLink = styled(TransactionOverviewLink)` export const SERVICE_COLUMNS: Array> = [ { - field: 'severity', + field: 'healthStatus', name: i18n.translate('xpack.apm.servicesTable.healthColumnLabel', { defaultMessage: 'Health', }), width: px(unit * 6), sortable: true, - render: (_, { severity }) => { - return ; + render: (_, { healthStatus }) => { + return ( + + ); }, }, { @@ -172,40 +175,38 @@ export const SERVICE_COLUMNS: Array> = [ }, ]; -const SEVERITY_ORDER = [ - Severity.warning, - Severity.minor, - Severity.major, - Severity.critical, +const SERVICE_HEALTH_STATUS_ORDER = [ + ServiceHealthStatus.unknown, + ServiceHealthStatus.healthy, + ServiceHealthStatus.warning, + ServiceHealthStatus.critical, ]; -export function ServiceList({ - items, - displayHealthStatus, - noItemsMessage, -}: Props) { +export function ServiceList({ items, noItemsMessage }: Props) { + const displayHealthStatus = items.some((item) => 'healthStatus' in item); + const columns = displayHealthStatus ? SERVICE_COLUMNS - : SERVICE_COLUMNS.filter((column) => column.field !== 'severity'); + : SERVICE_COLUMNS.filter((column) => column.field !== 'healthStatus'); return ( { - // For severity, sort items by severity first, then by TPM + // For healthStatus, sort items by healthStatus first, then by TPM - return sortField === 'severity' + return sortField === 'healthStatus' ? orderBy( itemsToSort, [ (item) => { - return item.severity - ? SEVERITY_ORDER.indexOf(item.severity) + return item.healthStatus + ? SERVICE_HEALTH_STATUS_ORDER.indexOf(item.healthStatus) : -1; }, (item) => item.transactionsPerMinute?.value ?? 0, diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx index e4ba1e36378d96..d8c8f25616560f 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx @@ -10,6 +10,7 @@ import { merge } from 'lodash'; import React, { FunctionComponent, ReactChild } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; +import { ServiceHealthStatus } from '../../../../../common/service_health_status'; import { ServiceOverview } from '..'; import { EuiThemeProvider } from '../../../../../../observability/public'; import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; @@ -114,7 +115,7 @@ describe('Service Overview -> View', () => { errorsPerMinute: 200, avgResponseTime: 300, environments: ['test', 'dev'], - severity: 1, + healthStatus: ServiceHealthStatus.warning, }, { serviceName: 'My Go Service', @@ -123,7 +124,7 @@ describe('Service Overview -> View', () => { errorsPerMinute: 500, avgResponseTime: 600, environments: [], - severity: 10, + severity: ServiceHealthStatus.healthy, }, ], }); @@ -252,7 +253,7 @@ describe('Service Overview -> View', () => { errorsPerMinute: 200, avgResponseTime: 300, environments: ['test', 'dev'], - severity: 1, + healthStatus: ServiceHealthStatus.warning, }, ], }); diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap index b56f7d68202747..dfc0cc8637ff10 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap @@ -153,8 +153,8 @@ NodeList [ > - Unknown + Warning @@ -435,7 +435,7 @@ NodeList [ > 'severity' in item); - return ( <> @@ -134,7 +132,6 @@ export function ServiceOverview() { errorRate.y)); return ( @@ -70,7 +72,7 @@ export function ErroneousTransactionsRateChart() { option.type === alertParams.anomalySeverityType + ); + + if (!selectedOption) { + throw new Error( + `Anomaly alert severity type ${alertParams.anomalySeverityType} is not supported.` + ); + } + + const threshold = selectedOption.threshold; + if (mlJobIds.length === 0) { return {}; } @@ -96,7 +118,7 @@ export function registerTransactionDurationAnomalyAlertType({ { range: { record_score: { - gte: alertParams.anomalyScore, + gte: threshold, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts index ed8ae923e6e6c8..da087b4c1911a7 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import Boom from 'boom'; +import { getServiceHealthStatus } from '../../../common/service_health_status'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { PromiseReturnType } from '../../../typings/common'; import { @@ -12,6 +13,7 @@ import { } from '../../../common/transaction_types'; import { ServiceAnomalyStats, + getSeverity, ML_ERRORS, } from '../../../common/anomaly_detection'; import { getMlJobsWithAPMGroup } from '../anomaly_detection/get_ml_jobs_with_apm_group'; @@ -130,13 +132,19 @@ function transformResponseToServiceAnomalies( response.aggregations?.services.buckets ?? [] ).reduce( (statsByServiceName, { key: serviceName, top_score: topScoreAgg }) => { + const anomalyScore = topScoreAgg.hits.hits[0]?.sort?.[0]; + + const severity = getSeverity(anomalyScore); + const healthStatus = getServiceHealthStatus({ severity }); + return { ...statsByServiceName, [serviceName]: { transactionType: topScoreAgg.hits.hits[0]?._source?.by_field_value, - anomalyScore: topScoreAgg.hits.hits[0]?.sort?.[0], + anomalyScore, actualValue: topScoreAgg.hits.hits[0]?._source?.actual?.[0], jobId: topScoreAgg.hits.hits[0]?._source?.job_id, + healthStatus, }, }; }, diff --git a/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts b/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts index e529198e717d32..f30b80feda3026 100644 --- a/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ServiceHealthStatus } from '../../../common/service_health_status'; + import { AGENT_NAME, SERVICE_ENVIRONMENT, @@ -43,6 +45,7 @@ const anomalies = { actualValue: 10000, anomalyScore: 50, jobId: 'apm-test-1234-ml-module-name', + healthStatus: ServiceHealthStatus.warning, }, }, }; diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts index e7e18cbff1c15a..17799203fe73b9 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { getServiceHealthStatus } from '../../../../common/service_health_status'; import { EventOutcome } from '../../../../common/event_outcome'; import { getSeverity } from '../../../../common/anomaly_detection'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; @@ -413,10 +414,11 @@ export const getHealthStatuses = async ( const stats = anomalies.serviceAnomalies[serviceName]; const severity = getSeverity(stats.anomalyScore); + const healthStatus = getServiceHealthStatus({ severity }); return { serviceName, - severity, + healthStatus, }; }); }; diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx index 04f60662d88a37..85e92d0827daab 100644 --- a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx @@ -51,6 +51,7 @@ export class UrlDrilldown implements Drilldown txtUrlDrilldownDisplayName; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 18834f55af0a50..e516ab1cb73b37 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -82,6 +82,58 @@ describe('#create', () => { expect(mockBaseClient.create).not.toHaveBeenCalled(); }); + it('allows a specified ID when overwriting an existing object', async () => { + const attributes = { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }; + const options = { id: 'predefined-uuid', overwrite: true, version: 'some-version' }; + const mockedResponse = { + id: 'predefined-uuid', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + references: [], + }; + + mockBaseClient.create.mockResolvedValue(mockedResponse); + + expect(await wrapper.create('known-type', attributes, options)).toEqual({ + ...mockedResponse, + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, + }); + + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'predefined-uuid' }, + { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + { user: mockAuthenticatedUser() } + ); + + expect(mockBaseClient.create).toHaveBeenCalledTimes(1); + expect(mockBaseClient.create).toHaveBeenCalledWith( + 'known-type', + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + { id: 'predefined-uuid', overwrite: true, version: 'some-version' } + ); + }); + it('generates ID, encrypts attributes and strips them from response except for ones with `dangerouslyExposeValue` set to `true`', async () => { const attributes = { attrOne: 'one', @@ -262,6 +314,77 @@ describe('#bulkCreate', () => { expect(mockBaseClient.bulkCreate).not.toHaveBeenCalled(); }); + it('allows a specified ID when overwriting an existing object', async () => { + const attributes = { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }; + const mockedResponse = { + saved_objects: [ + { + id: 'predefined-uuid', + type: 'known-type', + attributes: { ...attributes, attrSecret: '*secret*', attrNotSoSecret: '*not-so-secret*' }, + references: [], + }, + { + id: 'some-id', + type: 'unknown-type', + attributes, + references: [], + }, + ], + }; + + mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); + + const bulkCreateParams = [ + { id: 'predefined-uuid', type: 'known-type', attributes, version: 'some-version' }, + { type: 'unknown-type', attributes }, + ]; + + await expect(wrapper.bulkCreate(bulkCreateParams, { overwrite: true })).resolves.toEqual({ + saved_objects: [ + { + ...mockedResponse.saved_objects[0], + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, + }, + mockedResponse.saved_objects[1], + ], + }); + + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'predefined-uuid' }, + { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + { user: mockAuthenticatedUser() } + ); + + expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith( + [ + { + ...bulkCreateParams[0], + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + }, + bulkCreateParams[1], + ], + { overwrite: true } + ); + }); + it('generates ID, encrypts attributes and strips them from response except for ones with `dangerouslyExposeValue` set to `true`', async () => { const attributes = { attrOne: 'one', diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index 0eeb9943b5be98..eef389186d6708 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -68,13 +68,18 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon // Saved objects with encrypted attributes should have IDs that are hard to guess especially // since IDs are part of the AAD used during encryption, that's why we control them within this // wrapper and don't allow consumers to specify their own IDs directly. - if (options.id) { + + // only allow a specified ID if we're overwriting an existing ESO with a Version + // this helps us ensure that the document really was previously created using ESO + // and not being used to get around the specified ID limitation + const canSpecifyID = options.overwrite && options.version; + if (options.id && !canSpecifyID) { throw new Error( 'Predefined IDs are not allowed for saved objects with encrypted attributes.' ); } - const id = generateID(); + const id = options.id ?? generateID(); const namespace = getDescriptorNamespace( this.options.baseTypeRegistry, type, @@ -97,7 +102,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon public async bulkCreate( objects: Array>, - options?: SavedObjectsBaseOptions + options?: SavedObjectsBaseOptions & Pick ) { // We encrypt attributes for every object in parallel and that can potentially exhaust libuv or // NodeJS thread pool. If it turns out to be a problem, we can consider switching to the @@ -110,14 +115,15 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon // Saved objects with encrypted attributes should have IDs that are hard to guess especially // since IDs are part of the AAD used during encryption, that's why we control them within this - // wrapper and don't allow consumers to specify their own IDs directly. - if (object.id) { + // wrapper and don't allow consumers to specify their own IDs directly unless overwriting the original document. + const canSpecifyID = options?.overwrite && object.version; + if (object.id && !canSpecifyID) { throw new Error( 'Predefined IDs are not allowed for saved objects with encrypted attributes.' ); } - const id = generateID(); + const id = object.id ?? generateID(); const namespace = getDescriptorNamespace( this.options.baseTypeRegistry, object.type, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index 3867c306553799..b0df3723ca77ec 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -14,8 +14,8 @@ import { SinonFakeServer } from 'sinon'; import { ReactWrapper } from 'enzyme'; import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; +import { createMemoryHistory } from 'history'; -import { init as initHttpRequests } from './helpers/http_requests'; import { notificationServiceMock, fatalErrorsServiceMock, @@ -41,8 +41,7 @@ import { policyNameAlreadyUsedErrorMessage, maximumDocumentsRequiredMessage, } from '../../public/application/services/policies/policy_validation'; -import { HttpResponse } from './helpers/http_requests'; -import { createMemoryHistory } from 'history'; +import { editPolicyHelpers } from './helpers'; // @ts-ignore initHttp(axios.create({ adapter: axiosXhrAdapter })); @@ -54,11 +53,8 @@ initNotification( const history = createMemoryHistory(); let server: SinonFakeServer; -let httpRequestsMockHelpers: { - setPoliciesResponse: (response: HttpResponse) => void; - setNodesListResponse: (response: HttpResponse) => void; - setNodesDetailsResponse: (nodeAttributes: string, response: HttpResponse) => void; -}; +let httpRequestsMockHelpers: editPolicyHelpers.EditPolicySetup['http']['httpRequestsMockHelpers']; +let http: editPolicyHelpers.EditPolicySetup['http']; const policy = { phases: { hot: { @@ -94,6 +90,17 @@ const activatePhase = async (rendered: ReactWrapper, phase: string) => { }); rendered.update(); }; +const openNodeAttributesSection = (rendered: ReactWrapper, phase: string) => { + const getControls = () => findTestSubject(rendered, `${phase}-dataTierAllocationControls`); + act(() => { + findTestSubject(getControls(), 'dataTierSelect').simulate('click'); + }); + rendered.update(); + act(() => { + findTestSubject(getControls(), 'customDataAllocationOption').simulate('click'); + }); + rendered.update(); +}; const expectedErrorMessages = (rendered: ReactWrapper, expectedMessages: string[]) => { const errorMessages = rendered.find('.euiFormErrorText'); expect(errorMessages.length).toBe(expectedMessages.length); @@ -119,12 +126,16 @@ const setPolicyName = (rendered: ReactWrapper, policyName: string) => { policyNameField.simulate('change', { target: { value: policyName } }); rendered.update(); }; -const setPhaseAfter = (rendered: ReactWrapper, phase: string, after: string) => { +const setPhaseAfter = (rendered: ReactWrapper, phase: string, after: string | number) => { const afterInput = rendered.find(`input#${phase}-selectedMinimumAge`); afterInput.simulate('change', { target: { value: after } }); rendered.update(); }; -const setPhaseIndexPriority = (rendered: ReactWrapper, phase: string, priority: string) => { +const setPhaseIndexPriority = ( + rendered: ReactWrapper, + phase: string, + priority: string | number +) => { const priorityInput = rendered.find(`input#${phase}-phaseIndexPriority`); priorityInput.simulate('change', { target: { value: priority } }); rendered.update(); @@ -139,7 +150,9 @@ describe('edit policy', () => { component = ( ); - ({ server, httpRequestsMockHelpers } = initHttpRequests()); + + ({ http } = editPolicyHelpers.setup()); + ({ server, httpRequestsMockHelpers } = http); httpRequestsMockHelpers.setPoliciesResponse(policies); }); @@ -321,7 +334,7 @@ describe('edit policy', () => { describe('warm phase', () => { beforeEach(() => { server.respondImmediately = true; - httpRequestsMockHelpers.setNodesListResponse({}); + http.setupNodeListResponse(); httpRequestsMockHelpers.setNodesDetailsResponse('attribute:true', [ { nodeId: 'testNodeId', stats: { name: 'testNodeName', host: 'testHost' } }, ]); @@ -431,34 +444,39 @@ describe('edit policy', () => { expect(getNodeAttributeSelect(rendered, 'warm').exists()).toBeFalsy(); }); test('should show warning instead of node attributes input when none exist', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: { data: ['node1'] }, + }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeTruthy(); + openNodeAttributesSection(rendered, 'warm'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeTruthy(); expect(getNodeAttributeSelect(rendered, 'warm').exists()).toBeFalsy(); }); test('should show node attributes input when attributes exist', async () => { - httpRequestsMockHelpers.setNodesListResponse({ 'attribute:true': ['node1'] }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'warm'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'warm'); expect(nodeAttributesSelect.exists()).toBeTruthy(); expect(nodeAttributesSelect.find('option').length).toBe(2); }); test('should show view node attributes link when attribute selected and show flyout when clicked', async () => { - httpRequestsMockHelpers.setNodesListResponse({ 'attribute:true': ['node1'] }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'warm'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'warm'); expect(nodeAttributesSelect.exists()).toBeTruthy(); expect(findTestSubject(rendered, 'warm-viewNodeDetailsFlyoutButton').exists()).toBeFalsy(); @@ -473,11 +491,23 @@ describe('edit policy', () => { rendered.update(); expect(rendered.find('.euiFlyout').exists()).toBeTruthy(); }); + test('should show default allocation warning when no node roles are found', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: {}, + }); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'warm'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); + }); }); describe('cold phase', () => { beforeEach(() => { server.respondImmediately = true; - httpRequestsMockHelpers.setNodesListResponse({}); + http.setupNodeListResponse(); httpRequestsMockHelpers.setNodesDetailsResponse('attribute:true', [ { nodeId: 'testNodeId', stats: { name: 'testNodeName', host: 'testHost' } }, ]); @@ -511,34 +541,39 @@ describe('edit policy', () => { expect(getNodeAttributeSelect(rendered, 'cold').exists()).toBeFalsy(); }); test('should show warning instead of node attributes input when none exist', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: { data: ['node1'] }, + }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeTruthy(); + openNodeAttributesSection(rendered, 'cold'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeTruthy(); expect(getNodeAttributeSelect(rendered, 'cold').exists()).toBeFalsy(); }); test('should show node attributes input when attributes exist', async () => { - httpRequestsMockHelpers.setNodesListResponse({ 'attribute:true': ['node1'] }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'cold'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'cold'); expect(nodeAttributesSelect.exists()).toBeTruthy(); expect(nodeAttributesSelect.find('option').length).toBe(2); }); test('should show view node attributes link when attribute selected and show flyout when clicked', async () => { - httpRequestsMockHelpers.setNodesListResponse({ 'attribute:true': ['node1'] }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'cold'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'cold'); expect(nodeAttributesSelect.exists()).toBeTruthy(); expect(findTestSubject(rendered, 'cold-viewNodeDetailsFlyoutButton').exists()).toBeFalsy(); @@ -563,6 +598,128 @@ describe('edit policy', () => { save(rendered); expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); }); + test('should show default allocation warning when no node roles are found', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: {}, + }); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'cold'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); + }); + }); + describe('frozen phase', () => { + beforeEach(() => { + server.respondImmediately = true; + http.setupNodeListResponse(); + httpRequestsMockHelpers.setNodesDetailsResponse('attribute:true', [ + { nodeId: 'testNodeId', stats: { name: 'testNodeName', host: 'testHost' } }, + ]); + }); + test('should allow 0 for phase timing', async () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + setPhaseAfter(rendered, 'frozen', 0); + save(rendered); + expectedErrorMessages(rendered, []); + }); + test('should show positive number required error when trying to save cold phase with -1 for after', async () => { + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + setPhaseAfter(rendered, 'frozen', -1); + save(rendered); + expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); + }); + test('should show spinner for node attributes input when loading', async () => { + server.respondImmediately = false; + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeTruthy(); + expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + expect(getNodeAttributeSelect(rendered, 'frozen').exists()).toBeFalsy(); + }); + test('should show warning instead of node attributes input when none exist', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: { data: ['node1'] }, + }); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'frozen'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeTruthy(); + expect(getNodeAttributeSelect(rendered, 'frozen').exists()).toBeFalsy(); + }); + test('should show node attributes input when attributes exist', async () => { + http.setupNodeListResponse(); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'frozen'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); + const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'frozen'); + expect(nodeAttributesSelect.exists()).toBeTruthy(); + expect(nodeAttributesSelect.find('option').length).toBe(2); + }); + test('should show view node attributes link when attribute selected and show flyout when clicked', async () => { + http.setupNodeListResponse(); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'frozen'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); + const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'frozen'); + expect(nodeAttributesSelect.exists()).toBeTruthy(); + expect(findTestSubject(rendered, 'frozen-viewNodeDetailsFlyoutButton').exists()).toBeFalsy(); + expect(nodeAttributesSelect.find('option').length).toBe(2); + nodeAttributesSelect.simulate('change', { target: { value: 'attribute:true' } }); + rendered.update(); + const flyoutButton = findTestSubject(rendered, 'frozen-viewNodeDetailsFlyoutButton'); + expect(flyoutButton.exists()).toBeTruthy(); + await act(async () => { + await flyoutButton.simulate('click'); + }); + rendered.update(); + expect(rendered.find('.euiFlyout').exists()).toBeTruthy(); + }); + test('should show positive number required error when trying to save with -1 for index priority', async () => { + http.setupNodeListResponse(); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + setPhaseAfter(rendered, 'frozen', 1); + setPhaseIndexPriority(rendered, 'frozen', -1); + save(rendered); + expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); + }); + test('should show default allocation warning when no node roles are found', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: {}, + }); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'frozen'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); + }); }); describe('delete phase', () => { test('should allow 0 for phase timing', async () => { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/edit_policy.ts b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/edit_policy.ts new file mode 100644 index 00000000000000..4eeb542671d23c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/edit_policy.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { init as initHttpRequests } from './http_requests'; + +export type EditPolicySetup = ReturnType; + +export const setup = () => { + const { httpRequestsMockHelpers, server } = initHttpRequests(); + + const setupNodeListResponse = ( + response: Record = { + nodesByAttributes: { 'attribute:true': ['node1'] }, + nodesByRoles: { data: ['node1'] }, + } + ) => { + httpRequestsMockHelpers.setNodesListResponse(response); + }; + + return { + http: { + setupNodeListResponse, + httpRequestsMockHelpers, + server, + }, + }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/http_requests.ts b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/http_requests.ts index 6cbe3bdf1f8c6a..a9d326073e4d3c 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/http_requests.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/http_requests.ts @@ -40,6 +40,8 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { }; }; +export type HttpRequestMockHelpers = ReturnType; + export const init = () => { const server = sinon.fakeServer.create(); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/index.ts b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/index.ts new file mode 100644 index 00000000000000..4c32ea121bb570 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as editPolicyHelpers from './edit_policy'; + +export { HttpRequestMockHelpers, init } from './http_requests'; + +export { editPolicyHelpers }; diff --git a/x-pack/plugins/index_lifecycle_management/common/types/api.ts b/x-pack/plugins/index_lifecycle_management/common/types/api.ts new file mode 100644 index 00000000000000..16b8fbd127ab6c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/common/types/api.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type NodeDataRole = 'data' | 'data_hot' | 'data_warm' | 'data_cold' | 'data_frozen'; + +export interface ListNodesRouteResponse { + nodesByAttributes: { [attributePair: string]: string[] }; + nodesByRoles: { [role in NodeDataRole]?: string[] }; +} diff --git a/x-pack/plugins/index_lifecycle_management/common/types/index.ts b/x-pack/plugins/index_lifecycle_management/common/types/index.ts index fef79c7782bb05..a23dc647f1f655 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/index.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/index.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './api'; + export * from './policies'; diff --git a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts index 97effee44533ab..8f913dd884dfee 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts @@ -6,6 +6,8 @@ import { Index as IndexInterface } from '../../../index_management/common/types'; +export type PhaseWithAllocation = 'warm' | 'cold' | 'frozen'; + export interface SerializedPolicy { name: string; phases: Phases; @@ -62,6 +64,7 @@ export interface SerializedWarmPhase extends SerializedPhase { set_priority?: { priority: number | null; }; + migrate?: { enabled: boolean }; }; } @@ -72,6 +75,7 @@ export interface SerializedColdPhase extends SerializedPhase { set_priority?: { priority: number | null; }; + migrate?: { enabled: boolean }; }; } @@ -82,6 +86,7 @@ export interface SerializedFrozenPhase extends SerializedPhase { set_priority?: { priority: number | null; }; + migrate?: { enabled: boolean }; }; } @@ -103,6 +108,13 @@ export interface AllocateAction { require?: { [attribute: string]: string; }; + migrate?: { + /** + * If enabled is ever set it will only be set to `false` because the default value + * for this is `true`. Rather leave unspecified for true. + */ + enabled: false; + }; } export interface Policy { @@ -125,9 +137,23 @@ export interface PhaseWithMinAge { selectedMinimumAgeUnits: string; } +/** + * Different types of allocation markers we use in deserialized policies. + * + * default - use data tier based data allocation based on node roles -- this is ES best practice mode. + * custom - use node_attrs to allocate data to specific nodes + * none - do not move data anywhere when entering a phase + */ +export type DataTierAllocationType = 'default' | 'custom' | 'none'; + export interface PhaseWithAllocationAction { selectedNodeAttrs: string; selectedReplicaCount: string; + /** + * A string value indicating allocation type. If unspecified we assume the user + * wants to use default allocation. + */ + dataTierAllocationType: DataTierAllocationType; } export interface PhaseWithIndexPriority { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts index f11860d36faf87..6d4c57d23138d1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts @@ -38,6 +38,7 @@ export const defaultNewWarmPhase: WarmPhase = { selectedReplicaCount: '', warmPhaseOnRollover: true, phaseIndexPriority: '50', + dataTierAllocationType: 'default', }; export const defaultNewColdPhase: ColdPhase = { @@ -48,6 +49,7 @@ export const defaultNewColdPhase: ColdPhase = { selectedReplicaCount: '', freezeEnabled: false, phaseIndexPriority: '0', + dataTierAllocationType: 'default', }; export const defaultNewFrozenPhase: FrozenPhase = { @@ -58,6 +60,7 @@ export const defaultNewFrozenPhase: FrozenPhase = { selectedReplicaCount: '', freezeEnabled: false, phaseIndexPriority: '0', + dataTierAllocationType: 'default', }; export const defaultNewDeletePhase: DeletePhase = { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/check_phase_compatibility.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/check_phase_compatibility.ts new file mode 100644 index 00000000000000..2ef0fb145551f9 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/check_phase_compatibility.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + NodeDataRole, + ListNodesRouteResponse, + PhaseWithAllocation, +} from '../../../../common/types'; + +/** + * Given a phase and current node roles, determine whether the phase + * can use default data tier allocation. + * + * This can only be checked for phases that have an allocate action. + */ +export const isPhaseDefaultDataAllocationCompatible = ( + phase: PhaseWithAllocation, + nodesByRoles: ListNodesRouteResponse['nodesByRoles'] +): boolean => { + // The 'data' role covers all node roles, so if we have at least one node with the data role + // we can use default allocation. + if (nodesByRoles.data?.length) { + return true; + } + + // Otherwise we need to check whether a node role for the specific phase exists + if (nodesByRoles[`data_${phase}` as NodeDataRole]?.length) { + return true; + } + + // Otherwise default allocation has nowhere to allocate new shards to in this phase. + return false; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/determine_allocation_type.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/determine_allocation_type.ts new file mode 100644 index 00000000000000..4067ad97fc43bc --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/determine_allocation_type.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DataTierAllocationType, AllocateAction } from '../../../../common/types'; + +/** + * Determine what deserialized state the policy config represents. + * + * See {@DataTierAllocationType} for more information. + */ +export const determineDataTierAllocationType = ( + allocateAction?: AllocateAction +): DataTierAllocationType => { + if (!allocateAction) { + return 'default'; + } + + if (allocateAction.migrate?.enabled === false) { + return 'none'; + } + + if ( + (allocateAction.require && Object.keys(allocateAction.require).length) || + (allocateAction.include && Object.keys(allocateAction.include).length) || + (allocateAction.exclude && Object.keys(allocateAction.exclude).length) + ) { + return 'custom'; + } + + return 'default'; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/index.ts new file mode 100644 index 00000000000000..67a512cefe00c3 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/index.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './determine_allocation_type'; + +export * from './check_phase_compatibility'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts new file mode 100644 index 00000000000000..1dabae1a0f0c4e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './data_tiers'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.scss new file mode 100644 index 00000000000000..62ec3f303e1e8c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.scss @@ -0,0 +1,9 @@ +.indexLifecycleManagement__phase__dataTierAllocation { + &__controlSection { + background-color: $euiColorLightestShade; + padding-top: $euiSizeM; + padding-left: $euiSizeM; + padding-right: $euiSizeM; + padding-bottom: $euiSizeM; + } +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.tsx new file mode 100644 index 00000000000000..3ae60a5a3d622a --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.tsx @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiText, EuiFormRow, EuiSpacer, EuiSuperSelect, EuiSuperSelectOption } from '@elastic/eui'; + +import { DataTierAllocationType } from '../../../../../../common/types'; +import { NodeAllocation } from './node_allocation'; +import { SharedProps } from './types'; + +import './data_tier_allocation.scss'; + +type SelectOptions = EuiSuperSelectOption; + +const i18nTexts = { + allocationFieldLabel: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.allocationFieldLabel', + { defaultMessage: 'Data tier options' } + ), + allocationOptions: { + warm: { + default: { + input: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.defaultOption.input', + { defaultMessage: 'Use warm nodes (recommended)' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.defaultOption.helpText', + { defaultMessage: 'Move data to nodes in the warm tier.' } + ), + }, + none: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.noneOption.input', + { defaultMessage: 'Off' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.noneOption.helpText', + { defaultMessage: 'Do not move data in the warm phase.' } + ), + }, + custom: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.customOption.input', + { defaultMessage: 'Custom' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.customOption.helpText', + { defaultMessage: 'Move data based on node attributes.' } + ), + }, + }, + cold: { + default: { + input: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.defaultOption.input', + { defaultMessage: 'Use cold nodes (recommended)' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.defaultOption.helpText', + { defaultMessage: 'Move data to nodes in the cold tier.' } + ), + }, + none: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.noneOption.input', + { defaultMessage: 'Off' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.noneOption.helpText', + { defaultMessage: 'Do not move data in the cold phase.' } + ), + }, + custom: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.customOption.input', + { defaultMessage: 'Custom' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.customOption.helpText', + { defaultMessage: 'Move data based on node attributes.' } + ), + }, + }, + frozen: { + default: { + input: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.frozen.defaultOption.input', + { defaultMessage: 'Use frozen nodes (recommended)' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.frozen.defaultOption.helpText', + { defaultMessage: 'Move data to nodes in the frozen tier.' } + ), + }, + none: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.frozen.noneOption.input', + { defaultMessage: 'Off' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.frozen.noneOption.helpText', + { defaultMessage: 'Do not move data in the frozen phase.' } + ), + }, + custom: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.frozen.customOption.input', + { defaultMessage: 'Custom' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.frozen.customOption.helpText', + { defaultMessage: 'Move data based on node attributes.' } + ), + }, + }, + }, +}; + +export const DataTierAllocation: FunctionComponent = (props) => { + const { phaseData, setPhaseData, phase, hasNodeAttributes } = props; + + return ( +
+ + setPhaseData('dataTierAllocationType', value)} + options={ + [ + { + value: 'default', + inputDisplay: i18nTexts.allocationOptions[phase].default.input, + dropdownDisplay: ( + <> + {i18nTexts.allocationOptions[phase].default.input} + +

+ {i18nTexts.allocationOptions[phase].default.helpText} +

+
+ + ), + }, + { + value: 'none', + inputDisplay: i18nTexts.allocationOptions[phase].none.inputDisplay, + dropdownDisplay: ( + <> + {i18nTexts.allocationOptions[phase].none.inputDisplay} + +

+ {i18nTexts.allocationOptions[phase].none.helpText} +

+
+ + ), + }, + { + 'data-test-subj': 'customDataAllocationOption', + value: 'custom', + inputDisplay: i18nTexts.allocationOptions[phase].custom.inputDisplay, + dropdownDisplay: ( + <> + {i18nTexts.allocationOptions[phase].custom.inputDisplay} + +

+ {i18nTexts.allocationOptions[phase].custom.helpText} +

+
+ + ), + }, + ] as SelectOptions[] + } + /> +
+ {phaseData.dataTierAllocationType === 'custom' && hasNodeAttributes && ( + <> + +
+ +
+ + )} +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/default_allocation_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/default_allocation_warning.tsx new file mode 100644 index 00000000000000..a7ebc0d2e4a241 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/default_allocation_warning.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent } from 'react'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; + +import { PhaseWithAllocation } from '../../../../../../common/types'; + +const i18nTexts = { + warm: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the warm tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the warm tier to use role-based allocation. The policy will fail to complete allocation if there are no warm nodes.', + } + ), + }, + cold: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the cold tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the cold tier to use role-based allocation. The policy will fail to complete allocation if there are no cold nodes.', + } + ), + }, + frozen: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.frozenPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the frozen tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.frozenPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the frozen tier to use role-based allocation. The policy will fail to complete allocation if there are no frozen nodes.', + } + ), + }, +}; + +interface Props { + phase: PhaseWithAllocation; +} + +export const DefaultAllocationWarning: FunctionComponent = ({ phase }) => { + return ( + <> + + + {i18nTexts[phase].body} + + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/index.ts new file mode 100644 index 00000000000000..26464a75ae14c2 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { NodesDataProvider } from './node_data_provider'; +export { NodeAllocation } from './node_allocation'; +export { NodeAttrsDetails } from './node_attrs_details'; +export { DataTierAllocation } from './data_tier_allocation'; +export { DefaultAllocationWarning } from './default_allocation_warning'; +export { NoNodeAttributesWarning } from './no_node_attributes_warning'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/no_node_attributes_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/no_node_attributes_warning.tsx new file mode 100644 index 00000000000000..1ba82623c2b947 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/no_node_attributes_warning.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { PhaseWithAllocation } from '../../../../../../common/types'; + +const i18nTexts = { + title: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.nodeAttributesMissingLabel', { + defaultMessage: 'No custom node attributes configured', + }), + warm: { + body: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.warm.nodeAttributesMissingDescription', + { + defaultMessage: + 'Define custom node attributes in elasticsearch.yml to use attribute-based allocation. Warm nodes will be used instead.', + } + ), + }, + cold: { + body: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.cold.nodeAttributesMissingDescription', + { + defaultMessage: + 'Define custom node attributes in elasticsearch.yml to use attribute-based allocation. Cold nodes will be used instead.', + } + ), + }, + frozen: { + body: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.frozen.nodeAttributesMissingDescription', + { + defaultMessage: + 'Define custom node attributes in elasticsearch.yml to use attribute-based allocation. Frozen nodes will be used instead.', + } + ), + }, +}; + +export const NoNodeAttributesWarning: FunctionComponent<{ phase: PhaseWithAllocation }> = ({ + phase, +}) => { + return ( + <> + + + {i18nTexts[phase].body} + + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_allocation.tsx new file mode 100644 index 00000000000000..a57a6ba4ff2c65 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_allocation.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, FunctionComponent } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiSelect, EuiButtonEmpty, EuiText, EuiSpacer } from '@elastic/eui'; + +import { PhaseWithAllocationAction } from '../../../../../../common/types'; +import { propertyof } from '../../../../services/policies/policy_validation'; + +import { ErrableFormRow } from '../form_errors'; + +import { NodeAttrsDetails } from './node_attrs_details'; +import { SharedProps } from './types'; +import { LearnMoreLink } from '../learn_more_link'; + +const learnMoreLink = ( + + } + docPath="modules-cluster.html#cluster-shard-allocation-settings" + /> +); + +const i18nTexts = { + doNotModifyAllocationOption: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.nodeAllocation.doNotModifyAllocationOption', + { defaultMessage: 'Do not modify allocation configuration' } + ), +}; + +export const NodeAllocation: FunctionComponent = ({ + phase, + setPhaseData, + errors, + phaseData, + isShowingErrors, + nodes, +}) => { + const [selectedNodeAttrsForDetails, setSelectedNodeAttrsForDetails] = useState( + null + ); + + const nodeOptions = Object.keys(nodes).map((attrs) => ({ + text: `${attrs} (${nodes[attrs].length})`, + value: attrs, + })); + + nodeOptions.sort((a, b) => a.value.localeCompare(b.value)); + + // check that this string is a valid property + const nodeAttrsProperty = propertyof('selectedNodeAttrs'); + + return ( + <> + +

+ +

+
+ + + {/* + TODO: this field component must be revisited to support setting multiple require values and to support + setting `include and exclude values on ILM policies. See https://github.com/elastic/kibana/issues/77344 + */} + setSelectedNodeAttrsForDetails(phaseData.selectedNodeAttrs)} + > + + + ) : null + } + > + { + setPhaseData(nodeAttrsProperty, e.target.value); + }} + /> + + + {selectedNodeAttrsForDetails ? ( + setSelectedNodeAttrsForDetails(null)} + /> + ) : null} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_attrs_details.tsx similarity index 97% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_attrs_details.tsx index af8833c8082b3e..c29495d13eb8ed 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_attrs_details.tsx @@ -20,7 +20,7 @@ import { EuiButton, } from '@elastic/eui'; -import { useLoadNodeDetails } from '../../../services/api'; +import { useLoadNodeDetails } from '../../../../services/api'; interface Props { close: () => void; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_data_provider.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_data_provider.tsx new file mode 100644 index 00000000000000..a7c0f3ec7c8660 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_data_provider.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiButton, EuiCallOut, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { ListNodesRouteResponse } from '../../../../../../common/types'; +import { useLoadNodes } from '../../../../services/api'; + +interface Props { + children: (data: ListNodesRouteResponse) => JSX.Element; +} + +export const NodesDataProvider = ({ children }: Props): JSX.Element => { + const { isLoading, data, error, resendRequest } = useLoadNodes(); + + if (isLoading) { + return ( + <> + + + + ); + } + + const renderError = () => { + if (error) { + const { statusCode, message } = error; + return ( + <> + + } + color="danger" + > +

+ {message} ({statusCode}) +

+ + + +
+ + + + ); + } + return null; + }; + + return ( + <> + {renderError()} + {/* `data` will always be defined because we use an initial value when loading */} + {children(data!)} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/types.ts new file mode 100644 index 00000000000000..d4cb31a3be9e76 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ListNodesRouteResponse, + PhaseWithAllocation, + PhaseWithAllocationAction, +} from '../../../../../../common/types'; +import { PhaseValidationErrors } from '../../../../services/policies/policy_validation'; + +export interface SharedProps { + phase: PhaseWithAllocation; + errors?: PhaseValidationErrors; + phaseData: PhaseWithAllocationAction; + setPhaseData: (dataKey: keyof PhaseWithAllocationAction, value: string) => void; + isShowingErrors: boolean; + nodes: ListNodesRouteResponse['nodesByAttributes']; + hasNodeAttributes: boolean; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_field.tsx new file mode 100644 index 00000000000000..7bf8cd3ba6d90a --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_field.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { EuiDescribedFormGroup, EuiDescribedFormGroupProps } from '@elastic/eui'; + +import { ToggleableField, Props as ToggleableFieldProps } from './toggleable_field'; + +type Props = EuiDescribedFormGroupProps & { + switchProps: ToggleableFieldProps; +}; + +export const DescribedFormField: FunctionComponent = ({ + children, + switchProps, + ...restDescribedFormProps +}) => { + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index 4410c4bb38397f..2428cade0898eb 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -8,11 +8,17 @@ export { ActiveBadge } from './active_badge'; export { ErrableFormRow } from './form_errors'; export { LearnMoreLink } from './learn_more_link'; export { MinAgeInput } from './min_age_input'; -export { NodeAllocation } from './node_allocation'; -export { NodeAttrsDetails } from './node_attrs_details'; export { OptionalLabel } from './optional_label'; export { PhaseErrorMessage } from './phase_error_message'; export { PolicyJsonFlyout } from './policy_json_flyout'; export { SetPriorityInput } from './set_priority_input'; export { SnapshotPolicies } from './snapshot_policies'; +export { + DataTierAllocation, + NodeAllocation, + NodeAttrsDetails, + NodesDataProvider, + DefaultAllocationWarning, +} from './data_tier_allocation'; +export { DescribedFormField } from './described_form_field'; export { Forcemerge } from './forcemerge'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx deleted file mode 100644 index 6a22d8716514cf..00000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { - EuiSelect, - EuiButtonEmpty, - EuiCallOut, - EuiSpacer, - EuiLoadingSpinner, - EuiButton, -} from '@elastic/eui'; - -import { LearnMoreLink } from './learn_more_link'; -import { ErrableFormRow } from './form_errors'; -import { useLoadNodes } from '../../../services/api'; -import { NodeAttrsDetails } from './node_attrs_details'; -import { PhaseWithAllocationAction, Phases } from '../../../../../common/types'; -import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; - -const learnMoreLink = ( - - - - } - docPath="modules-cluster.html#cluster-shard-allocation-settings" - /> - -); - -interface Props { - phase: keyof Phases & string; - errors?: PhaseValidationErrors; - phaseData: T; - setPhaseData: (dataKey: keyof T & string, value: string) => void; - isShowingErrors: boolean; -} -export const NodeAllocation = ({ - phase, - setPhaseData, - errors, - phaseData, - isShowingErrors, -}: React.PropsWithChildren>) => { - const { isLoading, data: nodes, error, resendRequest } = useLoadNodes(); - - const [selectedNodeAttrsForDetails, setSelectedNodeAttrsForDetails] = useState( - null - ); - - if (isLoading) { - return ( - - - - - ); - } - - if (error) { - const { statusCode, message } = error; - return ( - - - } - color="danger" - > -

- {message} ({statusCode}) -

- - - -
- - -
- ); - } - - let nodeOptions = Object.keys(nodes).map((attrs) => ({ - text: `${attrs} (${nodes[attrs].length})`, - value: attrs, - })); - - nodeOptions.sort((a, b) => a.value.localeCompare(b.value)); - if (nodeOptions.length) { - nodeOptions = [ - { - text: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.defaultNodeAllocation', { - defaultMessage: "Default allocation (don't use attributes)", - }), - value: '', - }, - ...nodeOptions, - ]; - } - if (!nodeOptions.length) { - return ( - - - } - color="warning" - > - - {learnMoreLink} - - - - - ); - } - - // check that this string is a valid property - const nodeAttrsProperty = propertyof('selectedNodeAttrs'); - - return ( - - - { - setPhaseData(nodeAttrsProperty, e.target.value); - }} - /> - - {!!phaseData.selectedNodeAttrs ? ( - setSelectedNodeAttrsForDetails(phaseData.selectedNodeAttrs)} - > - - - ) : null} - {learnMoreLink} - - - {selectedNodeAttrsForDetails ? ( - setSelectedNodeAttrsForDetails(null)} - /> - ) : null} - - ); -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx new file mode 100644 index 00000000000000..ff4301808db339 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent, useState } from 'react'; +import { EuiSpacer, EuiSwitch, EuiSwitchProps } from '@elastic/eui'; + +export interface Props extends Omit { + initialValue: boolean; + onChange: (nextValue: boolean) => void; +} + +export const ToggleableField: FunctionComponent = ({ + initialValue, + onChange, + children, + ...restProps +}) => { + const [isContentVisible, setIsContentVisible] = useState(initialValue); + + return ( + <> + { + const nextValue = e.target.checked; + setIsContentVisible(nextValue); + onChange(nextValue); + }} + /> + + {isContentVisible ? children : null} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index f1c287788e08da..85529ef0c9a5b3 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useEffect, useState } from 'react'; +import React, { Fragment, useEffect, useState, useCallback } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -45,7 +45,7 @@ import { import { ErrableFormRow, LearnMoreLink, PolicyJsonFlyout } from './components'; import { ColdPhase, DeletePhase, FrozenPhase, HotPhase, WarmPhase } from './phases'; -interface Props { +export interface Props { policies: PolicyFromES[]; policyName: string; getUrlForApp: ( @@ -119,15 +119,39 @@ export const EditPolicy: React.FunctionComponent = ({ setIsShowingPolicyJsonFlyout(!isShowingPolicyJsonFlyout); }; - const setPhaseData = (phase: keyof Phases, key: string, value: any) => { - setPolicy({ - ...policy, - phases: { - ...policy.phases, - [phase]: { ...policy.phases[phase], [key]: value }, - }, - }); - }; + const setPhaseData = useCallback( + (phase: keyof Phases, key: string, value: any) => { + setPolicy((nextPolicy) => ({ + ...nextPolicy, + phases: { + ...nextPolicy.phases, + [phase]: { ...nextPolicy.phases[phase], [key]: value }, + }, + })); + }, + [setPolicy] + ); + + const setHotPhaseData = useCallback( + (key: string, value: any) => setPhaseData('hot', key, value), + [setPhaseData] + ); + const setWarmPhaseData = useCallback( + (key: string, value: any) => setPhaseData('warm', key, value), + [setPhaseData] + ); + const setColdPhaseData = useCallback( + (key: string, value: any) => setPhaseData('cold', key, value), + [setPhaseData] + ); + const setFrozenPhaseData = useCallback( + (key: string, value: any) => setPhaseData('frozen', key, value), + [setPhaseData] + ); + const setDeletePhaseData = useCallback( + (key: string, value: any) => setPhaseData('delete', key, value), + [setPhaseData] + ); const setWarmPhaseOnRollover = (value: boolean) => { setPolicy({ @@ -277,7 +301,7 @@ export const EditPolicy: React.FunctionComponent = ({ 0} - setPhaseData={(key, value) => setPhaseData('hot', key, value)} + setPhaseData={setHotPhaseData} phaseData={policy.phases.hot} setWarmPhaseOnRollover={setWarmPhaseOnRollover} /> @@ -287,7 +311,7 @@ export const EditPolicy: React.FunctionComponent = ({ 0} - setPhaseData={(key, value) => setPhaseData('warm', key, value)} + setPhaseData={setWarmPhaseData} phaseData={policy.phases.warm} hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} /> @@ -297,7 +321,7 @@ export const EditPolicy: React.FunctionComponent = ({ 0} - setPhaseData={(key, value) => setPhaseData('cold', key, value)} + setPhaseData={setColdPhaseData} phaseData={policy.phases.cold} hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} /> @@ -307,7 +331,7 @@ export const EditPolicy: React.FunctionComponent = ({ 0} - setPhaseData={(key, value) => setPhaseData('frozen', key, value)} + setPhaseData={setFrozenPhaseData} phaseData={policy.phases.frozen} hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} /> @@ -318,7 +342,7 @@ export const EditPolicy: React.FunctionComponent = ({ errors={errors?.delete} isShowingErrors={isShowingErrors && !!errors && Object.keys(errors.delete).length > 0} getUrlForApp={getUrlForApp} - setPhaseData={(key, value) => setPhaseData('delete', key, value)} + setPhaseData={setDeletePhaseData} phaseData={policy.phases.delete} hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx index ae2858e7a84aeb..241a98fffa6dfa 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx @@ -4,19 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { FunctionComponent, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiFieldNumber, - EuiDescribedFormGroup, - EuiSwitch, - EuiTextColor, -} from '@elastic/eui'; +import { EuiFieldNumber, EuiDescribedFormGroup, EuiSwitch, EuiTextColor } from '@elastic/eui'; import { ColdPhase as ColdPhaseInterface, Phases } from '../../../../../common/types'; import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; @@ -27,14 +19,24 @@ import { PhaseErrorMessage, OptionalLabel, ErrableFormRow, - MinAgeInput, - NodeAllocation, SetPriorityInput, + MinAgeInput, + DescribedFormField, } from '../components'; -const freezeLabel = i18n.translate('xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel', { - defaultMessage: 'Freeze index', -}); +import { DataTierAllocationField } from './shared'; + +const i18nTexts = { + freezeLabel: i18n.translate('xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel', { + defaultMessage: 'Freeze index', + }), + dataTierAllocation: { + description: i18n.translate('xpack.indexLifecycleMgmt.coldPhase.dataTier.description', { + defaultMessage: + 'Move data to data nodes optimized for less frequent, read-only access. Store cold data on less-expensive hardware.', + }), + }, +}; const coldProperty: keyof Phases = 'cold'; const phaseProperty = (propertyName: keyof ColdPhaseInterface) => propertyName; @@ -46,18 +48,17 @@ interface Props { errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; } -export class ColdPhase extends PureComponent { - render() { - const { - setPhaseData, - phaseData, - errors, - isShowingErrors, - hotPhaseRolloverEnabled, - } = this.props; - - return ( -
+export const ColdPhase: FunctionComponent = ({ + setPhaseData, + phaseData, + errors, + isShowingErrors, + hotPhaseRolloverEnabled, +}) => { + return ( +
+ <> + {/* Section title group; containing min age */} @@ -86,7 +87,7 @@ export class ColdPhase extends PureComponent { data-test-subj="enablePhaseSwitch-cold" label={ } @@ -101,68 +102,83 @@ export class ColdPhase extends PureComponent { } fullWidth > - - {phaseData.phaseEnabled ? ( - - - errors={errors} - phaseData={phaseData} - phase={coldProperty} - isShowingErrors={isShowingErrors} - setPhaseData={setPhaseData} - rolloverEnabled={hotPhaseRolloverEnabled} - /> - - - - phase={coldProperty} - setPhaseData={setPhaseData} - errors={errors} - phaseData={phaseData} - isShowingErrors={isShowingErrors} - /> - - - - - - - - } - isShowingErrors={isShowingErrors} - errors={errors?.freezeEnabled} - helpText={i18n.translate( - 'xpack.indexLifecycleMgmt.coldPhase.replicaCountHelpText', - { - defaultMessage: 'By default, the number of replicas remains the same.', - } - )} - > - { - setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); - }} - min={0} - /> - - - - - ) : ( -
- )} - + {phaseData.phaseEnabled ? ( + + errors={errors} + phaseData={phaseData} + phase={coldProperty} + isShowingErrors={isShowingErrors} + setPhaseData={setPhaseData} + rolloverEnabled={hotPhaseRolloverEnabled} + /> + ) : null} {phaseData.phaseEnabled ? ( + {/* Data tier allocation section */} + + + {/* Replicas section */} + + {i18n.translate('xpack.indexLifecycleMgmt.coldPhase.replicasTitle', { + defaultMessage: 'Replicas', + })} + + } + description={i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasDescription', + { + defaultMessage: + 'Set the number of replicas. Remains the same as the previous phase by default.', + } + )} + switchProps={{ + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.coldPhase.numberOfReplicas.switchLabel', + { defaultMessage: 'Set replicas' } + ), + initialValue: Boolean(phaseData.selectedReplicaCount), + onChange: (v) => { + if (!v) { + setPhaseData('selectedReplicaCount', ''); + } + }, + }} + fullWidth + > + + + + + } + isShowingErrors={isShowingErrors} + errors={errors?.selectedReplicaCount} + > + { + setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); + }} + min={0} + /> + + + {/* Freeze section */} @@ -191,8 +207,8 @@ export class ColdPhase extends PureComponent { onChange={(e) => { setPhaseData(phaseProperty('freezeEnabled'), e.target.checked); }} - label={freezeLabel} - aria-label={freezeLabel} + label={i18nTexts.freezeLabel} + aria-label={i18nTexts.freezeLabel} /> @@ -204,7 +220,7 @@ export class ColdPhase extends PureComponent { /> ) : null} -
- ); - } -} + +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx index bfaf1414381696..6a849cc2c3f1f1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx @@ -4,19 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { FunctionComponent, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiFieldNumber, - EuiDescribedFormGroup, - EuiSwitch, - EuiTextColor, -} from '@elastic/eui'; +import { EuiFieldNumber, EuiDescribedFormGroup, EuiSwitch, EuiTextColor } from '@elastic/eui'; import { FrozenPhase as FrozenPhaseInterface, Phases } from '../../../../../common/types'; import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; @@ -28,13 +20,22 @@ import { OptionalLabel, ErrableFormRow, MinAgeInput, - NodeAllocation, SetPriorityInput, + DescribedFormField, } from '../components'; +import { DataTierAllocationField } from './shared'; -const freezeLabel = i18n.translate('xpack.indexLifecycleMgmt.frozenPhase.freezeIndexLabel', { - defaultMessage: 'Freeze index', -}); +const i18nTexts = { + freezeLabel: i18n.translate('xpack.indexLifecycleMgmt.frozenPhase.freezeIndexLabel', { + defaultMessage: 'Freeze index', + }), + dataTierAllocation: { + description: i18n.translate('xpack.indexLifecycleMgmt.frozenPhase.dataTier.description', { + defaultMessage: + 'Move data to data nodes optimized for infrequent, read-only access. Store frozen data on the least-expensive hardware.', + }), + }, +}; const frozenProperty: keyof Phases = 'frozen'; const phaseProperty = (propertyName: keyof FrozenPhaseInterface) => propertyName; @@ -46,18 +47,17 @@ interface Props { errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; } -export class FrozenPhase extends PureComponent { - render() { - const { - setPhaseData, - phaseData, - errors, - isShowingErrors, - hotPhaseRolloverEnabled, - } = this.props; - - return ( -
+export const FrozenPhase: FunctionComponent = ({ + setPhaseData, + phaseData, + errors, + isShowingErrors, + hotPhaseRolloverEnabled, +}) => { + return ( +
+ <> + {/* Section title group; containing min age */} @@ -101,68 +101,82 @@ export class FrozenPhase extends PureComponent { } fullWidth > - - {phaseData.phaseEnabled ? ( - - - errors={errors} - phaseData={phaseData} - phase={frozenProperty} - isShowingErrors={isShowingErrors} - setPhaseData={setPhaseData} - rolloverEnabled={hotPhaseRolloverEnabled} - /> - - - - phase={frozenProperty} - setPhaseData={setPhaseData} - errors={errors} - phaseData={phaseData} - isShowingErrors={isShowingErrors} - /> - - - - - - - - } - isShowingErrors={isShowingErrors} - errors={errors?.freezeEnabled} - helpText={i18n.translate( - 'xpack.indexLifecycleMgmt.frozenPhase.replicaCountHelpText', - { - defaultMessage: 'By default, the number of replicas remains the same.', - } - )} - > - { - setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); - }} - min={0} - /> - - - - - ) : ( -
- )} - + {phaseData.phaseEnabled ? ( + + errors={errors} + phaseData={phaseData} + phase={frozenProperty} + isShowingErrors={isShowingErrors} + setPhaseData={setPhaseData} + rolloverEnabled={hotPhaseRolloverEnabled} + /> + ) : null} {phaseData.phaseEnabled ? ( + {/* Data tier allocation section */} + + + {/* Replicas section */} + + {i18n.translate('xpack.indexLifecycleMgmt.frozenPhase.replicasTitle', { + defaultMessage: 'Replicas', + })} + + } + description={i18n.translate( + 'xpack.indexLifecycleMgmt.frozenPhase.numberOfReplicasDescription', + { + defaultMessage: + 'Set the number of replicas. Remains the same as the previous phase by default.', + } + )} + switchProps={{ + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.frozenPhase.numberOfReplicas.switchLabel', + { defaultMessage: 'Set replicas' } + ), + initialValue: Boolean(phaseData.selectedReplicaCount), + onChange: (v) => { + if (!v) { + setPhaseData('selectedReplicaCount', ''); + } + }, + }} + fullWidth + > + + + + + } + isShowingErrors={isShowingErrors} + errors={errors?.selectedReplicaCount} + > + { + setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); + }} + min={0} + /> + + @@ -191,8 +205,8 @@ export class FrozenPhase extends PureComponent { onChange={(e) => { setPhaseData(phaseProperty('freezeEnabled'), e.target.checked); }} - label={freezeLabel} - aria-label={freezeLabel} + label={i18nTexts.freezeLabel} + aria-label={i18nTexts.freezeLabel} /> @@ -204,7 +218,7 @@ export class FrozenPhase extends PureComponent { /> ) : null} -
- ); - } -} + +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/data_tier_allocation_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/data_tier_allocation_field.tsx new file mode 100644 index 00000000000000..6475e5286a7788 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/data_tier_allocation_field.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +import { PhaseWithAllocationAction, PhaseWithAllocation } from '../../../../../../common/types'; + +import { + DataTierAllocation, + DefaultAllocationWarning, + NoNodeAttributesWarning, + NodesDataProvider, +} from '../../components/data_tier_allocation'; +import { PhaseValidationErrors } from '../../../../services/policies/policy_validation'; +import { isPhaseDefaultDataAllocationCompatible } from '../../../../lib/data_tiers'; + +const i18nTexts = { + title: i18n.translate('xpack.indexLifecycleMgmt.common.dataTier.title', { + defaultMessage: 'Data allocation', + }), +}; + +interface Props { + description: React.ReactNode; + phase: PhaseWithAllocation; + setPhaseData: (dataKey: keyof PhaseWithAllocationAction, value: string) => void; + isShowingErrors: boolean; + errors?: PhaseValidationErrors; + phaseData: PhaseWithAllocationAction; +} + +/** + * Top-level layout control for the data tier allocation field. + */ +export const DataTierAllocationField: FunctionComponent = ({ + description, + phase, + phaseData, + setPhaseData, + isShowingErrors, + errors, +}) => { + return ( + + {(nodesData) => { + const isCompatible = isPhaseDefaultDataAllocationCompatible(phase, nodesData.nodesByRoles); + const hasNodeAttrs = Boolean(Object.keys(nodesData.nodesByAttributes ?? {}).length); + + return ( + {i18nTexts.title}} + description={description} + fullWidth + > + + <> + + + {/* Data tier related warnings */} + + {phaseData.dataTierAllocationType === 'default' && !isCompatible && ( + + )} + + {phaseData.dataTierAllocationType === 'custom' && !hasNodeAttrs && ( + + )} + + + + ); + }} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/index.ts new file mode 100644 index 00000000000000..f9e939058adb99 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DataTierAllocationField } from './data_tier_allocation_field'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx index c806056899cacc..16a740b1171c97 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, PureComponent } from 'react'; +import React, { Fragment, FunctionComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { @@ -27,44 +27,53 @@ import { OptionalLabel, ErrableFormRow, SetPriorityInput, - NodeAllocation, MinAgeInput, + DescribedFormField, Forcemerge, } from '../components'; +import { DataTierAllocationField } from './shared'; -const shrinkLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { - defaultMessage: 'Shrink index', -}); - -const moveToWarmPhaseOnRolloverLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel', - { - defaultMessage: 'Move to warm phase on rollover', - } -); +const i18nTexts = { + shrinkLabel: i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { + defaultMessage: 'Shrink index', + }), + moveToWarmPhaseOnRolloverLabel: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel', + { + defaultMessage: 'Move to warm phase on rollover', + } + ), + dataTierAllocation: { + description: i18n.translate('xpack.indexLifecycleMgmt.warmPhase.dataTier.description', { + defaultMessage: + 'Move warm data to nodes optimized for read-only access. Store warm data on less-expensive hardware.', + }), + }, +}; const warmProperty: keyof Phases = 'warm'; const phaseProperty = (propertyName: keyof WarmPhaseInterface) => propertyName; interface Props { - setPhaseData: (key: keyof WarmPhaseInterface & string, value: boolean | string) => void; + setPhaseData: ( + key: keyof WarmPhaseInterface & string, + value: boolean | string | undefined + ) => void; phaseData: WarmPhaseInterface; isShowingErrors: boolean; errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; } -export class WarmPhase extends PureComponent { - render() { - const { - setPhaseData, - phaseData, - errors, - isShowingErrors, - hotPhaseRolloverEnabled, - } = this.props; - - return ( -
+export const WarmPhase: FunctionComponent = ({ + setPhaseData, + phaseData, + errors, + isShowingErrors, + hotPhaseRolloverEnabled, +}) => { + return ( +
+ <> @@ -115,7 +124,7 @@ export class WarmPhase extends PureComponent { { @@ -137,58 +146,75 @@ export class WarmPhase extends PureComponent { /> ) : null} - - - - - phase={warmProperty} - setPhaseData={setPhaseData} - errors={errors} - phaseData={phaseData} - isShowingErrors={isShowingErrors} - /> - - - - - - - - } - isShowingErrors={isShowingErrors} - errors={errors?.selectedReplicaCount} - helpText={i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.replicaCountHelpText', - { - defaultMessage: 'By default, the number of replicas remains the same.', - } - )} - > - { - setPhaseData('selectedReplicaCount', e.target.value); - }} - min={0} - /> - - - - - ) : null} + {phaseData.phaseEnabled ? ( + {/* Data tier allocation section */} + + + + {i18n.translate('xpack.indexLifecycleMgmt.warmPhase.replicasTitle', { + defaultMessage: 'Replicas', + })} + + } + description={i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasDescription', + { + defaultMessage: + 'Set the number of replicas. Remains the same as the previous phase by default.', + } + )} + switchProps={{ + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.warmPhase.numberOfReplicas.switchLabel', + { defaultMessage: 'Set replicas' } + ), + initialValue: Boolean(phaseData.selectedReplicaCount), + onChange: (v) => { + if (!v) { + setPhaseData('selectedReplicaCount', ''); + } + }, + }} + fullWidth + > + + + + + } + isShowingErrors={isShowingErrors} + errors={errors?.selectedReplicaCount} + > + { + setPhaseData('selectedReplicaCount', e.target.value); + }} + min={0} + /> + + @@ -217,8 +243,8 @@ export class WarmPhase extends PureComponent { onChange={(e) => { setPhaseData(phaseProperty('shrinkEnabled'), e.target.checked); }} - label={shrinkLabel} - aria-label={shrinkLabel} + label={i18nTexts.shrinkLabel} + aria-label={i18nTexts.shrinkLabel} aria-controls="shrinkContent" /> @@ -275,7 +301,7 @@ export class WarmPhase extends PureComponent { /> ) : null} -
- ); - } -} + +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts index 3d068433becbd4..b279a5647c3e8b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts @@ -6,7 +6,7 @@ import { METRIC_TYPE } from '@kbn/analytics'; -import { PolicyFromES, SerializedPolicy } from '../../../common/types'; +import { PolicyFromES, SerializedPolicy, ListNodesRouteResponse } from '../../../common/types'; import { UIM_POLICY_DELETE, @@ -23,10 +23,10 @@ interface GenericObject { } export const useLoadNodes = () => { - return useRequest({ + return useRequest({ path: `nodes/list`, method: 'get', - initialData: [], + initialData: { nodesByAttributes: {}, nodesByRoles: {} } as ListNodesRouteResponse, }); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts index 3b71c11349752f..70f172de390e33 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts @@ -13,6 +13,8 @@ import { PhaseValidationErrors, positiveNumberRequiredMessage, } from './policy_validation'; +import { determineDataTierAllocationType } from '../../lib'; +import { serializePhaseWithAllocation } from './shared'; const coldPhaseInitialization: ColdPhase = { phaseEnabled: false, @@ -22,6 +24,7 @@ const coldPhaseInitialization: ColdPhase = { selectedReplicaCount: '', freezeEnabled: false, phaseIndexPriority: '', + dataTierAllocationType: 'default', }; export const coldPhaseFromES = (phaseSerialized?: SerializedColdPhase): ColdPhase => { @@ -32,6 +35,12 @@ export const coldPhaseFromES = (phaseSerialized?: SerializedColdPhase): ColdPhas phase.phaseEnabled = true; + if (phaseSerialized.actions.allocate) { + phase.dataTierAllocationType = determineDataTierAllocationType( + phaseSerialized.actions.allocate + ); + } + if (phaseSerialized.min_age) { const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); phase.selectedMinimumAge = minAge; @@ -80,19 +89,7 @@ export const coldPhaseToES = ( esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; } - esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; - - if (phase.selectedNodeAttrs) { - const [name, value] = phase.selectedNodeAttrs.split(':'); - esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); - esPhase.actions.allocate.require = { - [name]: value, - }; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.require; - } - } + esPhase.actions = serializePhaseWithAllocation(phase, esPhase.actions); if (isNumber(phase.selectedReplicaCount)) { esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/frozen_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/frozen_phase.ts index 6249507bcb4078..28d18b8f892635 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/frozen_phase.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/frozen_phase.ts @@ -13,6 +13,8 @@ import { PhaseValidationErrors, positiveNumberRequiredMessage, } from './policy_validation'; +import { determineDataTierAllocationType } from '../../lib'; +import { serializePhaseWithAllocation } from './shared'; const frozenPhaseInitialization: FrozenPhase = { phaseEnabled: false, @@ -22,6 +24,7 @@ const frozenPhaseInitialization: FrozenPhase = { selectedReplicaCount: '', freezeEnabled: false, phaseIndexPriority: '', + dataTierAllocationType: 'default', }; export const frozenPhaseFromES = (phaseSerialized?: SerializedFrozenPhase): FrozenPhase => { @@ -32,6 +35,12 @@ export const frozenPhaseFromES = (phaseSerialized?: SerializedFrozenPhase): Froz phase.phaseEnabled = true; + if (phaseSerialized.actions.allocate) { + phase.dataTierAllocationType = determineDataTierAllocationType( + phaseSerialized.actions.allocate + ); + } + if (phaseSerialized.min_age) { const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); phase.selectedMinimumAge = minAge; @@ -80,19 +89,7 @@ export const frozenPhaseToES = ( esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; } - esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; - - if (phase.selectedNodeAttrs) { - const [name, value] = phase.selectedNodeAttrs.split(':'); - esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); - esPhase.actions.allocate.require = { - [name]: value, - }; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.require; - } - } + esPhase.actions = serializePhaseWithAllocation(phase, esPhase.actions); if (isNumber(phase.selectedReplicaCount)) { esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts new file mode 100644 index 00000000000000..0e7257d437ee72 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts @@ -0,0 +1,464 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import cloneDeep from 'lodash/cloneDeep'; +import { serializePolicy } from './policy_serialization'; +import { + defaultNewColdPhase, + defaultNewDeletePhase, + defaultNewFrozenPhase, + defaultNewHotPhase, + defaultNewWarmPhase, +} from '../../constants'; +import { DataTierAllocationType } from '../../../../common/types'; + +describe('Policy serialization', () => { + test('serialize a policy using "default" data allocation', () => { + expect( + serializePolicy( + { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'default', + // These selected attrs should be ignored + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'default', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + frozen: { + ...defaultNewFrozenPhase, + dataTierAllocationType: 'default', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }, + { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + cold: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + frozen: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + }, + } + ) + ).toEqual({ + name: 'test', + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + warm: { + actions: { + set_priority: { + priority: 50, + }, + }, + }, + cold: { + actions: { + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + frozen: { + actions: { + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + }, + }); + }); + + test('serialize a policy using "custom" data allocation', () => { + expect( + serializePolicy( + { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + frozen: { + ...defaultNewFrozenPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }, + { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { something: 'here' }, + }, + }, + }, + cold: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { something: 'here' }, + }, + }, + }, + frozen: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { something: 'here' }, + }, + }, + }, + }, + } + ) + ).toEqual({ + name: 'test', + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + warm: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { + another: 'thing', + }, + }, + set_priority: { + priority: 50, + }, + }, + }, + cold: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { + another: 'thing', + }, + }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + frozen: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { + another: 'thing', + }, + }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + }, + }); + }); + + test('serialize a policy using "custom" data allocation with no node attributes', () => { + expect( + serializePolicy( + { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: '', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: '', + phaseEnabled: true, + }, + frozen: { + ...defaultNewFrozenPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: '', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }, + { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + cold: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + frozen: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + }, + } + ) + ).toEqual({ + // There should be no allocation action in any phases... + name: 'test', + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + warm: { + actions: { + allocate: { include: {}, exclude: {}, require: { something: 'here' } }, + set_priority: { + priority: 50, + }, + }, + }, + cold: { + actions: { + allocate: { include: {}, exclude: {}, require: { something: 'here' } }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + frozen: { + actions: { + allocate: { include: {}, exclude: {}, require: { something: 'here' } }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + }, + }); + }); + + test('serialize a policy using "none" data allocation with no node attributes', () => { + expect( + serializePolicy( + { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'none', + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'none', + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + frozen: { + ...defaultNewFrozenPhase, + dataTierAllocationType: 'none', + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }, + { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + cold: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + frozen: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + }, + } + ) + ).toEqual({ + // There should be no allocation action in any phases... + name: 'test', + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + warm: { + actions: { + migrate: { + enabled: false, + }, + set_priority: { + priority: 50, + }, + }, + }, + cold: { + actions: { + migrate: { + enabled: false, + }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + frozen: { + actions: { + migrate: { + enabled: false, + }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + }, + }); + }); + + test('serialization does not alter the original policy', () => { + const originalPolicy = { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + cold: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + frozen: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + }, + }; + + const originalClone = cloneDeep(originalPolicy); + + const deserializedPolicy = { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'none' as DataTierAllocationType, + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'none' as DataTierAllocationType, + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + frozen: { + ...defaultNewFrozenPhase, + dataTierAllocationType: 'none' as DataTierAllocationType, + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }; + + serializePolicy(deserializedPolicy, originalPolicy); + deserializedPolicy.phases.warm.dataTierAllocationType = 'custom'; + serializePolicy(deserializedPolicy, originalPolicy); + deserializedPolicy.phases.warm.dataTierAllocationType = 'default'; + serializePolicy(deserializedPolicy, originalPolicy); + expect(originalPolicy).toEqual(originalClone); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/index.ts new file mode 100644 index 00000000000000..fe97b85778a53f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { serializePhaseWithAllocation } from './serialize_phase_with_allocation'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/serialize_phase_with_allocation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/serialize_phase_with_allocation.ts new file mode 100644 index 00000000000000..5a9db3069aea61 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/serialize_phase_with_allocation.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import cloneDeep from 'lodash/cloneDeep'; + +import { + AllocateAction, + PhaseWithAllocationAction, + SerializedPhase, +} from '../../../../../common/types'; + +export const serializePhaseWithAllocation = ( + phase: PhaseWithAllocationAction, + originalPhaseActions: SerializedPhase['actions'] = {} +): SerializedPhase['actions'] => { + const esPhaseActions: SerializedPhase['actions'] = cloneDeep(originalPhaseActions); + + if (phase.dataTierAllocationType === 'custom' && phase.selectedNodeAttrs) { + const [name, value] = phase.selectedNodeAttrs.split(':'); + esPhaseActions.allocate = esPhaseActions.allocate || ({} as AllocateAction); + esPhaseActions.allocate.require = { + [name]: value, + }; + } else if (phase.dataTierAllocationType === 'none') { + esPhaseActions.migrate = { enabled: false }; + if (esPhaseActions.allocate) { + delete esPhaseActions.allocate; + } + } else if (phase.dataTierAllocationType === 'default') { + if (esPhaseActions.allocate) { + delete esPhaseActions.allocate.require; + } + delete esPhaseActions.migrate; + } + + return esPhaseActions; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts index cc815d67dbc18e..6971f652f986b2 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts @@ -16,6 +16,9 @@ import { positiveNumbersAboveZeroErrorMessage, } from './policy_validation'; +import { determineDataTierAllocationType } from '../../lib'; +import { serializePhaseWithAllocation } from './shared'; + const warmPhaseInitialization: WarmPhase = { phaseEnabled: false, warmPhaseOnRollover: false, @@ -28,6 +31,7 @@ const warmPhaseInitialization: WarmPhase = { forceMergeEnabled: false, selectedForceMergeSegments: '', phaseIndexPriority: '', + dataTierAllocationType: 'default', }; export const warmPhaseFromES = (phaseSerialized?: SerializedWarmPhase): WarmPhase => { @@ -39,6 +43,12 @@ export const warmPhaseFromES = (phaseSerialized?: SerializedWarmPhase): WarmPhas phase.phaseEnabled = true; + if (phaseSerialized.actions.allocate) { + phase.dataTierAllocationType = determineDataTierAllocationType( + phaseSerialized.actions.allocate + ); + } + if (phaseSerialized.min_age) { if (phaseSerialized.min_age === '0ms') { phase.warmPhaseOnRollover = true; @@ -99,19 +109,7 @@ export const warmPhaseToES = ( delete esPhase.min_age; } - esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; - - if (phase.selectedNodeAttrs) { - const [name, value] = phase.selectedNodeAttrs.split(':'); - esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); - esPhase.actions.allocate.require = { - [name]: value, - }; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.require; - } - } + esPhase.actions = serializePhaseWithAllocation(phase, esPhase.actions); if (isNumber(phase.selectedReplicaCount)) { esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts index b30a59c997e874..99df70e7df82d5 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/nodes/register_list_route.ts @@ -6,22 +6,45 @@ import { LegacyAPICaller } from 'src/core/server'; +import { ListNodesRouteResponse, NodeDataRole } from '../../../../common/types'; + import { RouteDependencies } from '../../../types'; import { addBasePath } from '../../../services'; -function convertStatsIntoList(stats: any, disallowedNodeAttributes: string[]): any { - return Object.entries(stats.nodes).reduce((accum: any, [nodeId, nodeStats]: [any, any]) => { - const attributes = nodeStats.attributes || {}; - for (const [key, value] of Object.entries(attributes)) { - const isNodeAttributeAllowed = !disallowedNodeAttributes.includes(key); - if (isNodeAttributeAllowed) { - const attributeString = `${key}:${value}`; - accum[attributeString] = accum[attributeString] || []; - accum[attributeString].push(nodeId); +interface Stats { + nodes: { + [nodeId: string]: { + attributes: Record; + roles: string[]; + }; + }; +} + +function convertStatsIntoList( + stats: Stats, + disallowedNodeAttributes: string[] +): ListNodesRouteResponse { + return Object.entries(stats.nodes).reduce( + (accum, [nodeId, nodeStats]) => { + const attributes = nodeStats.attributes || {}; + for (const [key, value] of Object.entries(attributes)) { + const isNodeAttributeAllowed = !disallowedNodeAttributes.includes(key); + if (isNodeAttributeAllowed) { + const attributeString = `${key}:${value}`; + accum.nodesByAttributes[attributeString] = accum.nodesByAttributes[attributeString] ?? []; + accum.nodesByAttributes[attributeString].push(nodeId); + } + } + + const dataRoles = nodeStats.roles.filter((r) => r.startsWith('data')) as NodeDataRole[]; + for (const role of dataRoles) { + accum.nodesByRoles[role as NodeDataRole] = accum.nodesByRoles[role] ?? []; + accum.nodesByRoles[role as NodeDataRole]!.push(nodeId); } - } - return accum; - }, {}); + return accum; + }, + { nodesByAttributes: {}, nodesByRoles: {} } as ListNodesRouteResponse + ); } async function fetchNodeStats(callAsCurrentUser: LegacyAPICaller): Promise { @@ -54,8 +77,8 @@ export function registerListRoute({ router, config, license, lib }: RouteDepende const stats = await fetchNodeStats( context.core.elasticsearch.legacy.client.callAsCurrentUser ); - const okResponse = { body: convertStatsIntoList(stats, disallowedNodeAttributes) }; - return response.ok(okResponse); + const body: ListNodesRouteResponse = convertStatsIntoList(stats, disallowedNodeAttributes); + return response.ok({ body }); } catch (e) { if (lib.isEsError(e)) { return response.customError({ diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts index ba6b8665479a95..5ef38a0e46dc39 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts @@ -40,6 +40,8 @@ const setPrioritySchema = schema.maybe( const unfollowSchema = schema.maybe(schema.object({})); // Unfollow has no options +const migrateSchema = schema.maybe(schema.object({ enabled: schema.literal(false) })); + const allocateNodeSchema = schema.maybe(schema.recordOf(schema.string(), schema.string())); const allocateSchema = schema.maybe( schema.object({ @@ -76,6 +78,7 @@ const warmPhaseSchema = schema.maybe( schema.object({ min_age: minAgeSchema, actions: schema.object({ + migrate: migrateSchema, set_priority: setPrioritySchema, unfollow: unfollowSchema, readonly: schema.maybe(schema.object({})), // Readonly has no options @@ -94,6 +97,7 @@ const coldPhaseSchema = schema.maybe( schema.object({ min_age: minAgeSchema, actions: schema.object({ + migrate: migrateSchema, set_priority: setPrioritySchema, unfollow: unfollowSchema, allocate: allocateSchema, @@ -111,6 +115,7 @@ const frozenPhaseSchema = schema.maybe( schema.object({ min_age: minAgeSchema, actions: schema.object({ + migrate: migrateSchema, set_priority: setPrioritySchema, unfollow: unfollowSchema, allocate: allocateSchema, diff --git a/x-pack/plugins/ml/common/index.ts b/x-pack/plugins/ml/common/index.ts index 791a7de48f36fd..9a415ac0718b35 100644 --- a/x-pack/plugins/ml/common/index.ts +++ b/x-pack/plugins/ml/common/index.ts @@ -5,3 +5,5 @@ */ export { SearchResponse7 } from './types/es_client'; +export { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from './constants/anomalies'; +export { getSeverityColor, getSeverityType } from './util/anomaly_utils'; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 2c5dbe108ab1e9..1cd52079b4e395 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -35,5 +35,8 @@ "dashboard", "savedObjects", "home" + ], + "extraPublicDirs": [ + "common" ] } diff --git a/x-pack/plugins/security_solution/cypress/.eslintrc.json b/x-pack/plugins/security_solution/cypress/.eslintrc.json index 96a5a52f13e6c7..a738652e2d27b1 100644 --- a/x-pack/plugins/security_solution/cypress/.eslintrc.json +++ b/x-pack/plugins/security_solution/cypress/.eslintrc.json @@ -2,5 +2,8 @@ "plugins": ["cypress"], "env": { "cypress/globals": true + }, + "rules": { + "import/no-extraneous-dependencies": "off" } } diff --git a/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts b/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts index 6438a738580b78..e09d62d2a87d16 100644 --- a/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts @@ -28,7 +28,7 @@ import { resetFields, } from '../tasks/fields_browser'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline } from '../tasks/security_main'; +import { openTimelineUsingToggle } from '../tasks/security_main'; import { openTimelineFieldsBrowser, populateTimeline } from '../tasks/timeline'; import { HOSTS_URL } from '../urls/navigation'; @@ -48,7 +48,7 @@ describe('Fields Browser', () => { context('Fields Browser rendering', () => { before(() => { loginAndWaitForPage(HOSTS_URL); - openTimeline(); + openTimelineUsingToggle(); populateTimeline(); openTimelineFieldsBrowser(); }); @@ -111,7 +111,7 @@ describe('Fields Browser', () => { context('Editing the timeline', () => { before(() => { loginAndWaitForPage(HOSTS_URL); - openTimeline(); + openTimelineUsingToggle(); populateTimeline(); openTimelineFieldsBrowser(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts b/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts index 53ddff501db823..c19e51c3ada408 100644 --- a/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts @@ -12,7 +12,7 @@ import { import { closesModal, openStatsAndTables } from '../tasks/inspect'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline } from '../tasks/security_main'; +import { openTimelineUsingToggle } from '../tasks/security_main'; import { executeTimelineKQL, openTimelineInspectButton, @@ -58,7 +58,7 @@ describe('Inspect', () => { it('inspects the timeline', () => { const hostExistsQuery = 'host.name: *'; loginAndWaitForPage(HOSTS_URL); - openTimeline(); + openTimelineUsingToggle(); executeTimelineKQL(hostExistsQuery); openTimelineSettings(); openTimelineInspectButton(); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts new file mode 100644 index 00000000000000..9f61d11b7ac0fd --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { timeline } from '../objects/timeline'; + +import { + FAVORITE_TIMELINE, + LOCKED_ICON, + NOTES, + NOTES_BUTTON, + NOTES_COUNT, + NOTES_TEXT_AREA, + PIN_EVENT, + TIMELINE_DESCRIPTION, + // TIMELINE_FILTER, + TIMELINE_QUERY, + TIMELINE_TITLE, +} from '../screens/timeline'; +import { + TIMELINES_DESCRIPTION, + TIMELINES_PINNED_EVENT_COUNT, + TIMELINES_NOTES_COUNT, + TIMELINES_FAVORITE, +} from '../screens/timelines'; + +import { loginAndWaitForPage } from '../tasks/login'; +import { openTimelineUsingToggle } from '../tasks/security_main'; +import { + addDescriptionToTimeline, + addFilter, + addNameToTimeline, + addNotesToTimeline, + closeNotes, + closeTimeline, + createNewTimeline, + markAsFavorite, + openTimelineFromSettings, + pinFirstEvent, + populateTimeline, + waitForTimelineChanges, +} from '../tasks/timeline'; +import { openTimeline } from '../tasks/timelines'; + +import { OVERVIEW_URL } from '../urls/navigation'; + +describe('Timelines', () => { + before(() => { + cy.server(); + cy.route('PATCH', '**/api/timeline').as('timeline'); + }); + + it('Creates a timeline', async () => { + loginAndWaitForPage(OVERVIEW_URL); + openTimelineUsingToggle(); + populateTimeline(); + addFilter(timeline.filter); + pinFirstEvent(); + + cy.get(PIN_EVENT).should('have.attr', 'aria-label', 'Pinned event'); + cy.get(LOCKED_ICON).should('be.visible'); + + addNameToTimeline(timeline.title); + + const response = await cy.wait('@timeline').promisify(); + const timelineId = JSON.parse(response.xhr.responseText).data.persistTimeline.timeline + .savedObjectId; + + addDescriptionToTimeline(timeline.description); + addNotesToTimeline(timeline.notes); + closeNotes(); + markAsFavorite(); + waitForTimelineChanges(); + createNewTimeline(); + closeTimeline(); + openTimelineFromSettings(); + + cy.contains(timeline.title).should('exist'); + cy.get(TIMELINES_DESCRIPTION).first().should('have.text', timeline.description); + cy.get(TIMELINES_PINNED_EVENT_COUNT).first().should('have.text', '1'); + cy.get(TIMELINES_NOTES_COUNT).first().should('have.text', '1'); + cy.get(TIMELINES_FAVORITE).first().should('exist'); + + openTimeline(timelineId); + + cy.get(FAVORITE_TIMELINE).should('exist'); + cy.get(TIMELINE_TITLE).should('have.attr', 'value', timeline.title); + cy.get(TIMELINE_DESCRIPTION).should('have.attr', 'value', timeline.description); + cy.get(TIMELINE_QUERY).should('have.text', timeline.query); + // Comments this assertion until we agreed what to do with the filters. + // cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); + cy.get(NOTES_COUNT).should('have.text', '1'); + cy.get(PIN_EVENT).should('have.attr', 'aria-label', 'Pinned event'); + cy.get(NOTES_BUTTON).click(); + cy.get(NOTES_TEXT_AREA).should('have.attr', 'placeholder', 'Add a Note'); + cy.get(NOTES).should('have.text', timeline.notes); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts index df0a26f3649c04..f62db083172a40 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts @@ -19,7 +19,7 @@ import { } from '../tasks/hosts/all_hosts'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline } from '../tasks/security_main'; +import { openTimelineUsingToggle } from '../tasks/security_main'; import { createNewTimeline } from '../tasks/timeline'; import { HOSTS_URL } from '../urls/navigation'; @@ -31,7 +31,7 @@ describe('timeline data providers', () => { }); beforeEach(() => { - openTimeline(); + openTimelineUsingToggle(); }); afterEach(() => { diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_events.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_events.spec.ts deleted file mode 100644 index 549cd134a04a44..00000000000000 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_events.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { PIN_EVENT } from '../screens/timeline'; - -import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline } from '../tasks/security_main'; -import { pinFirstEvent, populateTimeline, unpinFirstEvent } from '../tasks/timeline'; - -import { HOSTS_URL } from '../urls/navigation'; - -describe('timeline events', () => { - before(() => { - loginAndWaitForPage(HOSTS_URL); - openTimeline(); - populateTimeline(); - }); - - after(() => { - unpinFirstEvent(); - }); - - it('pins the first event to the timeline', () => { - cy.server(); - cy.route('POST', '**/api/solutions/security/graphql').as('persistTimeline'); - - pinFirstEvent(); - - cy.wait('@persistTimeline', { timeout: 10000 }).then((response) => { - cy.wrap(response.status).should('eql', 200); - cy.wrap(response.xhr.responseText).should('include', 'persistPinnedEventOnTimeline'); - }); - - cy.get(PIN_EVENT).should('have.attr', 'aria-label', 'Pinned event'); - }); -}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts index 87639f41d41097..9b3434b5521d4c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts @@ -8,7 +8,7 @@ import { TIMELINE_FLYOUT_HEADER, TIMELINE_NOT_READY_TO_DROP_BUTTON } from '../sc import { dragFirstHostToTimeline, waitForAllHostsToBeLoaded } from '../tasks/hosts/all_hosts'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline, openTimelineIfClosed } from '../tasks/security_main'; +import { openTimelineUsingToggle, openTimelineIfClosed } from '../tasks/security_main'; import { createNewTimeline } from '../tasks/timeline'; import { HOSTS_URL } from '../urls/navigation'; @@ -25,7 +25,7 @@ describe('timeline flyout button', () => { }); it('toggles open the timeline', () => { - openTimeline(); + openTimelineUsingToggle(); cy.get(TIMELINE_FLYOUT_HEADER).should('have.css', 'visibility', 'visible'); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_search_or_filter.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_search_or_filter.spec.ts index a2e2a72a17946b..814fcee2b0c5fd 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_search_or_filter.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_search_or_filter.spec.ts @@ -7,7 +7,7 @@ import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline } from '../tasks/security_main'; +import { openTimelineUsingToggle } from '../tasks/security_main'; import { executeTimelineKQL } from '../tasks/timeline'; import { HOSTS_URL } from '../urls/navigation'; @@ -19,7 +19,7 @@ describe('timeline search or filter KQL bar', () => { it('executes a KQL query', () => { const hostExistsQuery = 'host.name: *'; - openTimeline(); + openTimelineUsingToggle(); executeTimelineKQL(hostExistsQuery); cy.get(SERVER_SIDE_EVENT_COUNT) diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts index 12e6f3db9b61e3..e4f303fb89fdad 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts @@ -12,7 +12,7 @@ import { } from '../screens/timeline'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline } from '../tasks/security_main'; +import { openTimelineUsingToggle } from '../tasks/security_main'; import { checkIdToggleField, createNewTimeline, @@ -30,7 +30,7 @@ describe('toggle column in timeline', () => { }); beforeEach(() => { - openTimeline(); + openTimelineUsingToggle(); populateTimeline(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines_export.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines_export.spec.ts index d8f96aaf5e5639..103bbaad8f3039 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines_export.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines_export.spec.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { exportTimeline, waitForTimelinesPanelToBeLoaded } from '../tasks/timeline'; +import { exportTimeline, waitForTimelinesPanelToBeLoaded } from '../tasks/timelines'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; diff --git a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts index 6d605e1d577a9f..6c1d73492f30a7 100644 --- a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts @@ -30,7 +30,7 @@ import { openAllHosts } from '../tasks/hosts/main'; import { waitForIpsTableToBeLoaded } from '../tasks/network/flows'; import { clearSearchBar, kqlSearch, navigateFromHeaderTo } from '../tasks/security_header'; -import { openTimeline } from '../tasks/security_main'; +import { openTimelineUsingToggle } from '../tasks/security_main'; import { addDescriptionToTimeline, addNameToTimeline, @@ -82,7 +82,7 @@ describe('url state', () => { it('sets the timeline start and end dates from the url when locked to global time', () => { loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.url); - openTimeline(); + openTimelineUsingToggle(); cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE).should( 'have.attr', @@ -105,7 +105,7 @@ describe('url state', () => { ); cy.get(DATE_PICKER_END_DATE_POPOVER_BUTTON).should('have.attr', 'title', ABSOLUTE_DATE.endTime); - openTimeline(); + openTimelineUsingToggle(); cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE).should( 'have.attr', @@ -121,7 +121,7 @@ describe('url state', () => { it('sets the url state when timeline/global date pickers are unlinked and timeline start and end date are set', () => { loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.urlUnlinked); - openTimeline(); + openTimelineUsingToggle(); setTimelineStartDate(ABSOLUTE_DATE.newStartTimeTyped); updateTimelineDates(); setTimelineEndDate(ABSOLUTE_DATE.newEndTimeTyped); @@ -220,7 +220,7 @@ describe('url state', () => { it('sets and reads the url state for timeline by id', () => { loginAndWaitForPage(HOSTS_URL); - openTimeline(); + openTimelineUsingToggle(); executeTimelineKQL('host.name: *'); cy.get(SERVER_SIDE_EVENT_COUNT) diff --git a/x-pack/plugins/security_solution/cypress/objects/timeline.ts b/x-pack/plugins/security_solution/cypress/objects/timeline.ts index ff7e80e5661ad2..6121cb9a99b14d 100644 --- a/x-pack/plugins/security_solution/cypress/objects/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/objects/timeline.ts @@ -10,6 +10,30 @@ export interface Timeline { query: string; } +export interface CompleteTimeline extends Timeline { + notes: string; + filter: TimelineFilter; +} + +export interface TimelineFilter { + field: string; + operator: string; + value?: string; +} + export interface TimelineWithId extends Timeline { id: string; } + +export const filter: TimelineFilter = { + field: 'host.name', + operator: 'exists', +}; + +export const timeline: CompleteTimeline = { + title: 'Security Timeline', + description: 'This is the best timeline', + query: 'host.name: * ', + notes: 'Yes, the best timeline', + filter, +}; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index bcb64fc947feb6..94255a2af89761 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -4,6 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TimelineFilter } from '../objects/timeline'; + +export const ADD_NOTE_BUTTON = '[data-test-subj="add-note"]'; + +export const ADD_FILTER = '[data-test-subj="timeline"] [data-test-subj="addFilter"]'; + export const ATTACH_TIMELINE_TO_NEW_CASE_ICON = '[data-test-subj="attach-timeline-case"]'; export const ATTACH_TIMELINE_TO_EXISTING_CASE_ICON = @@ -15,14 +21,18 @@ export const CASE = (id: string) => { return `[data-test-subj="cases-table-row-${id}"]`; }; +export const CLOSE_NOTES_BTN = '[data-test-subj="notesModal"] .euiButtonIcon'; + export const CLOSE_TIMELINE_BTN = '[data-test-subj="close-timeline"]'; +export const COMBO_BOX = '.euiComboBoxOption__content'; + export const CREATE_NEW_TIMELINE = '[data-test-subj="timeline-new"]'; export const DRAGGABLE_HEADER = '[data-test-subj="events-viewer-panel"] [data-test-subj="headers-group"] [data-test-subj="draggable-header"]'; -export const EXPORT_TIMELINE_ACTION = '[data-test-subj="export-timeline-action"]'; +export const FAVORITE_TIMELINE = '[data-test-subj="timeline-favorite-filled-star"]'; export const HEADER = '[data-test-subj="header"]'; @@ -34,6 +44,16 @@ export const ID_FIELD = '[data-test-subj="timeline"] [data-test-subj="field-name export const ID_TOGGLE_FIELD = '[data-test-subj="toggle-field-_id"]'; +export const LOCKED_ICON = '[data-test-subj="timeline-date-picker-lock-button"]'; + +export const NOTES = '[data-test-subj="markdown-root"]'; + +export const NOTES_TEXT_AREA = '[data-test-subj="add-a-note"]'; + +export const NOTES_BUTTON = '[data-test-subj="timeline-notes-button-large"]'; + +export const NOTES_COUNT = '[data-test-subj="timeline-notes-count"]'; + export const OPEN_TIMELINE_ICON = '[data-test-subj="open-timeline-button"]'; export const PIN_EVENT = '[data-test-subj="pin"]'; @@ -45,21 +65,17 @@ export const REMOVE_COLUMN = '[data-test-subj="remove-column"]'; export const RESET_FIELDS = '[data-test-subj="events-viewer-panel"] [data-test-subj="reset-fields"]'; +export const SAVE_FILTER_BTN = '[data-test-subj="saveFilter"]'; + export const SEARCH_OR_FILTER_CONTAINER = '[data-test-subj="timeline-search-or-filter-search-container"]'; export const SERVER_SIDE_EVENT_COUNT = '[data-test-subj="server-side-event-count"]'; -export const TIMELINE = (id: string) => { - return `[data-test-subj="title-${id}"]`; -}; +export const STAR_ICON = '[data-test-subj="timeline-favorite-empty-star"]'; export const TIMELINE_CHANGES_IN_PROGRESS = '[data-test-subj="timeline"] .euiProgress'; -export const TIMELINE_CHECKBOX = (id: string) => { - return `[data-test-subj="checkboxSelectRow-${id}"]`; -}; - export const TIMELINE_COLUMN_SPINNER = '[data-test-subj="timeline-loading-spinner"]'; export const TIMELINE_DATA_PROVIDERS = '[data-test-subj="dataProviders"]'; @@ -74,6 +90,17 @@ export const TIMELINE_DROPPED_DATA_PROVIDERS = '[data-test-subj="providerContain export const TIMELINE_FIELDS_BUTTON = '[data-test-subj="timeline"] [data-test-subj="show-field-browser"]'; +export const TIMELINE_FILTER = (filter: TimelineFilter) => { + return `[data-test-subj="filter filter-enabled filter-key-${filter.field} filter-value-${filter.value} filter-unpinned"]`; +}; + +export const TIMELINE_FILTER_FIELD = '[data-test-subj="filterFieldSuggestionList"]'; + +export const TIMELINE_FILTER_OPERATOR = '[data-test-subj="filterOperatorList"]'; + +export const TIMELINE_FILTER_VALUE = + '[data-test-subj="filterParamsComboBox phraseParamsComboxBox"]'; + export const TIMELINE_FLYOUT_HEADER = '[data-test-subj="eui-flyout-header"]'; export const TIMELINE_FLYOUT_BODY = '[data-test-subj="eui-flyout-body"]'; @@ -89,8 +116,6 @@ export const TIMELINE_SETTINGS_ICON = '[data-test-subj="settings-gear"]'; export const TIMELINE_TITLE = '[data-test-subj="timeline-title"]'; -export const TIMELINES_TABLE = '[data-test-subj="timelines-table"]'; - export const TIMESTAMP_HEADER_FIELD = '[data-test-subj="header-text-@timestamp"]'; export const TIMESTAMP_TOGGLE_FIELD = '[data-test-subj="toggle-field-@timestamp"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/timelines.ts b/x-pack/plugins/security_solution/cypress/screens/timelines.ts new file mode 100644 index 00000000000000..e87e3c4f72ca50 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/timelines.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const BULK_ACTIONS = '[data-test-subj="utility-bar-action-button"]'; + +export const EXPORT_TIMELINE_ACTION = '[data-test-subj="export-timeline-action"]'; + +export const TIMELINE = (id: string) => { + return `[data-test-subj="title-${id}"]`; +}; + +export const TIMELINE_CHECKBOX = (id: string) => { + return `[data-test-subj="checkboxSelectRow-${id}"]`; +}; + +export const TIMELINES_FAVORITE = '[data-test-subj="favorite-starFilled-star"]'; + +export const TIMELINES_DESCRIPTION = '[data-test-subj="description"]'; + +export const TIMELINES_NOTES_COUNT = '[data-test-subj="notes-count"]'; + +export const TIMELINES_PINNED_EVENT_COUNT = '[data-test-subj="pinned-event-count"]'; + +export const TIMELINES_TABLE = '[data-test-subj="timelines-table"]'; + +export const TIMELINES_USERNAME = '[data-test-subj="username"]'; diff --git a/x-pack/plugins/security_solution/cypress/support/index.d.ts b/x-pack/plugins/security_solution/cypress/support/index.d.ts index f66aeff5d578d3..f0b0b8c92c6161 100644 --- a/x-pack/plugins/security_solution/cypress/support/index.d.ts +++ b/x-pack/plugins/security_solution/cypress/support/index.d.ts @@ -6,6 +6,7 @@ declare namespace Cypress { interface Chainable { + promisify(): Promise; stubSecurityApi(dataFileName: string): Chainable; stubSearchStrategyApi(dataFileName: string): Chainable; attachFile(fileName: string, fileType?: string): Chainable; diff --git a/x-pack/plugins/security_solution/cypress/support/index.js b/x-pack/plugins/security_solution/cypress/support/index.js index 244781e0ccd014..1328bea0e29181 100644 --- a/x-pack/plugins/security_solution/cypress/support/index.js +++ b/x-pack/plugins/security_solution/cypress/support/index.js @@ -21,6 +21,7 @@ // Import commands.js using ES2015 syntax: import './commands'; +import 'cypress-promise/register'; Cypress.Cookies.defaults({ preserve: 'sid', diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 0daff52de70633..dc89a39d082bc6 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -55,7 +55,7 @@ import { EQL_TYPE, EQL_QUERY_INPUT, } from '../screens/create_new_rule'; -import { TIMELINE } from '../screens/timeline'; +import { TIMELINE } from '../screens/timelines'; export const createAndActivateRule = () => { cy.get(SCHEDULE_CONTINUE_BUTTON).click({ force: true }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts index 47b73db8b96dfe..6b1f3699d333a7 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts @@ -6,14 +6,14 @@ import { MAIN_PAGE, TIMELINE_TOGGLE_BUTTON } from '../screens/security_main'; -export const openTimeline = () => { +export const openTimelineUsingToggle = () => { cy.get(TIMELINE_TOGGLE_BUTTON).click(); }; export const openTimelineIfClosed = () => { cy.get(MAIN_PAGE).then(($page) => { if ($page.find(TIMELINE_TOGGLE_BUTTON).length === 1) { - openTimeline(); + openTimelineUsingToggle(); } }); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index cd8b197fc4dec3..438700bdfca80a 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -4,36 +4,47 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TimelineFilter } from '../objects/timeline'; + import { ALL_CASES_CREATE_NEW_CASE_TABLE_BTN } from '../screens/all_cases'; + import { - BULK_ACTIONS, + ADD_FILTER, + ADD_NOTE_BUTTON, + ATTACH_TIMELINE_TO_EXISTING_CASE_ICON, + ATTACH_TIMELINE_TO_NEW_CASE_ICON, + CASE, CLOSE_TIMELINE_BTN, + CLOSE_NOTES_BTN, + COMBO_BOX, CREATE_NEW_TIMELINE, - EXPORT_TIMELINE_ACTION, - TIMELINE_CHECKBOX, HEADER, ID_FIELD, ID_HEADER_FIELD, ID_TOGGLE_FIELD, + NOTES_BUTTON, + NOTES_TEXT_AREA, + OPEN_TIMELINE_ICON, PIN_EVENT, + REMOVE_COLUMN, + RESET_FIELDS, + SAVE_FILTER_BTN, SEARCH_OR_FILTER_CONTAINER, SERVER_SIDE_EVENT_COUNT, + STAR_ICON, TIMELINE_CHANGES_IN_PROGRESS, TIMELINE_DESCRIPTION, TIMELINE_FIELDS_BUTTON, + TIMELINE_FILTER_FIELD, + TIMELINE_FILTER_OPERATOR, + TIMELINE_FILTER_VALUE, TIMELINE_INSPECT_BUTTON, TIMELINE_SETTINGS_ICON, TIMELINE_TITLE, - TIMELINES_TABLE, TIMESTAMP_TOGGLE_FIELD, TOGGLE_TIMELINE_EXPAND_EVENT, - REMOVE_COLUMN, - RESET_FIELDS, - ATTACH_TIMELINE_TO_NEW_CASE_ICON, - OPEN_TIMELINE_ICON, - ATTACH_TIMELINE_TO_EXISTING_CASE_ICON, - CASE, } from '../screens/timeline'; +import { TIMELINES_TABLE } from '../screens/timelines'; import { drag, drop } from '../tasks/common'; @@ -49,6 +60,24 @@ export const addNameToTimeline = (name: string) => { cy.get(TIMELINE_TITLE).should('have.attr', 'value', name); }; +export const addNotesToTimeline = (notes: string) => { + cy.get(NOTES_BUTTON).click(); + cy.get(NOTES_TEXT_AREA).type(notes); + cy.get(ADD_NOTE_BUTTON).click(); +}; + +export const addFilter = (filter: TimelineFilter) => { + cy.get(ADD_FILTER).click(); + cy.get(TIMELINE_FILTER_FIELD).type(filter.field); + cy.get(COMBO_BOX).contains(filter.field).click(); + cy.get(TIMELINE_FILTER_OPERATOR).type(filter.operator); + cy.get(COMBO_BOX).contains(filter.operator).click(); + if (filter.operator !== 'exists') { + cy.get(TIMELINE_FILTER_VALUE).type(`${filter.value}{enter}`); + } + cy.get(SAVE_FILTER_BTN).click(); +}; + export const addNewCase = () => { cy.get(ALL_CASES_CREATE_NEW_CASE_TABLE_BTN).click(); }; @@ -71,6 +100,10 @@ export const checkIdToggleField = () => { }); }; +export const closeNotes = () => { + cy.get(CLOSE_NOTES_BTN).click(); +}; + export const closeTimeline = () => { cy.get(CLOSE_TIMELINE_BTN).click({ force: true }); }; @@ -89,10 +122,8 @@ export const expandFirstTimelineEventDetails = () => { cy.get(TOGGLE_TIMELINE_EXPAND_EVENT).first().click({ force: true }); }; -export const exportTimeline = (timelineId: string) => { - cy.get(TIMELINE_CHECKBOX(timelineId)).click({ force: true }); - cy.get(BULK_ACTIONS).click({ force: true }); - cy.get(EXPORT_TIMELINE_ACTION).click(); +export const markAsFavorite = () => { + cy.get(STAR_ICON).click(); }; export const openTimelineFieldsBrowser = () => { @@ -160,11 +191,11 @@ export const selectCase = (caseId: string) => { cy.get(CASE(caseId)).click(); }; -export const waitForTimelinesPanelToBeLoaded = () => { - cy.get(TIMELINES_TABLE).should('exist'); -}; - export const waitForTimelineChanges = () => { cy.get(TIMELINE_CHANGES_IN_PROGRESS).should('exist'); cy.get(TIMELINE_CHANGES_IN_PROGRESS).should('not.exist'); }; + +export const waitForTimelinesPanelToBeLoaded = () => { + cy.get(TIMELINES_TABLE).should('exist'); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts new file mode 100644 index 00000000000000..1c5ce246a35b34 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + TIMELINE_CHECKBOX, + BULK_ACTIONS, + EXPORT_TIMELINE_ACTION, + TIMELINES_TABLE, + TIMELINE, +} from '../screens/timelines'; + +export const exportTimeline = (timelineId: string) => { + cy.get(TIMELINE_CHECKBOX(timelineId)).click({ force: true }); + cy.get(BULK_ACTIONS).click({ force: true }); + cy.get(EXPORT_TIMELINE_ACTION).click(); +}; + +export const openTimeline = (id: string) => { + cy.get(TIMELINE(id)).click(); +}; + +export const waitForTimelinesPanelToBeLoaded = () => { + cy.get(TIMELINES_TABLE).should('exist'); +}; diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index fd7941fb17cc58..6982c200a5afdc 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -1,4 +1,4 @@ -{ + { "author": "Elastic", "name": "security_solution", "version": "8.0.0", diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx index 71cf81c00dc09c..1bfeb482873c73 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx @@ -146,7 +146,7 @@ const AddDataProviderPopoverComponent: React.FC = ( = ( {`+ ${ADD_FIELD_LABEL}`} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 4ab05af5dd6d46..0f6535015c799b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -376,7 +376,11 @@ const NotesButtonComponent = React.memo( )} {size === 'l' && showNotes ? ( - + { const exitIfTimesToRunIsInvalid = (timesToRun) => { if (!timesToRun > 0) { console.error( - '\nERROR: You must specify a valid number of times to run the SIEM Cypress tests.' + '\nERROR: You must specify a valid number of times to run the Security Solution Cypress tests.' ); showUsage(); process.exit(1); @@ -44,7 +44,7 @@ const spawnChild = async () => { const child = spawn('node', [ 'scripts/functional_tests', '--config', - 'x-pack/test/security_solution_cypress/config.ts', + 'x-pack/test/security_solution_cypress/cli_config.ts', ]); for await (const chunk of child.stdout) { console.log(chunk.toString()); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx index fa4f8a7e09690e..c4adb9f1f49dee 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx @@ -8,7 +8,7 @@ import React, { Fragment, FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiCodeBlock, EuiForm, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiCodeBlock, EuiForm, EuiFormRow, EuiSpacer } from '@elastic/eui'; import { dictionaryToArray } from '../../../../../../common/types/common'; @@ -132,23 +132,21 @@ export const StepDefineSummary: FC = ({ - - - +
); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index dea5ff4a58b319..06fbed44438ffd 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7978,7 +7978,6 @@ "xpack.indexLifecycleMgmt.appTitle": "インデックスライフサイクルポリシー", "xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel": "インデックスを凍結", "xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasLabel": "複製の数", - "xpack.indexLifecycleMgmt.coldPhase.replicaCountHelpText": "デフォルトで、複製の数は同じままになります。", "xpack.indexLifecycleMgmt.confirmDelete.cancelButton": "キャンセル", "xpack.indexLifecycleMgmt.confirmDelete.deleteButton": "削除", "xpack.indexLifecycleMgmt.confirmDelete.errorMessage": "ポリシー {policyName} の削除中にエラーが発生しました", @@ -7986,7 +7985,6 @@ "xpack.indexLifecycleMgmt.confirmDelete.title": "ポリシー「{name}」が削除されました", "xpack.indexLifecycleMgmt.confirmDelete.undoneWarning": "削除されたポリシーは復元できません。", "xpack.indexLifecycleMgmt.editPolicy.cancelButton": "キャンセル", - "xpack.indexLifecycleMgmt.editPolicy.coldPhase.activateWarmPhaseSwitchLabel": "コールドフェーズを有効にする", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseDescriptionText": "インデックスへのクエリの頻度を減らすことで、大幅に性能が低いハードウェアにシャードを割り当てることができます。クエリが遅いため、複製の数を減らすことができます。", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseLabel": "コールドフェーズ", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeIndexExplanationText": "凍結されたインデックスはクラスターにほとんどオーバーヘッドがなく、書き込みオペレーションがブロックされます。凍結されたインデックスは検索できますが、クエリが遅くなります。", @@ -8032,7 +8030,6 @@ "xpack.indexLifecycleMgmt.editPolicy.maximumIndexSizeMissingError": "最大インデックスサイズが必要です。", "xpack.indexLifecycleMgmt.editPolicy.nameLabel": "名前", "xpack.indexLifecycleMgmt.editPolicy.nodeAllocationLabel": "シャードの割当をコントロールするノード属性を選択", - "xpack.indexLifecycleMgmt.editPolicy.nodeAttributesMissingDescription": "ノード属性なしではシャードの割り当てをコントロールできません。", "xpack.indexLifecycleMgmt.editPolicy.nodeAttributesMissingLabel": "elasticsearch.yml でノード属性が構成されていません", "xpack.indexLifecycleMgmt.editPolicy.numberRequiredError": "数字が必要です。", "xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeLabel": "コールドフェーズのタイミング", @@ -8188,7 +8185,6 @@ "xpack.indexLifecycleMgmt.warmPhase.numberOfPrimaryShardsLabel": "プライマリシャードの数", "xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasLabel": "レプリカの数", "xpack.indexLifecycleMgmt.warmPhase.numberOfSegmentsLabel": "セグメントの数", - "xpack.indexLifecycleMgmt.warmPhase.replicaCountHelpText": "デフォルトで、レプリカの数は同じままになります。", "xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel": "インデックスを縮小", "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "なし(グループなし)", "xpack.infra.alerting.alertFlyout.groupByLabel": "グループ分けの条件", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e31ba39527ae65..80d4f550a10155 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7982,7 +7982,6 @@ "xpack.indexLifecycleMgmt.appTitle": "索引生命周期策略", "xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel": "冻结索引", "xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasLabel": "副本分片数目", - "xpack.indexLifecycleMgmt.coldPhase.replicaCountHelpText": "默认情况下,副本分片数目仍一样。", "xpack.indexLifecycleMgmt.confirmDelete.cancelButton": "取消", "xpack.indexLifecycleMgmt.confirmDelete.deleteButton": "删除", "xpack.indexLifecycleMgmt.confirmDelete.errorMessage": "删除策略 {policyName} 时出错", @@ -7990,7 +7989,6 @@ "xpack.indexLifecycleMgmt.confirmDelete.title": "删除策略“{name}”", "xpack.indexLifecycleMgmt.confirmDelete.undoneWarning": "无法恢复删除的策略。", "xpack.indexLifecycleMgmt.editPolicy.cancelButton": "取消", - "xpack.indexLifecycleMgmt.editPolicy.coldPhase.activateWarmPhaseSwitchLabel": "激活冷阶段", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseDescriptionText": "您查询自己索引的频率较低,因此您可以在效率较低的硬件上分配分片。因为您的查询较为缓慢,所以您可以减少副本分片数目。", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseLabel": "冷阶段", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeIndexExplanationText": "冻结的索引在集群上有很少的开销,已被阻止进行写操作。您可以搜索冻结的索引,但查询应会较慢。", @@ -8036,7 +8034,6 @@ "xpack.indexLifecycleMgmt.editPolicy.maximumIndexSizeMissingError": "最大索引大小必填。", "xpack.indexLifecycleMgmt.editPolicy.nameLabel": "名称", "xpack.indexLifecycleMgmt.editPolicy.nodeAllocationLabel": "选择节点属性来控制分片分配", - "xpack.indexLifecycleMgmt.editPolicy.nodeAttributesMissingDescription": "没有节点属性,将无法控制分片分配。", "xpack.indexLifecycleMgmt.editPolicy.nodeAttributesMissingLabel": "elasticsearch.yml 中未配置任何节点属性", "xpack.indexLifecycleMgmt.editPolicy.numberRequiredError": "数字必填。", "xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeLabel": "冷阶段计时", @@ -8192,7 +8189,6 @@ "xpack.indexLifecycleMgmt.warmPhase.numberOfPrimaryShardsLabel": "主分片数目", "xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasLabel": "副本分片数目", "xpack.indexLifecycleMgmt.warmPhase.numberOfSegmentsLabel": "段数目", - "xpack.indexLifecycleMgmt.warmPhase.replicaCountHelpText": "默认情况下,副本分片数目仍一样。", "xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel": "缩小索引", "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "无内容(未分组)", "xpack.infra.alerting.alertFlyout.groupByLabel": "分组依据", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index c0674e6c4a5f72..6177262557e076 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -244,7 +244,7 @@ export const AlertForm = ({ ) : null} {AlertParamsExpressionComponent ? ( - + }> { expect(screen.getByText(/Go to URL/i)).toBeDisabled(); }); + +test('if action is beta, beta badge is shown', () => { + const betaUrl = new ActionFactory( + { + ...urlDrilldownActionFactory, + isBeta: true, + }, + { + getLicense: () => licensingMock.createLicense(), + getFeatureUsageStart: () => licensingMock.createStart().featureUsage, + } + ); + const screen = render(); + expect(screen.getByText(/Beta/i)).toBeVisible(); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx index 16d0250c5721ec..a3f6cac3ba1b48 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx @@ -19,9 +19,12 @@ import { EuiTextColor, EuiTitle, EuiLink, + EuiBetaBadge, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { + txtBetaActionFactoryLabel, + txtBetaActionFactoryTooltip, txtChangeButton, txtTriggerPickerHelpText, txtTriggerPickerLabel, @@ -255,7 +258,15 @@ const SelectedActionFactory: React.FC = ({ )} -

{actionFactory.getDisplayName(context)}

+

+ {actionFactory.getDisplayName(context)}{' '} + {actionFactory.isBeta && ( + + )} +

{showDeselect && ( @@ -350,6 +361,10 @@ const ActionFactorySelector: React.FC = ({ data-test-subj={`${TEST_SUBJ_ACTION_FACTORY_ITEM}-${actionFactory.id}`} onClick={() => onActionFactorySelected(actionFactory)} disabled={!actionFactory.isCompatibleLicense()} + betaBadgeLabel={actionFactory.isBeta ? txtBetaActionFactoryLabel : undefined} + betaBadgeTooltipContent={ + actionFactory.isBeta ? txtBetaActionFactoryTooltip : undefined + } > {actionFactory.getIconType(context) && ( diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/i18n.ts index f494ecfb51f32a..43a3bd01daf372 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/i18n.ts @@ -33,3 +33,17 @@ export const txtTriggerPickerHelpTooltip = i18n.translate( defaultMessage: 'Determines when the drilldown appears in context menu', } ); + +export const txtBetaActionFactoryLabel = i18n.translate( + 'xpack.uiActionsEnhanced.components.actionWizard.betaActionLabel', + { + defaultMessage: `Beta`, + } +); + +export const txtBetaActionFactoryTooltip = i18n.translate( + 'xpack.uiActionsEnhanced.components.actionWizard.betaActionTooltip', + { + defaultMessage: `This action is in beta and is subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features. Please help us by reporting any bugs or providing other feedback.`, + } +); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts index 8faccc088a3279..f5e565d4090ff0 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts @@ -36,6 +36,12 @@ export interface DrilldownDefinition< */ id: string; + /** + * Is this action factory not GA? + * Adds a beta badge on a list item representing this ActionFactory + */ + readonly isBeta?: boolean; + /** * Minimal license level * Empty means no restrictions diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts index 78f7218dce22e2..500ef21b61dc40 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts @@ -19,7 +19,8 @@ export const txtUrlTemplatePlaceholder = i18n.translate( export const txtUrlPreviewHelpText = i18n.translate( 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewHelpText', { - defaultMessage: 'Please note that \\{\\{event.*\\}\\} variables replaced by dummy values.', + defaultMessage: + 'Please note that in preview \\{\\{event.*\\}\\} variables are substituted with dummy values.', } ); diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts index 032a4a63fe2e9d..66a876bdbab855 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts @@ -114,3 +114,15 @@ describe('License & ActionFactory', () => { }); }); }); + +describe('isBeta', () => { + test('false by default', async () => { + const factory = createActionFactory(); + expect(factory.isBeta).toBe(false); + }); + + test('true', async () => { + const factory = createActionFactory({ isBeta: true }); + expect(factory.isBeta).toBe(true); + }); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts index 35a82adf9896dd..3ad6d4ee397493 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts @@ -46,6 +46,7 @@ export class ActionFactory< } public readonly id = this.def.id; + public readonly isBeta = this.def.isBeta ?? false; public readonly minimalLicense = this.def.minimalLicense; public readonly licenseFeatureName = this.def.licenseFeatureName; public readonly order = this.def.order || 0; diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts index 91b8c8ec1e5eff..7ec6b21485747b 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts @@ -46,6 +46,12 @@ export interface ActionFactoryDefinition< */ licenseFeatureName?: string; + /** + * Is this action factory not GA? + * Adds a beta badge on a list item representing this ActionFactory + */ + readonly isBeta?: boolean; + /** * This method should return a definition of a new action, normally used to * register it in `ui_actions` registry. diff --git a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts index b8086c16f5e714..ab0aa1200f5a78 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts @@ -89,6 +89,7 @@ export class UiActionsServiceEnhancements { ExecutionContext extends TriggerContextMapping[SupportedTriggers] = TriggerContextMapping[SupportedTriggers] >({ id: factoryId, + isBeta, order, CollectConfig, createConfig, @@ -109,6 +110,7 @@ export class UiActionsServiceEnhancements { ExecutionContext > = { id: factoryId, + isBeta, minimalLicense, licenseFeatureName, order, diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts index 02d39bba42659b..79fc8470a8ec2d 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts @@ -50,6 +50,8 @@ export function defineRoutes( includedHiddenTypes: ['alert'], }); const savedObjectsWithAlerts = await savedObjects.getScopedClient(req, { + // Exclude the security and spaces wrappers to get around the safeguards those have in place to prevent + // us from doing what we want to do - brute force replace the ApiKey excludedWrappers: ['security', 'spaces'], includedHiddenTypes: ['alert'], }); @@ -138,7 +140,13 @@ export function defineRoutes( const savedObjectsWithAlerts = await savedObjects.getScopedClient(req, { includedHiddenTypes: ['alert'], }); - const result = await savedObjectsWithAlerts.update(type, id, attributes, options); + const savedAlert = await savedObjectsWithAlerts.get(type, id); + const result = await savedObjectsWithAlerts.update( + type, + id, + { ...savedAlert.attributes, ...attributes }, + options + ); return res.ok({ body: result }); } ); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/webhook.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/webhook.ts index d82d116396cd65..ef14dd9ec2eff7 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/webhook.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/webhook.ts @@ -141,6 +141,77 @@ export default function webhookTest({ getService }: FtrProviderContext) { }); }); + it('should remove headers when a webhook is updated', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'test') + .send({ + name: 'A generic Webhook action', + actionTypeId: '.webhook', + secrets: { + user: 'username', + password: 'mypassphrase', + }, + config: { + url: webhookSimulatorURL, + headers: { + someHeader: '123', + }, + }, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + isPreconfigured: false, + name: 'A generic Webhook action', + actionTypeId: '.webhook', + config: { + ...defaultValues, + url: webhookSimulatorURL, + headers: { + someHeader: '123', + }, + }, + }); + + await supertest + .put(`/api/actions/action/${createdAction.id}`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'A generic Webhook action', + secrets: { + user: 'username', + password: 'mypassphrase', + }, + config: { + url: webhookSimulatorURL, + headers: { + someOtherHeader: '456', + }, + }, + }) + .expect(200); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/action/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + isPreconfigured: false, + name: 'A generic Webhook action', + actionTypeId: '.webhook', + config: { + ...defaultValues, + url: webhookSimulatorURL, + headers: { + someOtherHeader: '456', + }, + }, + }); + }); + it('should send authentication to the webhook target', async () => { const webhookActionId = await createWebhookAction(webhookSimulatorURL, {}, kibanaURL); const { body: result } = await supertest diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/nodes.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/nodes.js index bc8b2af4014232..bb35f6fd96429c 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/nodes.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/nodes.js @@ -29,7 +29,7 @@ export default function ({ getService }) { const nodesIds = Object.keys(nodeStats.nodes); const { body } = await loadNodes().expect(200); - expect(body[NODE_CUSTOM_ATTRIBUTE]).to.eql(nodesIds); + expect(body.nodesByAttributes[NODE_CUSTOM_ATTRIBUTE]).to.eql(nodesIds); }); }); diff --git a/x-pack/test/api_integration/config_security_basic.ts b/x-pack/test/api_integration/config_security_basic.ts index 84899405056867..237d162e803287 100644 --- a/x-pack/test/api_integration/config_security_basic.ts +++ b/x-pack/test/api_integration/config_security_basic.ts @@ -19,6 +19,7 @@ export default async function (context: FtrConfigProviderContext) { 'xpack.security.authc.api_key.enabled=true', ]; config.testFiles = [require.resolve('./apis/security/security_basic')]; + config.junit.reportName = 'X-Pack API Integration Tests (Security Basic)'; return config; }); } diff --git a/x-pack/test/api_integration/config_security_trial.ts b/x-pack/test/api_integration/config_security_trial.ts index 4c1e2913b987c1..839165d8618cf3 100644 --- a/x-pack/test/api_integration/config_security_trial.ts +++ b/x-pack/test/api_integration/config_security_trial.ts @@ -12,6 +12,7 @@ import { default as createTestConfig } from './config'; export default async function (context: FtrConfigProviderContext) { return createTestConfig(context).then((config) => { config.testFiles = [require.resolve('./apis/security/security_trial')]; + config.junit.reportName = 'X-Pack API Integration Tests (Security Trial)'; return config; }); } diff --git a/x-pack/test/apm_api_integration/trial/tests/service_maps/__snapshots__/service_maps.snap b/x-pack/test/apm_api_integration/trial/tests/service_maps/__snapshots__/service_maps.snap index 1424ca42539c08..199a49dce8f9e5 100644 --- a/x-pack/test/apm_api_integration/trial/tests/service_maps/__snapshots__/service_maps.snap +++ b/x-pack/test/apm_api_integration/trial/tests/service_maps/__snapshots__/service_maps.snap @@ -77,6 +77,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -104,6 +105,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -130,6 +132,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -156,6 +159,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -209,6 +213,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -255,6 +260,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -301,6 +307,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -407,6 +414,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -465,6 +473,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -491,6 +500,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -504,6 +514,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -523,6 +534,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -548,6 +560,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -573,6 +586,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -605,6 +619,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -687,6 +702,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -734,6 +750,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -748,6 +765,7 @@ Array [ "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -806,6 +824,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -833,6 +852,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -859,6 +879,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -885,6 +906,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -938,6 +960,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -984,6 +1007,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -1030,6 +1054,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -1136,6 +1161,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -1194,6 +1220,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -1220,6 +1247,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -1233,6 +1261,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -1252,6 +1281,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -1277,6 +1307,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -1302,6 +1333,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -1334,6 +1366,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -1416,6 +1449,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, @@ -1463,6 +1497,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -1477,6 +1512,7 @@ Object { "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, diff --git a/x-pack/test/apm_api_integration/trial/tests/service_maps/service_maps.ts b/x-pack/test/apm_api_integration/trial/tests/service_maps/service_maps.ts index be3301964bd3c2..11b5ca71e64e7e 100644 --- a/x-pack/test/apm_api_integration/trial/tests/service_maps/service_maps.ts +++ b/x-pack/test/apm_api_integration/trial/tests/service_maps/service_maps.ts @@ -165,6 +165,7 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext) "serviceAnomalyStats": Object { "actualValue": 3933482.1764705875, "anomalyScore": 2.6101702751482714, + "healthStatus": "healthy", "jobId": "apm-testing-d457-high_mean_transaction_duration", "transactionType": "request", }, @@ -179,6 +180,7 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext) "serviceAnomalyStats": Object { "actualValue": 684716.5813953485, "anomalyScore": 0.20498907719907372, + "healthStatus": "healthy", "jobId": "apm-production-229a-high_mean_transaction_duration", "transactionType": "request", }, diff --git a/x-pack/test/apm_api_integration/trial/tests/services/top_services.ts b/x-pack/test/apm_api_integration/trial/tests/services/top_services.ts index d08064f6aa70e0..c93816dfb48b90 100644 --- a/x-pack/test/apm_api_integration/trial/tests/services/top_services.ts +++ b/x-pack/test/apm_api_integration/trial/tests/services/top_services.ts @@ -45,24 +45,24 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(response.body.items.length).to.be.greaterThan(0); }); - it('some items have severity set', () => { + it('some items have a health status set', () => { // Under the assumption that the loaded archive has // at least one APM ML job, and the time range is longer - // than 15m, at least one items should have severity set. - // Note that we currently have a bug where healthy services - // report as unknown (so without any severity status): + // than 15m, at least one items should have a health status + // set. Note that we currently have a bug where healthy + // services report as unknown (so without any health status): // https://github.com/elastic/kibana/issues/77083 - const severityScores = response.body.items.map((item: any) => item.severity); + const healthStatuses = response.body.items.map((item: any) => item.healthStatus); - expect(severityScores.filter(Boolean).length).to.be.greaterThan(0); + expect(healthStatuses.filter(Boolean).length).to.be.greaterThan(0); - expectSnapshot(severityScores).toMatchInline(` + expectSnapshot(healthStatuses).toMatchInline(` Array [ undefined, undefined, - "warning", - "warning", + "healthy", + "healthy", undefined, undefined, undefined, diff --git a/x-pack/test/functional/config_security_basic.ts b/x-pack/test/functional/config_security_basic.ts index 48c397d9c37dec..968281b75b7ac9 100644 --- a/x-pack/test/functional/config_security_basic.ts +++ b/x-pack/test/functional/config_security_basic.ts @@ -70,7 +70,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { }, junit: { - reportName: 'Chrome X-Pack UI Functional Tests', + reportName: 'Chrome X-Pack UI Functional Tests (Security Basic)', }, }; } diff --git a/x-pack/test/security_api_integration/session_idle.config.ts b/x-pack/test/security_api_integration/session_idle.config.ts index da85c6342037e3..34a23b7f5f9263 100644 --- a/x-pack/test/security_api_integration/session_idle.config.ts +++ b/x-pack/test/security_api_integration/session_idle.config.ts @@ -36,7 +36,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { }, junit: { - reportName: 'X-Pack Security API Integration Tests', + reportName: 'X-Pack Security API Integration Tests (Session Idle Timeout)', }, }; } diff --git a/x-pack/test/security_api_integration/session_lifespan.config.ts b/x-pack/test/security_api_integration/session_lifespan.config.ts index 17773a7739847c..b5fdf6b6914b14 100644 --- a/x-pack/test/security_api_integration/session_lifespan.config.ts +++ b/x-pack/test/security_api_integration/session_lifespan.config.ts @@ -36,7 +36,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { }, junit: { - reportName: 'X-Pack Security API Integration Tests', + reportName: 'X-Pack Security API Integration Tests (Session Lifespan)', }, }; } diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts index f288bc925123e1..01e2ad76fb3d2d 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -33,15 +33,11 @@ export default function ({ getService }: FtrProviderContext) { return (await es.search({ index: '.kibana_security_session*' })).hits.total.value; } - // FLAKY: https://github.com/elastic/kibana/issues/76239 - describe.skip('Session Idle cleanup', () => { + describe('Session Idle cleanup', () => { beforeEach(async () => { await es.cluster.health({ index: '.kibana_security_session*', waitForStatus: 'green' }); - await es.deleteByQuery({ + await es.indices.delete({ index: '.kibana_security_session*', - q: '*', - waitForCompletion: true, - refresh: true, ignore: [404], }); }); diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts index dbdaf494fdf274..6036acf3d1cf1b 100644 --- a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts @@ -30,15 +30,11 @@ export default function ({ getService }: FtrProviderContext) { return (await es.search({ index: '.kibana_security_session*' })).hits.total.value; } - // FLAKY: https://github.com/elastic/kibana/issues/76223 - describe.skip('Session Lifespan cleanup', () => { + describe('Session Lifespan cleanup', () => { beforeEach(async () => { await es.cluster.health({ index: '.kibana_security_session*', waitForStatus: 'green' }); - await es.deleteByQuery({ + await es.indices.delete({ index: '.kibana_security_session*', - q: '*', - waitForCompletion: true, - refresh: true, ignore: [404], }); }); diff --git a/x-pack/test/security_functional/login_selector.config.ts b/x-pack/test/security_functional/login_selector.config.ts index 48665c93c091a1..bdb4778740503f 100644 --- a/x-pack/test/security_functional/login_selector.config.ts +++ b/x-pack/test/security_functional/login_selector.config.ts @@ -76,7 +76,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { screenshots: { directory: resolve(__dirname, 'screenshots') }, junit: { - reportName: 'Chrome X-Pack Security Functional Tests', + reportName: 'Chrome X-Pack Security Functional Tests (Login Selector)', }, }; } diff --git a/x-pack/test/security_functional/oidc.config.ts b/x-pack/test/security_functional/oidc.config.ts index 5fd59e049a0f43..1ed5d510984204 100644 --- a/x-pack/test/security_functional/oidc.config.ts +++ b/x-pack/test/security_functional/oidc.config.ts @@ -76,7 +76,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { screenshots: { directory: resolve(__dirname, 'screenshots') }, junit: { - reportName: 'Chrome X-Pack Security Functional Tests', + reportName: 'Chrome X-Pack Security Functional Tests (OpenID Connect)', }, }; } diff --git a/x-pack/test/security_functional/saml.config.ts b/x-pack/test/security_functional/saml.config.ts index c47145f8bc039a..9d925bee480a8c 100644 --- a/x-pack/test/security_functional/saml.config.ts +++ b/x-pack/test/security_functional/saml.config.ts @@ -70,7 +70,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { screenshots: { directory: resolve(__dirname, 'screenshots') }, junit: { - reportName: 'Chrome X-Pack Security Functional Tests', + reportName: 'Chrome X-Pack Security Functional Tests (SAML)', }, }; } diff --git a/yarn.lock b/yarn.lock index 7fb61b10c1bf7b..fa5f6ff1356701 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9941,6 +9941,11 @@ cypress-multi-reporters@^1.2.3: debug "^4.1.1" lodash "^4.17.11" +cypress-promise@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/cypress-promise/-/cypress-promise-1.1.0.tgz#f2d66965945fe198431aaf692d5157cea9d47b25" + integrity sha512-DhIf5PJ/a0iY+Yii6n7Rbwq+9TJxU4pupXYzf9mZd8nPG0AzQrj9i+pqINv4xbI2EV1p+PKW3maCkR7oPG4GrA== + cypress@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cypress/-/cypress-5.0.0.tgz#6957e299b790af8b1cd7bea68261b8935646f72e"