From 2106b69219c0f36daa330a4ebaecb8a4c3ca482b Mon Sep 17 00:00:00 2001 From: patrykkopycinski Date: Tue, 24 Mar 2020 14:26:24 +0100 Subject: [PATCH 01/56] [SIEM][Detection Engine] Add rule's notification alert type (#60832) --- .../legacy/plugins/siem/common/constants.ts | 8 + .../transform_actions.test.ts | 0 .../detection_engine}/transform_actions.ts | 4 +- .../siem/common/detection_engine/types.ts | 11 ++ .../notifications/add_tags.test.ts | 26 +++ .../notifications/add_tags.ts | 10 ++ .../notifications/build_signals_query.test.ts | 53 ++++++ .../notifications/build_signals_query.ts | 42 +++++ .../create_notifications.test.ts | 73 +++++++++ .../notifications/create_notifications.ts | 36 ++++ .../delete_notifications.test.ts | 141 ++++++++++++++++ .../notifications/delete_notifications.ts | 37 +++++ .../notifications/find_notifications.test.ts | 20 +++ .../notifications/find_notifications.ts | 37 +++++ .../notifications/get_signals_count.ts | 66 ++++++++ .../notifications/read_notifications.test.ts | 154 ++++++++++++++++++ .../notifications/read_notifications.ts | 48 ++++++ .../rules_notification_alert_type.test.ts | 142 ++++++++++++++++ .../rules_notification_alert_type.ts | 64 ++++++++ .../schedule_notification_actions.ts | 34 ++++ .../notifications/types.test.ts | 26 +++ .../detection_engine/notifications/types.ts | 107 ++++++++++++ .../update_notifications.test.ts | 137 ++++++++++++++++ .../notifications/update_notifications.ts | 64 ++++++++ .../notifications/utils.test.ts | 21 +++ .../detection_engine/notifications/utils.ts | 18 ++ .../routes/__mocks__/request_responses.ts | 68 +++++++- .../routes/rules/create_rules_route.test.ts | 15 ++ .../routes/rules/create_rules_route.ts | 13 ++ .../routes/rules/delete_rules_bulk_route.ts | 2 + .../routes/rules/delete_rules_route.ts | 2 + .../routes/rules/update_rules_route.ts | 11 ++ .../detection_engine/routes/rules/utils.ts | 2 +- .../add_prepackaged_rules_schema.test.ts | 3 +- .../schemas/create_rules_schema.test.ts | 3 +- .../schemas/import_rules_schema.test.ts | 3 +- .../routes/schemas/patch_rules_schema.test.ts | 3 +- .../schemas/update_rules_schema.test.ts | 3 +- .../detection_engine/rules/create_rules.ts | 4 +- .../rules/delete_rules.test.ts | 150 +++++++++++++++++ .../rules/patch_rules.test.ts | 55 ++++++- .../lib/detection_engine/rules/patch_rules.ts | 2 +- .../detection_engine/rules/read_rules.test.ts | 2 +- .../rules/update_rules.test.ts | 55 ++++++- .../detection_engine/rules/update_rules.ts | 2 +- .../signals/build_bulk_body.ts | 3 +- .../detection_engine/signals/build_rule.ts | 3 +- .../signals/bulk_create_ml_signals.ts | 3 +- .../signals/get_filter.test.ts | 14 ++ .../signals/search_after_bulk_create.ts | 3 +- .../signals/signal_rule_alert_type.ts | 47 +++++- .../signals/single_bulk_create.ts | 3 +- .../lib/detection_engine/signals/types.ts | 7 +- .../siem/server/lib/detection_engine/types.ts | 9 +- x-pack/legacy/plugins/siem/server/plugin.ts | 16 +- 55 files changed, 1850 insertions(+), 35 deletions(-) rename x-pack/legacy/plugins/siem/{server/lib/detection_engine/rules => common/detection_engine}/transform_actions.test.ts (100%) rename x-pack/legacy/plugins/siem/{server/lib/detection_engine/rules => common/detection_engine}/transform_actions.ts (83%) create mode 100644 x-pack/legacy/plugins/siem/common/detection_engine/types.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index c3fc4aea77863..ec720164e9bd7 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -52,12 +52,18 @@ export const IP_REPUTATION_LINKS_SETTING_DEFAULT = `[ */ export const SIGNALS_ID = `${APP_ID}.signals`; +/** + * Id for the notifications alerting type + */ +export const NOTIFICATIONS_ID = `${APP_ID}.notifications`; + /** * Special internal structure for tags for signals. This is used * to filter out tags that have internal structures within them. */ export const INTERNAL_IDENTIFIER = '__internal'; export const INTERNAL_RULE_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_id`; +export const INTERNAL_RULE_ALERT_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_alert_id`; export const INTERNAL_IMMUTABLE_KEY = `${INTERNAL_IDENTIFIER}_immutable`; /** @@ -87,3 +93,5 @@ export const DETECTION_ENGINE_QUERY_SIGNALS_URL = `${DETECTION_ENGINE_SIGNALS_UR * Common naming convention for an unauthenticated user */ export const UNAUTHENTICATED_USER = 'Unauthenticated'; + +export const NOTIFICATION_THROTTLE_RULE = 'rule'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.test.ts similarity index 100% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts rename to x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.test.ts diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts similarity index 83% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts rename to x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts index c1c17d2c70836..aeb4d53933022 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts +++ b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertAction } from '../../../../../../../plugins/alerting/common'; -import { RuleAlertAction } from '../types'; +import { AlertAction } from '../../../../../plugins/alerting/common'; +import { RuleAlertAction } from './types'; export const transformRuleToAlertAction = ({ group, diff --git a/x-pack/legacy/plugins/siem/common/detection_engine/types.ts b/x-pack/legacy/plugins/siem/common/detection_engine/types.ts new file mode 100644 index 0000000000000..0de370b11cdaf --- /dev/null +++ b/x-pack/legacy/plugins/siem/common/detection_engine/types.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 { AlertAction } from '../../../../../plugins/alerting/common'; + +export type RuleAlertAction = Omit & { + action_type_id: string; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts new file mode 100644 index 0000000000000..e14d20e3bc56e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts @@ -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 { addTags } from './add_tags'; +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +describe('add_tags', () => { + test('it should add a rule id as an internal structure', () => { + const tags = addTags([], 'rule-1'); + expect(tags).toEqual([`${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); + + test('it should not allow duplicate tags to be created', () => { + const tags = addTags(['tag-1', 'tag-1'], 'rule-1'); + expect(tags).toEqual(['tag-1', `${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); + + test('it should not allow duplicate internal tags to be created when called two times in a row', () => { + const tags1 = addTags(['tag-1'], 'rule-1'); + const tags2 = addTags(tags1, 'rule-1'); + expect(tags2).toEqual(['tag-1', `${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts new file mode 100644 index 0000000000000..6955e57d099be --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts @@ -0,0 +1,10 @@ +/* + * 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 { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +export const addTags = (tags: string[] = [], ruleAlertId: string): string[] => + Array.from(new Set([...tags, `${INTERNAL_RULE_ALERT_ID_KEY}:${ruleAlertId}`])); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts new file mode 100644 index 0000000000000..f83a8d40d6ae1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { buildSignalsSearchQuery } from './build_signals_query'; + +describe('buildSignalsSearchQuery', () => { + it('returns proper query object', () => { + const index = 'index'; + const ruleId = 'ruleId-12'; + const from = '123123123'; + const to = '1123123123'; + + expect( + buildSignalsSearchQuery({ + index, + from, + to, + ruleId, + }) + ).toEqual({ + index, + body: { + query: { + bool: { + filter: [ + { + bool: { + should: { + match: { + 'signal.rule.rule_id': ruleId, + }, + }, + minimum_should_match: 1, + }, + }, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts new file mode 100644 index 0000000000000..001650e5b2005 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts @@ -0,0 +1,42 @@ +/* + * 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. + */ + +interface BuildSignalsSearchQuery { + ruleId: string; + index: string; + from: string; + to: string; +} + +export const buildSignalsSearchQuery = ({ ruleId, index, from, to }: BuildSignalsSearchQuery) => ({ + index, + body: { + query: { + bool: { + filter: [ + { + bool: { + should: { + match: { + 'signal.rule.rule_id': ruleId, + }, + }, + minimum_should_match: 1, + }, + }, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, + }, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts new file mode 100644 index 0000000000000..dea42b0c852f9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { createNotifications } from './create_notifications'; + +describe('createNotifications', () => { + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('calls the alertsClient with proper params', async () => { + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + await createNotifications({ + alertsClient, + actions: [], + ruleAlertId, + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + ruleAlertId, + }), + }), + }) + ); + }); + + it('calls the alertsClient with transformed actions', async () => { + const action = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signalsCount}} signals' }, + action_type_id: '.slack', + }; + await createNotifications({ + alertsClient, + actions: [action], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + actions: expect.arrayContaining([ + { + group: action.group, + id: action.id, + params: action.params, + actionTypeId: '.slack', + }, + ]), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts new file mode 100644 index 0000000000000..3a1697f1c8afc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.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 { Alert } from '../../../../../../../plugins/alerting/common'; +import { APP_ID, NOTIFICATIONS_ID } from '../../../../common/constants'; +import { CreateNotificationParams } from './types'; +import { addTags } from './add_tags'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; + +export const createNotifications = async ({ + alertsClient, + actions, + enabled, + ruleAlertId, + interval, + name, + tags, +}: CreateNotificationParams): Promise => + alertsClient.create({ + data: { + name, + tags: addTags(tags, ruleAlertId), + alertTypeId: NOTIFICATIONS_ID, + consumer: APP_ID, + params: { + ruleAlertId, + }, + schedule: { interval }, + enabled, + actions: actions?.map(transformRuleToAlertAction), + throttle: null, + }, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts new file mode 100644 index 0000000000000..7e5c0eaf6286e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts @@ -0,0 +1,141 @@ +/* + * 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 { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { deleteNotifications } from './delete_notifications'; +import { readNotifications } from './read_notifications'; +jest.mock('./read_notifications'); + +describe('deleteNotifications', () => { + let alertsClient: ReturnType; + const notificationId = 'notification-52128c15-0d1b-4716-a4c5-46997ac7f3bd'; + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('should return null if notification was not found', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(result).toBe(null); + }); + + it('should call alertsClient.delete if notification was found', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: notificationId, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: notificationId }); + }); + + it('should call alertsClient.delete if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: null }); + }); + + it('should return null if alertsClient.delete rejects with 404 if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + alertsClient.delete.mockRejectedValue({ + output: { + statusCode: 404, + }, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual(null); + }); + + it('should return error object if alertsClient.delete rejects with status different than 404 and if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const errorObject = { + output: { + statusCode: 500, + }, + }; + + alertsClient.delete.mockRejectedValue(errorObject); + + let errorResult; + try { + await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + } catch (error) { + errorResult = error; + } + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(errorResult).toEqual(errorObject); + }); + + it('should return null if notification.id and id were null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteNotifications({ + alertsClient, + id: undefined, + ruleAlertId, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts new file mode 100644 index 0000000000000..7e244f96f1649 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts @@ -0,0 +1,37 @@ +/* + * 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 { readNotifications } from './read_notifications'; +import { DeleteNotificationParams } from './types'; + +export const deleteNotifications = async ({ + alertsClient, + id, + ruleAlertId, +}: DeleteNotificationParams) => { + const notification = await readNotifications({ alertsClient, id, ruleAlertId }); + if (notification == null) { + return null; + } + + if (notification.id != null) { + await alertsClient.delete({ id: notification.id }); + return notification; + } else if (id != null) { + try { + await alertsClient.delete({ id }); + return notification; + } catch (err) { + if (err.output.statusCode === 404) { + return null; + } else { + throw err; + } + } + } else { + return null; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts new file mode 100644 index 0000000000000..0e9e4a8370ec8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts @@ -0,0 +1,20 @@ +/* + * 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 { getFilter } from './find_notifications'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; + +describe('find_notifications', () => { + test('it returns a full filter with an AND if sent down', () => { + expect(getFilter('alert.attributes.enabled: true')).toEqual( + `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID} AND alert.attributes.enabled: true` + ); + }); + + test('it returns existing filter with no AND when not set', () => { + expect(getFilter(null)).toEqual(`alert.attributes.alertTypeId: ${NOTIFICATIONS_ID}`); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts new file mode 100644 index 0000000000000..fcdeda608fe4e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts @@ -0,0 +1,37 @@ +/* + * 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 { FindResult } from '../../../../../../../plugins/alerting/server'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; +import { FindNotificationParams } from './types'; + +export const getFilter = (filter: string | null | undefined) => { + if (filter == null) { + return `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID}`; + } else { + return `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID} AND ${filter}`; + } +}; + +export const findNotifications = async ({ + alertsClient, + perPage, + page, + fields, + filter, + sortField, + sortOrder, +}: FindNotificationParams): Promise => + alertsClient.find({ + options: { + fields, + page, + perPage, + filter: getFilter(filter), + sortOrder, + sortField, + }, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts new file mode 100644 index 0000000000000..6ae7922660bd7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts @@ -0,0 +1,66 @@ +/* + * 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 moment from 'moment'; +import { getNotificationResultsLink } from './utils'; +import { NotificationExecutorOptions } from './types'; +import { parseScheduleDates } from '../signals/utils'; +import { buildSignalsSearchQuery } from './build_signals_query'; + +interface SignalsCountResults { + signalsCount: string; + resultsLink: string; +} + +interface GetSignalsCount { + from: Date | string; + to: Date | string; + ruleAlertId: string; + ruleId: string; + index: string; + kibanaUrl: string | undefined; + callCluster: NotificationExecutorOptions['services']['callCluster']; +} + +export const getSignalsCount = async ({ + from, + to, + ruleAlertId, + ruleId, + index, + callCluster, + kibanaUrl = '', +}: GetSignalsCount): Promise => { + const fromMoment = moment.isDate(from) ? moment(from) : parseScheduleDates(from); + const toMoment = moment.isDate(to) ? moment(to) : parseScheduleDates(to); + + if (!fromMoment || !toMoment) { + throw new Error(`There was an issue with parsing ${from} or ${to} into Moment object`); + } + + const fromInMs = fromMoment.format('x'); + const toInMs = toMoment.format('x'); + + const query = buildSignalsSearchQuery({ + index, + ruleId, + to: toInMs, + from: fromInMs, + }); + + const result = await callCluster('count', query); + const resultsLink = getNotificationResultsLink({ + baseUrl: kibanaUrl, + id: ruleAlertId, + from: fromInMs, + to: toInMs, + }); + + return { + signalsCount: result.count, + resultsLink, + }; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts new file mode 100644 index 0000000000000..834ad2460959c --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { readNotifications } from './read_notifications'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { + getNotificationResult, + getFindNotificationsResultWithSingleHit, +} from '../routes/__mocks__/request_responses'; + +class TestError extends Error { + constructor() { + super(); + + this.name = 'CustomError'; + this.output = { statusCode: 404 }; + } + public output: { statusCode: number }; +} + +describe('read_notifications', () => { + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + describe('readNotifications', () => { + test('should return the output from alertsClient if id is set but ruleAlertId is undefined', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(getNotificationResult()); + }); + test('should return null if saved object found by alerts client given id is not alert type', async () => { + const result = getNotificationResult(); + delete result.alertTypeId; + alertsClient.get.mockResolvedValue(result); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + + test('should return error if alerts client throws 404 error on get', async () => { + alertsClient.get.mockImplementation(() => { + throw new TestError(); + }); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + + test('should return error if alerts client throws error on get', async () => { + alertsClient.get.mockImplementation(() => { + throw new Error('Test error'); + }); + try { + await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + } catch (exc) { + expect(exc.message).toEqual('Test error'); + } + }); + + test('should return the output from alertsClient if id is set but ruleAlertId is null', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: null, + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return the output from alertsClient if id is undefined but ruleAlertId is set', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return null if the output from alertsClient with ruleAlertId set is empty', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue({ data: [], page: 0, perPage: 1, total: 0 }); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(null); + }); + + test('should return the output from alertsClient if id is null but ruleAlertId is set', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: null, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return null if id and ruleAlertId are null', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: null, + ruleAlertId: null, + }); + expect(rule).toEqual(null); + }); + + test('should return null if id and ruleAlertId are undefined', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts new file mode 100644 index 0000000000000..87bdd6f3f40e1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts @@ -0,0 +1,48 @@ +/* + * 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 { SanitizedAlert } from '../../../../../../../plugins/alerting/common'; +import { ReadNotificationParams, isAlertType } from './types'; +import { findNotifications } from './find_notifications'; +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +export const readNotifications = async ({ + alertsClient, + id, + ruleAlertId, +}: ReadNotificationParams): Promise => { + if (id != null) { + try { + const notification = await alertsClient.get({ id }); + if (isAlertType(notification)) { + return notification; + } else { + return null; + } + } catch (err) { + if (err?.output?.statusCode === 404) { + return null; + } else { + // throw non-404 as they would be 500 or other internal errors + throw err; + } + } + } else if (ruleAlertId != null) { + const notificationFromFind = await findNotifications({ + alertsClient, + filter: `alert.attributes.tags: "${INTERNAL_RULE_ALERT_ID_KEY}:${ruleAlertId}"`, + page: 1, + }); + if (notificationFromFind.data.length === 0 || !isAlertType(notificationFromFind.data[0])) { + return null; + } else { + return notificationFromFind.data[0]; + } + } else { + // should never get here, and yet here we are. + return null; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts new file mode 100644 index 0000000000000..ff0126b129636 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -0,0 +1,142 @@ +/* + * 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 { savedObjectsClientMock } from 'src/core/server/mocks'; +import { loggerMock } from 'src/core/server/logging/logger.mock'; +import { getResult } from '../routes/__mocks__/request_responses'; +import { rulesNotificationAlertType } from './rules_notification_alert_type'; +import { buildSignalsSearchQuery } from './build_signals_query'; +import { AlertInstance } from '../../../../../../../plugins/alerting/server'; +import { NotificationExecutorOptions } from './types'; +jest.mock('./build_signals_query'); + +describe('rules_notification_alert_type', () => { + let payload: NotificationExecutorOptions; + let alert: ReturnType; + let alertInstanceMock: Record; + let alertInstanceFactoryMock: () => AlertInstance; + let savedObjectsClient: ReturnType; + let logger: ReturnType; + let callClusterMock: jest.Mock; + + beforeEach(() => { + alertInstanceMock = { + scheduleActions: jest.fn(), + replaceState: jest.fn(), + }; + alertInstanceMock.replaceState.mockReturnValue(alertInstanceMock); + alertInstanceFactoryMock = jest.fn().mockReturnValue(alertInstanceMock); + callClusterMock = jest.fn(); + savedObjectsClient = savedObjectsClientMock.create(); + logger = loggerMock.create(); + + payload = { + alertId: '1111', + services: { + savedObjectsClient, + alertInstanceFactory: alertInstanceFactoryMock, + callCluster: callClusterMock, + }, + params: { ruleAlertId: '2222' }, + state: {}, + spaceId: '', + name: 'name', + tags: [], + startedAt: new Date('2019-12-14T16:40:33.400Z'), + previousStartedAt: new Date('2019-12-13T16:40:33.400Z'), + createdBy: 'elastic', + updatedBy: 'elastic', + }; + + alert = rulesNotificationAlertType({ + logger, + }); + }); + + describe('executor', () => { + it('throws an error if rule alert was not found', async () => { + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + attributes: {}, + type: 'type', + references: [], + }); + await alert.executor(payload); + expect(logger.error).toHaveBeenCalledWith( + `Saved object for alert ${payload.params.ruleAlertId} was not found` + ); + }); + + it('should call buildSignalsSearchQuery with proper params', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 0, + }); + + await alert.executor(payload); + + expect(buildSignalsSearchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + from: '1576255233400', + index: '.siem-signals', + ruleId: 'rule-1', + to: '1576341633400', + }) + ); + }); + + it('should not call alertInstanceFactory if signalsCount was 0', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 0, + }); + + await alert.executor(payload); + + expect(alertInstanceFactoryMock).not.toHaveBeenCalled(); + }); + + it('should call scheduleActions if signalsCount was greater than 0', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 10, + }); + + await alert.executor(payload); + + expect(alertInstanceFactoryMock).toHaveBeenCalled(); + expect(alertInstanceMock.replaceState).toHaveBeenCalledWith( + expect.objectContaining({ signalsCount: 10 }) + ); + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( + 'default', + expect.objectContaining({ + rule: expect.objectContaining({ + name: ruleAlert.name, + }), + }) + ); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts new file mode 100644 index 0000000000000..c5dc4c3a27e16 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts @@ -0,0 +1,64 @@ +/* + * 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 { Logger } from 'src/core/server'; +import { schema } from '@kbn/config-schema'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; + +import { NotificationAlertTypeDefinition } from './types'; +import { getSignalsCount } from './get_signals_count'; +import { RuleAlertAttributes } from '../signals/types'; +import { siemRuleActionGroups } from '../signals/siem_rule_action_groups'; +import { scheduleNotificationActions } from './schedule_notification_actions'; + +export const rulesNotificationAlertType = ({ + logger, +}: { + logger: Logger; +}): NotificationAlertTypeDefinition => ({ + id: NOTIFICATIONS_ID, + name: 'SIEM Notifications', + actionGroups: siemRuleActionGroups, + defaultActionGroupId: 'default', + validate: { + params: schema.object({ + ruleAlertId: schema.string(), + }), + }, + async executor({ startedAt, previousStartedAt, alertId, services, params }) { + const ruleAlertSavedObject = await services.savedObjectsClient.get( + 'alert', + params.ruleAlertId + ); + + if (!ruleAlertSavedObject.attributes.params) { + logger.error(`Saved object for alert ${params.ruleAlertId} was not found`); + return; + } + + const { params: ruleAlertParams, name: ruleName } = ruleAlertSavedObject.attributes; + const ruleParams = { ...ruleAlertParams, name: ruleName }; + + const { signalsCount, resultsLink } = await getSignalsCount({ + from: previousStartedAt ?? `now-${ruleParams.interval}`, + to: startedAt, + index: ruleParams.outputIndex, + ruleId: ruleParams.ruleId!, + kibanaUrl: ruleAlertParams.meta?.kibanaUrl as string, + ruleAlertId: ruleAlertSavedObject.id, + callCluster: services.callCluster, + }); + + logger.info( + `Found ${signalsCount} signals using signal rule name: "${ruleParams.name}", id: "${params.ruleAlertId}", rule_id: "${ruleParams.ruleId}" in "${ruleParams.outputIndex}" index` + ); + + if (signalsCount) { + const alertInstance = services.alertInstanceFactory(alertId); + scheduleNotificationActions({ alertInstance, signalsCount, resultsLink, ruleParams }); + } + }, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts new file mode 100644 index 0000000000000..9c38c88a12411 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.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 { AlertInstance } from '../../../../../../../plugins/alerting/server'; +import { RuleTypeParams } from '../types'; + +type NotificationRuleTypeParams = RuleTypeParams & { + name: string; +}; + +interface ScheduleNotificationActions { + alertInstance: AlertInstance; + signalsCount: string; + resultsLink: string; + ruleParams: NotificationRuleTypeParams; +} + +export const scheduleNotificationActions = ({ + alertInstance, + signalsCount, + resultsLink, + ruleParams, +}: ScheduleNotificationActions): AlertInstance => + alertInstance + .replaceState({ + signalsCount, + }) + .scheduleActions('default', { + resultsLink, + rule: ruleParams, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts new file mode 100644 index 0000000000000..4fce037b483d5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts @@ -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 { loggerMock } from 'src/core/server/logging/logger.mock'; +import { getNotificationResult, getResult } from '../routes/__mocks__/request_responses'; +import { isAlertTypes, isNotificationAlertExecutor } from './types'; +import { rulesNotificationAlertType } from './rules_notification_alert_type'; + +describe('types', () => { + it('isAlertTypes should return true if is RuleNotificationAlertType type', () => { + expect(isAlertTypes([getNotificationResult()])).toEqual(true); + }); + + it('isAlertTypes should return false if is not RuleNotificationAlertType', () => { + expect(isAlertTypes([getResult()])).toEqual(false); + }); + + it('isNotificationAlertExecutor should return true it passed object is NotificationAlertTypeDefinition type', () => { + expect( + isNotificationAlertExecutor(rulesNotificationAlertType({ logger: loggerMock.create() })) + ).toEqual(true); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts new file mode 100644 index 0000000000000..edcd821353bc8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts @@ -0,0 +1,107 @@ +/* + * 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 { + AlertsClient, + PartialAlert, + AlertType, + State, + AlertExecutorOptions, +} from '../../../../../../../plugins/alerting/server'; +import { Alert } from '../../../../../../../plugins/alerting/common'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; + +export interface RuleNotificationAlertType extends Alert { + params: { + ruleAlertId: string; + }; +} + +export interface FindNotificationParams { + alertsClient: AlertsClient; + perPage?: number; + page?: number; + sortField?: string; + filter?: string; + fields?: string[]; + sortOrder?: 'asc' | 'desc'; +} + +export interface FindNotificationsRequestParams { + per_page: number; + page: number; + search?: string; + sort_field?: string; + filter?: string; + fields?: string[]; + sort_order?: 'asc' | 'desc'; +} + +export interface Clients { + alertsClient: AlertsClient; +} + +export type UpdateNotificationParams = Omit & { + actions: RuleAlertAction[]; + id?: string; + tags?: string[]; + interval: string | null; + ruleAlertId: string; +} & Clients; + +export type DeleteNotificationParams = Clients & { + id?: string; + ruleAlertId?: string; +}; + +export interface NotificationAlertParams { + actions: RuleAlertAction[]; + enabled: boolean; + ruleAlertId: string; + interval: string; + name: string; + tags?: string[]; + throttle?: null; +} + +export type CreateNotificationParams = NotificationAlertParams & Clients; + +export interface ReadNotificationParams { + alertsClient: AlertsClient; + id?: string | null; + ruleAlertId?: string | null; +} + +export const isAlertTypes = ( + partialAlert: PartialAlert[] +): partialAlert is RuleNotificationAlertType[] => { + return partialAlert.every(rule => isAlertType(rule)); +}; + +export const isAlertType = ( + partialAlert: PartialAlert +): partialAlert is RuleNotificationAlertType => { + return partialAlert.alertTypeId === NOTIFICATIONS_ID; +}; + +export type NotificationExecutorOptions = Omit & { + params: { + ruleAlertId: string; + }; +}; + +// This returns true because by default a NotificationAlertTypeDefinition is an AlertType +// since we are only increasing the strictness of params. +export const isNotificationAlertExecutor = ( + obj: NotificationAlertTypeDefinition +): obj is AlertType => { + return true; +}; + +export type NotificationAlertTypeDefinition = Omit & { + executor: ({ services, params, state }: NotificationExecutorOptions) => Promise; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts new file mode 100644 index 0000000000000..e1b452c911443 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { updateNotifications } from './update_notifications'; +import { readNotifications } from './read_notifications'; +import { createNotifications } from './create_notifications'; +import { getNotificationResult } from '../routes/__mocks__/request_responses'; +jest.mock('./read_notifications'); +jest.mock('./create_notifications'); + +describe('updateNotifications', () => { + const notification = getNotificationResult(); + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('should update the existing notification if interval provided', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + + await updateNotifications({ + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: notification.id, + data: expect.objectContaining({ + params: expect.objectContaining({ + ruleAlertId: 'new-rule-id', + }), + }), + }) + ); + }); + + it('should create a new notification if did not exist', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + + const params = { + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }; + + await updateNotifications(params); + + expect(createNotifications).toHaveBeenCalledWith(expect.objectContaining(params)); + }); + + it('should delete notification if notification was found and interval is null', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + + await updateNotifications({ + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: null, + name: '', + tags: [], + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notification.id, + }) + ); + }); + + it('should call the alertsClient with transformed actions', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + const action = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signalsCount}} signals' }, + action_type_id: '.slack', + }; + await updateNotifications({ + alertsClient, + actions: [action], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + actions: expect.arrayContaining([ + { + group: action.group, + id: action.id, + params: action.params, + actionTypeId: '.slack', + }, + ]), + }), + }) + ); + }); + + it('returns null if notification was not found and interval was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + const result = await updateNotifications({ + alertsClient, + actions: [], + enabled: true, + id: notification.id, + ruleAlertId, + name: notification.name, + tags: notification.tags, + interval: null, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts new file mode 100644 index 0000000000000..3197d21c0e95a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts @@ -0,0 +1,64 @@ +/* + * 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 { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { readNotifications } from './read_notifications'; +import { UpdateNotificationParams } from './types'; +import { addTags } from './add_tags'; +import { createNotifications } from './create_notifications'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; + +export const updateNotifications = async ({ + alertsClient, + actions, + enabled, + id, + ruleAlertId, + name, + tags, + interval, +}: UpdateNotificationParams): Promise => { + const notification = await readNotifications({ alertsClient, id, ruleAlertId }); + + if (interval && notification) { + const result = await alertsClient.update({ + id: notification.id, + data: { + tags: addTags(tags, ruleAlertId), + name, + schedule: { + interval, + }, + actions: actions?.map(transformRuleToAlertAction), + params: { + ruleAlertId, + }, + throttle: null, + }, + }); + return result; + } + + if (interval && !notification) { + const result = await createNotifications({ + alertsClient, + enabled, + tags, + name, + interval, + actions, + ruleAlertId, + }); + return result; + } + + if (!interval && notification) { + await alertsClient.delete({ id: notification.id }); + return null; + } + + return null; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts new file mode 100644 index 0000000000000..4c3f311d10acc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts @@ -0,0 +1,21 @@ +/* + * 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 { getNotificationResultsLink } from './utils'; + +describe('utils', () => { + it('getNotificationResultsLink', () => { + const resultLink = getNotificationResultsLink({ + baseUrl: 'http://localhost:5601', + id: 'notification-id', + from: '00000', + to: '1111', + }); + expect(resultLink).toEqual( + `http://localhost:5601/app/siem#/detections/rules/id/notification-id?timerange=(global:(linkTo:!(timeline),timerange:(from:00000,kind:absolute,to:1111)),timeline:(linkTo:!(global),timerange:(from:00000,kind:absolute,to:1111)))` + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts new file mode 100644 index 0000000000000..ed502d31d2fb5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts @@ -0,0 +1,18 @@ +/* + * 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 getNotificationResultsLink = ({ + baseUrl, + id, + from, + to, +}: { + baseUrl: string; + id: string; + from: string; + to: string; +}) => + `${baseUrl}/app/siem#/detections/rules/id/${id}?timerange=(global:(linkTo:!(timeline),timerange:(from:${from},kind:absolute,to:${to})),timeline:(linkTo:!(global),timerange:(from:${from},kind:absolute,to:${to})))`; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 0e0ab58a7a199..6435410f31797 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -28,6 +28,7 @@ import { } from '../../rules/types'; import { RuleAlertParamsRest, PrepackagedRules } from '../../types'; import { requestMock } from './request'; +import { RuleNotificationAlertType } from '../../notifications/types'; export const mockPrepackagedRule = (): PrepackagedRules => ({ rule_id: 'rule-1', @@ -204,11 +205,11 @@ export const getPrepackagedRulesStatusRequest = () => path: `${DETECTION_ENGINE_PREPACKAGED_URL}/_status`, }); -export interface FindHit { +export interface FindHit { page: number; perPage: number; total: number; - data: RuleAlertType[]; + data: T[]; } export const getEmptyFindResult = (): FindHit => ({ @@ -309,6 +310,27 @@ export const createMlRuleRequest = () => { }); }; +export const createRuleWithActionsRequest = () => { + const payload = typicalPayload(); + + return requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: { + ...payload, + throttle: '5m', + actions: [ + { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signalsCount}} signals' }, + action_type_id: '.slack', + }, + ], + }, + }); +}; + export const getSetSignalStatusByIdsRequest = () => requestMock.create({ method: 'post', @@ -616,3 +638,45 @@ export const getEmptyIndex = (): { _shards: Partial } => ({ export const getNonEmptyIndex = (): { _shards: Partial } => ({ _shards: { total: 1 }, }); + +export const getNotificationResult = (): RuleNotificationAlertType => ({ + id: '200dbf2f-b269-4bf9-aa85-11ba32ba73ba', + name: 'Notification for Rule Test', + tags: ['__internal_rule_alert_id:85b64e8a-2e40-4096-86af-5ac172c10825'], + alertTypeId: 'siem.notifications', + consumer: 'siem', + params: { + ruleAlertId: '85b64e8a-2e40-4096-86af-5ac172c10825', + }, + schedule: { + interval: '5m', + }, + enabled: true, + actions: [ + { + actionTypeId: '.slack', + params: { + message: 'Rule generated {{state.signalsCount}} signals\n\n{{rule.name}}\n{{resultsLink}}', + }, + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + }, + ], + throttle: null, + apiKey: null, + apiKeyOwner: 'elastic', + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: new Date('2020-03-21T11:15:13.530Z'), + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: '62b3a130-6b70-11ea-9ce9-6b9818c4cbd7', + updatedAt: new Date('2020-03-21T12:37:08.730Z'), +}); + +export const getFindNotificationsResultWithSingleHit = (): FindHit => ({ + page: 1, + perPage: 1, + total: 1, + data: [getNotificationResult()], +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 1a4e19c2047b5..14592dd499d43 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -15,10 +15,13 @@ import { getEmptyIndex, getFindResultWithSingleHit, createMlRuleRequest, + createRuleWithActionsRequest, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesRoute } from './create_rules_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +import { createNotifications } from '../../notifications/create_notifications'; +jest.mock('../../notifications/create_notifications'); describe('create_rules', () => { let server: ReturnType; @@ -65,6 +68,18 @@ describe('create_rules', () => { }); }); + describe('creating a Notification if throttle and actions were provided ', () => { + it('is successful', async () => { + const response = await server.inject(createRuleWithActionsRequest(), context); + expect(response.status).toEqual(200); + expect(createNotifications).toHaveBeenCalledWith( + expect.objectContaining({ + ruleAlertId: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + }); + describe('unhappy paths', () => { test('it returns a 400 if the index does not exist', async () => { clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index cee9054cf922e..1fbbb5274d738 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -17,6 +17,7 @@ import { transformValidate } from './validate'; import { getIndexExists } from '../../index/get_index_exists'; import { createRulesSchema } from '../schemas/create_rules_schema'; import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; +import { createNotifications } from '../../notifications/create_notifications'; export const createRulesRoute = (router: IRouter): void => { router.post( @@ -131,6 +132,18 @@ export const createRulesRoute = (router: IRouter): void => { version: 1, lists, }); + + if (throttle && actions.length) { + await createNotifications({ + alertsClient, + enabled, + name, + interval, + actions, + ruleAlertId: createdRule.id, + }); + } + const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts index c56f34588cbc6..85cfeefdceead 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts @@ -16,6 +16,7 @@ import { DeleteRulesRequestParams, } from '../../rules/types'; import { deleteRules } from '../../rules/delete_rules'; +import { deleteNotifications } from '../../notifications/delete_notifications'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; type Config = RouteConfig; @@ -57,6 +58,7 @@ export const deleteRulesBulkRoute = (router: IRouter) => { ruleId, }); if (rule != null) { + await deleteNotifications({ alertsClient, ruleAlertId: rule.id }); const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts index 753b281dbc09e..6fd50abd9364a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts @@ -16,6 +16,7 @@ import { IRuleSavedAttributesSavedObjectAttributes, } from '../../rules/types'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; +import { deleteNotifications } from '../../notifications/delete_notifications'; export const deleteRulesRoute = (router: IRouter) => { router.delete( @@ -52,6 +53,7 @@ export const deleteRulesRoute = (router: IRouter) => { ruleId, }); if (rule != null) { + await deleteNotifications({ alertsClient, ruleAlertId: rule.id }); const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index 7e56c32ade92a..f8cca6494e000 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -16,6 +16,7 @@ import { getIdError } from './utils'; import { transformValidate } from './validate'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { updateRules } from '../../rules/update_rules'; +import { updateNotifications } from '../../notifications/update_notifications'; export const updateRulesRoute = (router: IRouter) => { router.put( @@ -117,7 +118,17 @@ export const updateRulesRoute = (router: IRouter) => { version, lists, }); + if (rule != null) { + await updateNotifications({ + alertsClient, + actions, + enabled, + ruleAlertId: rule.id, + interval: throttle, + name, + }); + const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index e0ecbdedaac7c..a0458dc3a133d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -29,7 +29,7 @@ import { OutputError, } from '../utils'; import { hasListsFeature } from '../../feature_flags'; -import { transformAlertToRuleAction } from '../../rules/transform_actions'; +import { transformAlertToRuleAction } from '../../../../../common/detection_engine/transform_actions'; type PromiseFromStreams = ImportRuleAlertRest | Error; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts index 2b18e1b9bf52c..b10627d151fa2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts @@ -5,7 +5,8 @@ */ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; -import { ThreatParams, PrepackagedRules, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, PrepackagedRules } from '../../types'; import { addPrepackagedRulesSchema } from './add_prepackaged_rules_schema'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts index d9c3055512815..08bd01ee9a1a0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { createRulesSchema } from './create_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, RuleAlertParamsRest } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts index ffb49896ef7c7..c8e5bb981f921 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts @@ -10,7 +10,8 @@ import { importRulesQuerySchema, importRulesPayloadSchema, } from './import_rules_schema'; -import { ThreatParams, ImportRuleAlertRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, ImportRuleAlertRest } from '../../types'; import { ImportRulesRequestParams } from '../../rules/types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts index 42945e0970cba..45b5028f392b9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { patchRulesSchema } from './patch_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('patch rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts index db3709cd6b126..6f6beea7fa5fb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { updateRulesSchema } from './update_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, RuleAlertParamsRest } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts index db70b90d5a17c..a45b28ba3e105 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts @@ -6,12 +6,12 @@ import { Alert } from '../../../../../../../plugins/alerting/common'; import { APP_ID, SIGNALS_ID } from '../../../../common/constants'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { CreateRuleParams } from './types'; import { addTags } from './add_tags'; import { hasListsFeature } from '../feature_flags'; -import { transformRuleToAlertAction } from './transform_actions'; -export const createRules = ({ +export const createRules = async ({ alertsClient, actionsClient, // TODO: Use this actionsClient once we have actions such as email, etc... actions, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts new file mode 100644 index 0000000000000..38fc1dc5d1930 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { deleteRules } from './delete_rules'; +import { readRules } from './read_rules'; +jest.mock('./read_rules'); + +describe('deleteRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + const notificationId = 'notification-52128c15-0d1b-4716-a4c5-46997ac7f3bd'; + const ruleId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + }); + + it('should return null if notification was not found', async () => { + (readRules as jest.Mock).mockResolvedValue(null); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId, + }); + + expect(result).toBe(null); + }); + + it('should call alertsClient.delete if notification was found', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: notificationId, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: notificationId }); + }); + + it('should call alertsClient.delete if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: null }); + }); + + it('should return null if alertsClient.delete rejects with 404 if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + alertsClient.delete.mockRejectedValue({ + output: { + statusCode: 404, + }, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual(null); + }); + + it('should return error object if alertsClient.delete rejects with status different than 404 and if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const errorObject = { + output: { + statusCode: 500, + }, + }; + + alertsClient.delete.mockRejectedValue(errorObject); + + let errorResult; + try { + await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + } catch (error) { + errorResult = error; + } + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(errorResult).toEqual(errorObject); + }); + + it('should return null if ruleId and id was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: undefined, + ruleId: null, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts index b424d2912ebc8..cd18bee6f606f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts @@ -7,7 +7,7 @@ import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; -import { getMlResult } from '../routes/__mocks__/request_responses'; +import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { patchRules } from './patch_rules'; describe('patchRules', () => { @@ -21,6 +21,59 @@ describe('patchRules', () => { savedObjectsClient = savedObjectsClientMock.create(); }); + it('should call alertsClient.disable is the rule was enabled and enabled is false', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue(getResult()); + + await patchRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: false, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.disable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + + it('should call alertsClient.enable is the rule was disabled and enabled is true', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue({ + ...getResult(), + enabled: false, + }); + + await patchRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: true, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.enable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + it('calls the alertsClient with ML params', async () => { alertsClient.get.mockResolvedValue(getMlResult()); const params = { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts index 5b6fd08a9ea89..5394af526c917 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -6,12 +6,12 @@ import { defaults } from 'lodash/fp'; import { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { readRules } from './read_rules'; import { PatchRuleParams, IRuleSavedAttributesSavedObjectAttributes } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion, calculateName, calculateInterval } from './utils'; -import { transformRuleToAlertAction } from './transform_actions'; export const patchRules = async ({ alertsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts index 862ea9d2dcbe5..38a883329318b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts @@ -8,7 +8,7 @@ import { readRules } from './read_rules'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { getResult, getFindResultWithSingleHit } from '../routes/__mocks__/request_responses'; -class TestError extends Error { +export class TestError extends Error { constructor() { // Pass remaining arguments (including vendor specific ones) to parent constructor super(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts index 967a32df20c3b..af00816abfc3d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts @@ -7,7 +7,7 @@ import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; -import { getMlResult } from '../routes/__mocks__/request_responses'; +import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { updateRules } from './update_rules'; describe('updateRules', () => { @@ -21,6 +21,59 @@ describe('updateRules', () => { savedObjectsClient = savedObjectsClientMock.create(); }); + it('should call alertsClient.disable is the rule was enabled and enabled is false', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue(getResult()); + + await updateRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: false, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.disable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + + it('should call alertsClient.enable is the rule was disabled and enabled is true', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue({ + ...getResult(), + enabled: false, + }); + + await updateRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: true, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.enable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + it('calls the alertsClient with ML params', async () => { alertsClient.get.mockResolvedValue(getMlResult()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index a80f986482010..72cbc959c0105 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -5,13 +5,13 @@ */ import { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { readRules } from './read_rules'; import { IRuleSavedAttributesSavedObjectAttributes, UpdateRuleParams } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion } from './utils'; import { hasListsFeature } from '../feature_flags'; -import { transformRuleToAlertAction } from './transform_actions'; export const updateRules = async ({ alertsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts index adbd5f81d372a..f485769dffabc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts @@ -8,7 +8,8 @@ import { SignalSourceHit, SignalHit } from './types'; import { buildRule } from './build_rule'; import { buildSignal } from './build_signal'; import { buildEventTypeSignal } from './build_event_type_signal'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; interface BuildBulkBodyParams { doc: SignalSourceHit; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts index e94ca18b186e4..1de80ca0b7eaf 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts @@ -5,7 +5,8 @@ */ import { pickBy } from 'lodash/fp'; -import { RuleTypeParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams, OutputRuleAlertRest } from '../types'; interface BuildRuleParams { ruleParams: RuleTypeParams; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts index 95adb90172404..66e9f42061658 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts @@ -9,7 +9,8 @@ import { SearchResponse } from 'elasticsearch'; import { Logger } from '../../../../../../../../src/core/server'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { singleBulkCreate } from './single_bulk_create'; import { AnomalyResults, Anomaly } from '../../machine_learning'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts index b49f43ce9e7ac..86d1278031695 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts @@ -510,6 +510,20 @@ describe('get_filter', () => { ).rejects.toThrow('savedId parameter should be defined'); }); + test('throws on machine learning query', async () => { + await expect( + getFilter({ + type: 'machine_learning', + filters: undefined, + language: undefined, + query: undefined, + savedId: 'some-id', + services: servicesMock, + index: undefined, + }) + ).rejects.toThrow('Unsupported Rule of type "machine_learning" supplied to getFilter'); + }); + test('it works with references and does not add indexes', () => { const esQuery = getQueryFilter( '(event.module:suricata and event.kind:alert) and suricata.eve.alert.signature_id: (2610182 or 2610183 or 2610184 or 2610185 or 2610186 or 2610187)', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts index a12778d5b8f16..4f1a187a82937 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -5,7 +5,8 @@ */ import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { Logger } from '../../../../../../../../src/core/server'; import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 89dcd3274ebed..03d48a6b27867 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -5,13 +5,17 @@ */ import { Logger } from 'src/core/server'; -import { SIGNALS_ID, DEFAULT_SEARCH_AFTER_PAGE_SIZE } from '../../../../common/constants'; +import { + SIGNALS_ID, + DEFAULT_SEARCH_AFTER_PAGE_SIZE, + NOTIFICATION_THROTTLE_RULE, +} from '../../../../common/constants'; import { buildEventsSearchQuery } from './build_events_query'; import { getInputIndex } from './get_input_output_index'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { getFilter } from './get_filter'; -import { SignalRuleAlertTypeDefinition, AlertAttributes } from './types'; +import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types'; import { getGapBetweenRuns } from './utils'; import { writeSignalRuleExceptionToSavedObject } from './write_signal_rule_exception_to_saved_object'; import { signalParamsSchema } from './signal_params_schema'; @@ -22,6 +26,8 @@ import { getCurrentStatusSavedObject } from './get_current_status_saved_object'; import { writeCurrentStatusSucceeded } from './write_current_status_succeeded'; import { findMlSignals } from './find_ml_signals'; import { bulkCreateMlSignals } from './bulk_create_ml_signals'; +import { getSignalsCount } from '../notifications/get_signals_count'; +import { scheduleNotificationActions } from '../notifications/schedule_notification_actions'; export const signalRulesAlertType = ({ logger, @@ -46,6 +52,7 @@ export const signalRulesAlertType = ({ index, filters, language, + meta, machineLearningJobId, outputIndex, savedId, @@ -53,7 +60,10 @@ export const signalRulesAlertType = ({ to, type, } = params; - const savedObject = await services.savedObjectsClient.get('alert', alertId); + const savedObject = await services.savedObjectsClient.get( + 'alert', + alertId + ); const ruleStatusSavedObjects = await getRuleStatusSavedObjects({ alertId, @@ -76,6 +86,7 @@ export const signalRulesAlertType = ({ enabled, schedule: { interval }, throttle, + params: ruleParams, } = savedObject.attributes; const updatedAt = savedObject.updated_at ?? ''; @@ -199,6 +210,36 @@ export const signalRulesAlertType = ({ } if (creationSucceeded) { + if (meta?.throttle === NOTIFICATION_THROTTLE_RULE && actions.length) { + const notificationRuleParams = { + ...ruleParams, + name, + }; + const { signalsCount, resultsLink } = await getSignalsCount({ + from: `now-${interval}`, + to: 'now', + index: ruleParams.outputIndex, + ruleId: ruleParams.ruleId!, + kibanaUrl: meta?.kibanaUrl as string, + ruleAlertId: savedObject.id, + callCluster: services.callCluster, + }); + + logger.info( + `Found ${signalsCount} signals using signal rule name: "${notificationRuleParams.name}", id: "${notificationRuleParams.ruleId}", rule_id: "${notificationRuleParams.ruleId}" in "${notificationRuleParams.outputIndex}" index` + ); + + if (signalsCount) { + const alertInstance = services.alertInstanceFactory(alertId); + scheduleNotificationActions({ + alertInstance, + signalsCount, + resultsLink, + ruleParams: notificationRuleParams, + }); + } + } + logger.debug( `Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}", output_index: "${outputIndex}"` ); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts index 333a938e09d45..e2e4471f609ac 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts @@ -8,7 +8,8 @@ import { countBy, isEmpty } from 'lodash'; import { performance } from 'perf_hooks'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; import { SignalSearchResponse, BulkResponse } from './types'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { generateId } from './utils'; import { buildBulkBody } from './build_bulk_body'; import { Logger } from '../../../../../../../../src/core/server'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts index 06acff825f68e..93c48ed38c7c4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RuleAlertParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleAlertParams, OutputRuleAlertRest } from '../types'; import { SearchResponse } from '../../types'; import { AlertType, @@ -159,3 +160,7 @@ export interface AlertAttributes { }; throttle: string | null; } + +export interface RuleAlertAttributes extends AlertAttributes { + params: RuleAlertParams; +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts index 2cbdc7db3ba64..aae8763a7ea39 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertAction } from '../../../../../../plugins/alerting/common'; import { CallAPIOptions } from '../../../../../../../src/core/server'; import { Filter } from '../../../../../../../src/plugins/data/server'; import { IRuleStatusAttributes } from './rules/types'; import { ListsDefaultArraySchema } from './routes/schemas/types/lists_default_array'; +import { RuleAlertAction } from '../../../common/detection_engine/types'; export type PartialFilter = Partial; @@ -24,10 +24,6 @@ export interface ThreatParams { technique: IMitreAttack[]; } -export type RuleAlertAction = Omit & { - action_type_id: string; -}; - // Notice below we are using lists: ListsDefaultArraySchema[]; which is coming directly from the response output section. // TODO: Eventually this whole RuleAlertParams will be replaced with io-ts. For now we can slowly strangle it out and reduce duplicate types // We don't have the input types defined through io-ts just yet but as we being introducing types from there we will more and more remove @@ -56,7 +52,7 @@ export interface RuleAlertParams { query: string | undefined | null; references: string[]; savedId?: string | undefined | null; - meta: Record | undefined | null; + meta: Record | undefined | null; severity: string; tags: string[]; to: string; @@ -123,6 +119,7 @@ export type OutputRuleAlertRest = RuleAlertParamsRest & { created_by: string | undefined | null; updated_by: string | undefined | null; immutable: boolean; + throttle: string | undefined | null; }; export type ImportRuleAlertRest = Omit & { diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index c505edc79bc76..7008872a6f3cd 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -27,6 +27,8 @@ import { compose } from './lib/compose/kibana'; import { initRoutes } from './routes'; import { isAlertExecutor } from './lib/detection_engine/signals/types'; import { signalRulesAlertType } from './lib/detection_engine/signals/signal_rule_alert_type'; +import { rulesNotificationAlertType } from './lib/detection_engine/notifications/rules_notification_alert_type'; +import { isNotificationAlertExecutor } from './lib/detection_engine/notifications/types'; import { noteSavedObjectType, pinnedEventSavedObjectType, @@ -151,12 +153,20 @@ export class Plugin { }); if (plugins.alerting != null) { - const type = signalRulesAlertType({ + const signalRuleType = signalRulesAlertType({ logger: this.logger, version: this.context.env.packageInfo.version, }); - if (isAlertExecutor(type)) { - plugins.alerting.registerType(type); + const ruleNotificationType = rulesNotificationAlertType({ + logger: this.logger, + }); + + if (isAlertExecutor(signalRuleType)) { + plugins.alerting.registerType(signalRuleType); + } + + if (isNotificationAlertExecutor(ruleNotificationType)) { + plugins.alerting.registerType(ruleNotificationType); } } From babefb5737ddd6cdd6b475dcf61d9d9788c8018a Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 24 Mar 2020 14:33:31 +0100 Subject: [PATCH 02/56] introduce StartServicesAccessor type for `CoreSetup.getStartServices` (#60748) * create StartServicesAccessor type * update generated doc * update usages to use new type * add missing public annotation --- .../kibana-plugin-core-public.app.mount.md | 2 +- ...ana-plugin-core-public.applicationsetup.md | 2 +- ...c.applicationsetup.registermountcontext.md | 2 +- ...ana-plugin-core-public.applicationstart.md | 2 +- ...c.applicationstart.registermountcontext.md | 2 +- ...bana-plugin-core-public.appmountcontext.md | 2 +- ...a-plugin-core-public.appmountdeprecated.md | 2 +- ...-core-public.coresetup.getstartservices.md | 10 +++------ .../kibana-plugin-core-public.coresetup.md | 7 +------ .../core/public/kibana-plugin-core-public.md | 3 ++- ...lugin-core-public.startservicesaccessor.md | 13 ++++++++++++ ...-core-server.coresetup.getstartservices.md | 10 +++------ .../kibana-plugin-core-server.coresetup.md | 7 +------ .../core/server/kibana-plugin-core-server.md | 1 + ...lugin-core-server.startservicesaccessor.md | 13 ++++++++++++ .../kibana-plugin-plugins-data-server.md | 2 ++ src/core/public/index.ts | 20 +++++++++++------- src/core/public/public.api.md | 6 +++++- src/core/server/index.ts | 21 ++++++++++++------- src/core/server/server.api.md | 6 +++++- .../public/management_app/index.tsx | 4 ++-- .../data/server/kql_telemetry/route.ts | 4 ++-- .../management/public/management_app.tsx | 4 ++-- .../management/public/management_section.ts | 6 +++--- .../management/public/management_service.ts | 6 +++--- .../server/saved_objects/index.ts | 11 +++++++--- .../account_management_app.ts | 6 +++--- .../authentication/authentication_service.ts | 4 ++-- .../logged_out/logged_out_app.ts | 11 +++++++--- .../public/authentication/login/login_app.ts | 11 +++++++--- .../overwritten_session_app.ts | 6 +++--- .../api_keys/api_keys_management_app.tsx | 4 ++-- .../public/management/management_service.ts | 4 ++-- .../role_mappings_management_app.tsx | 4 ++-- .../management/roles/roles_management_app.tsx | 4 ++-- .../management/users/users_management_app.tsx | 4 ++-- .../public/management/management_service.tsx | 4 ++-- .../management/spaces_management_app.tsx | 4 ++-- .../space_selector/space_selector_app.tsx | 6 +++--- 39 files changed, 144 insertions(+), 96 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md diff --git a/docs/development/core/public/kibana-plugin-core-public.app.mount.md b/docs/development/core/public/kibana-plugin-core-public.app.mount.md index c42f73ced95af..8a9dfd9e2e972 100644 --- a/docs/development/core/public/kibana-plugin-core-public.app.mount.md +++ b/docs/development/core/public/kibana-plugin-core-public.app.mount.md @@ -14,5 +14,5 @@ mount: AppMount | AppMountDeprecated ## Remarks -When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md index e5554be515077..fc99e2208220f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md @@ -17,5 +17,5 @@ export interface ApplicationSetup | --- | --- | | [register(app)](./kibana-plugin-core-public.applicationsetup.register.md) | Register an mountable application to the system. | | [registerAppUpdater(appUpdater$)](./kibana-plugin-core-public.applicationsetup.registerappupdater.md) | Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-core-public.appupdatablefields.md) fields of all applications at runtime.This is meant to be used by plugins that needs to updates the whole list of applications. To only updates a specific application, use the updater$ property of the registered application instead. | -| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationsetup.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). | +| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationsetup.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md index 92a7ae1c0deee..1735d5df943ae 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md @@ -8,7 +8,7 @@ > > -Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.md index 834411de5d57c..a93bc61bac527 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.md @@ -24,5 +24,5 @@ export interface ApplicationStart | --- | --- | | [getUrlForApp(appId, options)](./kibana-plugin-core-public.applicationstart.geturlforapp.md) | Returns an URL to a given app, including the global base path. By default, the URL is relative (/basePath/app/my-app). Use the absolute option to generate an absolute url (http://host:port/basePath/app/my-app)Note that when generating absolute urls, the protocol, host and port are determined from the browser location. | | [navigateToApp(appId, options)](./kibana-plugin-core-public.applicationstart.navigatetoapp.md) | Navigate to a given app | -| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationstart.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). | +| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationstart.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md index 6e0fbb46e9a1e..11f661c4af2b3 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md @@ -8,7 +8,7 @@ > > -Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md b/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md index d0b243859aab0..52a36b0b56f02 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md @@ -8,7 +8,7 @@ > > -The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md b/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md index 130689882495a..66b8a69d84a38 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md +++ b/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md @@ -18,5 +18,5 @@ export declare type AppMountDeprecated = (contex ## Remarks -When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md index 91b906cf83d01..e4fec4eae31b1 100644 --- a/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md +++ b/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md @@ -2,16 +2,12 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [CoreSetup](./kibana-plugin-core-public.coresetup.md) > [getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md) -## CoreSetup.getStartServices() method +## CoreSetup.getStartServices property -Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed `start`. +[StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) Signature: ```typescript -getStartServices(): Promise<[CoreStart, TPluginsStart]>; +getStartServices: StartServicesAccessor; ``` -Returns: - -`Promise<[CoreStart, TPluginsStart]>` - diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.md index f211b740e84a3..c039bc19348cc 100644 --- a/docs/development/core/public/kibana-plugin-core-public.coresetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.coresetup.md @@ -19,14 +19,9 @@ export interface CoreSetup | [application](./kibana-plugin-core-public.coresetup.application.md) | ApplicationSetup | [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) | | [context](./kibana-plugin-core-public.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-core-public.contextsetup.md) | | [fatalErrors](./kibana-plugin-core-public.coresetup.fatalerrors.md) | FatalErrorsSetup | [FatalErrorsSetup](./kibana-plugin-core-public.fatalerrorssetup.md) | +| [getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md) | StartServicesAccessor<TPluginsStart> | [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | | [http](./kibana-plugin-core-public.coresetup.http.md) | HttpSetup | [HttpSetup](./kibana-plugin-core-public.httpsetup.md) | | [injectedMetadata](./kibana-plugin-core-public.coresetup.injectedmetadata.md) | {
getInjectedVar: (name: string, defaultValue?: any) => unknown;
} | exposed temporarily until https://github.com/elastic/kibana/issues/41990 done use \*only\* to retrieve config values. There is no way to set injected values in the new platform. Use the legacy platform API instead. | | [notifications](./kibana-plugin-core-public.coresetup.notifications.md) | NotificationsSetup | [NotificationsSetup](./kibana-plugin-core-public.notificationssetup.md) | | [uiSettings](./kibana-plugin-core-public.coresetup.uisettings.md) | IUiSettingsClient | [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) | -## Methods - -| Method | Description | -| --- | --- | -| [getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | - diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index b8aa56eb2941b..adc87de2b9e7e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -38,7 +38,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [AppLeaveDefaultAction](./kibana-plugin-core-public.appleavedefaultaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-core-public.appleavehandler.md) to execute the default behaviour when leaving the application.See | | [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) | | | [ApplicationStart](./kibana-plugin-core-public.applicationstart.md) | | -| [AppMountContext](./kibana-plugin-core-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). | +| [AppMountContext](./kibana-plugin-core-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). | | [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) | | | [Capabilities](./kibana-plugin-core-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | | [ChromeBadge](./kibana-plugin-core-public.chromebadge.md) | | @@ -153,6 +153,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | | [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | | [SavedObjectsClientContract](./kibana-plugin-core-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) | +| [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | | [StringValidation](./kibana-plugin-core-public.stringvalidation.md) | Allows regex objects or a regex string | | [Toast](./kibana-plugin-core-public.toast.md) | | | [ToastInput](./kibana-plugin-core-public.toastinput.md) | Inputs for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. | diff --git a/docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md b/docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md new file mode 100644 index 0000000000000..02e896a6b47e5 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) + +## StartServicesAccessor type + +Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed `start`. + +Signature: + +```typescript +export declare type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md index 10a656363c0d0..ea8e610ee56de 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md @@ -2,16 +2,12 @@ [Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreSetup](./kibana-plugin-core-server.coresetup.md) > [getStartServices](./kibana-plugin-core-server.coresetup.getstartservices.md) -## CoreSetup.getStartServices() method +## CoreSetup.getStartServices property -Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed `start`. This should only be used inside handlers registered during `setup` that will only be executed after `start` lifecycle. +[StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) Signature: ```typescript -getStartServices(): Promise<[CoreStart, TPluginsStart]>; +getStartServices: StartServicesAccessor; ``` -Returns: - -`Promise<[CoreStart, TPluginsStart]>` - diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.md index 5b5803629cc86..b0eba8ac78063 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.md @@ -19,15 +19,10 @@ export interface CoreSetup | [capabilities](./kibana-plugin-core-server.coresetup.capabilities.md) | CapabilitiesSetup | [CapabilitiesSetup](./kibana-plugin-core-server.capabilitiessetup.md) | | [context](./kibana-plugin-core-server.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-core-server.contextsetup.md) | | [elasticsearch](./kibana-plugin-core-server.coresetup.elasticsearch.md) | ElasticsearchServiceSetup | [ElasticsearchServiceSetup](./kibana-plugin-core-server.elasticsearchservicesetup.md) | +| [getStartServices](./kibana-plugin-core-server.coresetup.getstartservices.md) | StartServicesAccessor<TPluginsStart> | [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | | [http](./kibana-plugin-core-server.coresetup.http.md) | HttpServiceSetup | [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) | | [metrics](./kibana-plugin-core-server.coresetup.metrics.md) | MetricsServiceSetup | [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) | | [savedObjects](./kibana-plugin-core-server.coresetup.savedobjects.md) | SavedObjectsServiceSetup | [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) | | [uiSettings](./kibana-plugin-core-server.coresetup.uisettings.md) | UiSettingsServiceSetup | [UiSettingsServiceSetup](./kibana-plugin-core-server.uisettingsservicesetup.md) | | [uuid](./kibana-plugin-core-server.coresetup.uuid.md) | UuidServiceSetup | [UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) | -## Methods - -| Method | Description | -| --- | --- | -| [getStartServices()](./kibana-plugin-core-server.coresetup.getstartservices.md) | Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed start. This should only be used inside handlers registered during setup that will only be executed after start lifecycle. | - diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 54cf496b2d6af..a1158dc853918 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -259,6 +259,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [SharedGlobalConfig](./kibana-plugin-core-server.sharedglobalconfig.md) | | +| [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed start. This should only be used inside handlers registered during setup that will only be executed after start lifecycle. | | [StringValidation](./kibana-plugin-core-server.stringvalidation.md) | Allows regex objects or a regex string | | [UiSettingsType](./kibana-plugin-core-server.uisettingstype.md) | UI element type to represent the settings. | diff --git a/docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md b/docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md new file mode 100644 index 0000000000000..4de781fc99cc1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) + +## StartServicesAccessor type + +Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed `start`. This should only be used inside handlers registered during `setup` that will only be executed after `start` lifecycle. + +Signature: + +```typescript +export declare type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index d179b9d9dcd82..e756eb9b72905 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -60,6 +60,7 @@ | [esQuery](./kibana-plugin-plugins-data-server.esquery.md) | | | [fieldFormats](./kibana-plugin-plugins-data-server.fieldformats.md) | | | [indexPatterns](./kibana-plugin-plugins-data-server.indexpatterns.md) | | +| [search](./kibana-plugin-plugins-data-server.search.md) | | ## Type Aliases @@ -69,5 +70,6 @@ | [IFieldFormatsRegistry](./kibana-plugin-plugins-data-server.ifieldformatsregistry.md) | | | [ISearch](./kibana-plugin-plugins-data-server.isearch.md) | | | [ISearchCancel](./kibana-plugin-plugins-data-server.isearchcancel.md) | | +| [ParsedInterval](./kibana-plugin-plugins-data-server.parsedinterval.md) | | | [TSearchStrategyProvider](./kibana-plugin-plugins-data-server.tsearchstrategyprovider.md) | Search strategy provider creates an instance of a search strategy with the request handler context bound to it. This way every search strategy can use whatever information they require from the request context. | diff --git a/src/core/public/index.ts b/src/core/public/index.ts index b91afa3ae7dc0..f72e115fd24ff 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -208,15 +208,21 @@ export interface CoreSetup { injectedMetadata: { getInjectedVar: (name: string, defaultValue?: any) => unknown; }; - - /** - * Allows plugins to get access to APIs available in start inside async - * handlers, such as {@link App.mount}. Promise will not resolve until Core - * and plugin dependencies have completed `start`. - */ - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + /** {@link StartServicesAccessor} */ + getStartServices: StartServicesAccessor; } +/** + * Allows plugins to get access to APIs available in start inside async + * handlers, such as {@link App.mount}. Promise will not resolve until Core + * and plugin dependencies have completed `start`. + * + * @public + */ +export type StartServicesAccessor = () => Promise< + [CoreStart, TPluginsStart] +>; + /** * Core services exposed to the `Plugin` start lifecycle * diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 37212a07ee631..eec12f2348176 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -378,7 +378,8 @@ export interface CoreSetup { context: ContextSetup; // (undocumented) fatalErrors: FatalErrorsSetup; - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + // (undocumented) + getStartServices: StartServicesAccessor; // (undocumented) http: HttpSetup; // @deprecated @@ -1235,6 +1236,9 @@ export class SimpleSavedObject { _version?: SavedObject['version']; } +// @public +export type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; + // @public export type StringValidation = StringValidationRegex | StringValidationRegexString; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 89fee92a7ef02..1b436bfd72622 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -352,15 +352,22 @@ export interface CoreSetup { uuid: UuidServiceSetup; /** {@link MetricsServiceSetup} */ metrics: MetricsServiceSetup; - /** - * Allows plugins to get access to APIs available in start inside async handlers. - * Promise will not resolve until Core and plugin dependencies have completed `start`. - * This should only be used inside handlers registered during `setup` that will only be executed - * after `start` lifecycle. - */ - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + /** {@link StartServicesAccessor} */ + getStartServices: StartServicesAccessor; } +/** + * Allows plugins to get access to APIs available in start inside async handlers. + * Promise will not resolve until Core and plugin dependencies have completed `start`. + * This should only be used inside handlers registered during `setup` that will only be executed + * after `start` lifecycle. + * + * @public + */ +export type StartServicesAccessor = () => Promise< + [CoreStart, TPluginsStart] +>; + /** * Context passed to the plugins `start` method. * diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 229ffc4d21575..6d4181e5e1ab3 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -629,7 +629,8 @@ export interface CoreSetup { context: ContextSetup; // (undocumented) elasticsearch: ElasticsearchServiceSetup; - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + // (undocumented) + getStartServices: StartServicesAccessor; // (undocumented) http: HttpServiceSetup; // (undocumented) @@ -2269,6 +2270,9 @@ export type SharedGlobalConfig = RecursiveReadonly_2<{ path: Pick; }>; +// @public +export type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; + // @public export type StringValidation = StringValidationRegex | StringValidationRegexString; diff --git a/src/plugins/advanced_settings/public/management_app/index.tsx b/src/plugins/advanced_settings/public/management_app/index.tsx index 27d3114051c16..53b8f9983aa27 100644 --- a/src/plugins/advanced_settings/public/management_app/index.tsx +++ b/src/plugins/advanced_settings/public/management_app/index.tsx @@ -24,7 +24,7 @@ import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; import { AdvancedSettings } from './advanced_settings'; import { ManagementSetup } from '../../../management/public'; -import { CoreSetup } from '../../../../core/public'; +import { StartServicesAccessor } from '../../../../core/public'; import { ComponentRegistry } from '../types'; const title = i18n.translate('advancedSettings.advancedSettingsLabel', { @@ -48,7 +48,7 @@ export async function registerAdvSettingsMgmntApp({ componentRegistry, }: { management: ManagementSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; componentRegistry: ComponentRegistry['start']; }) { const kibanaSection = management.sections.getSection('kibana'); diff --git a/src/plugins/data/server/kql_telemetry/route.ts b/src/plugins/data/server/kql_telemetry/route.ts index d5725c859c9a9..dd7ff333e6257 100644 --- a/src/plugins/data/server/kql_telemetry/route.ts +++ b/src/plugins/data/server/kql_telemetry/route.ts @@ -17,12 +17,12 @@ * under the License. */ -import { CoreSetup, IRouter, Logger } from 'kibana/server'; +import { StartServicesAccessor, IRouter, Logger } from 'kibana/server'; import { schema } from '@kbn/config-schema'; export function registerKqlTelemetryRoute( router: IRouter, - getStartServices: CoreSetup['getStartServices'], + getStartServices: StartServicesAccessor, logger: Logger ) { router.post( diff --git a/src/plugins/management/public/management_app.tsx b/src/plugins/management/public/management_app.tsx index 705d98eaaf2ff..38db1039042e5 100644 --- a/src/plugins/management/public/management_app.tsx +++ b/src/plugins/management/public/management_app.tsx @@ -26,7 +26,7 @@ import { KibanaLegacySetup } from '../../kibana_legacy/public'; import { LegacyManagementSection } from './legacy'; import { ManagementChrome } from './components'; import { ManagementSection } from './management_section'; -import { ChromeBreadcrumb, CoreSetup } from '../../../core/public/'; +import { ChromeBreadcrumb, StartServicesAccessor } from '../../../core/public/'; export class ManagementApp { readonly id: string; @@ -41,7 +41,7 @@ export class ManagementApp { getSections: () => ManagementSection[], registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], getLegacyManagementSections: () => LegacyManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { this.id = id; this.title = title; diff --git a/src/plugins/management/public/management_section.ts b/src/plugins/management/public/management_section.ts index 2f323c4b6a9cf..483605341ae4c 100644 --- a/src/plugins/management/public/management_section.ts +++ b/src/plugins/management/public/management_section.ts @@ -19,7 +19,7 @@ import { CreateSection, RegisterManagementAppArgs } from './types'; import { KibanaLegacySetup } from '../../kibana_legacy/public'; -import { CoreSetup } from '../../../core/public'; +import { StartServicesAccessor } from '../../../core/public'; // @ts-ignore import { LegacyManagementSection } from './legacy'; import { ManagementApp } from './management_app'; @@ -34,14 +34,14 @@ export class ManagementSection { private readonly getSections: () => ManagementSection[]; private readonly registerLegacyApp: KibanaLegacySetup['registerLegacyApp']; private readonly getLegacyManagementSection: () => LegacyManagementSection; - private readonly getStartServices: CoreSetup['getStartServices']; + private readonly getStartServices: StartServicesAccessor; constructor( { id, title, order = 100, euiIconType, icon }: CreateSection, getSections: () => ManagementSection[], registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], getLegacyManagementSection: () => ManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { this.id = id; this.title = title; diff --git a/src/plugins/management/public/management_service.ts b/src/plugins/management/public/management_service.ts index 4a900345b3843..ed31a22992da8 100644 --- a/src/plugins/management/public/management_service.ts +++ b/src/plugins/management/public/management_service.ts @@ -22,7 +22,7 @@ import { KibanaLegacySetup } from '../../kibana_legacy/public'; // @ts-ignore import { LegacyManagementSection } from './legacy'; import { CreateSection } from './types'; -import { CoreSetup, CoreStart } from '../../../core/public'; +import { StartServicesAccessor, CoreStart } from '../../../core/public'; export class ManagementService { private sections: ManagementSection[] = []; @@ -30,7 +30,7 @@ export class ManagementService { private register( registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], getLegacyManagement: () => LegacyManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { return (section: CreateSection) => { if (this.getSection(section.id)) { @@ -71,7 +71,7 @@ export class ManagementService { public setup( kibanaLegacy: KibanaLegacySetup, getLegacyManagement: () => LegacyManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { const register = this.register.bind(this)( kibanaLegacy.registerLegacyApp, diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index 0dbdc2f3ac7e3..c76477cd8da43 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -4,14 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, SavedObject, SavedObjectsBaseOptions } from 'src/core/server'; +import { + StartServicesAccessor, + SavedObject, + SavedObjectsBaseOptions, + SavedObjectsServiceSetup, +} from 'src/core/server'; import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; interface SetupSavedObjectsParams { service: PublicMethodsOf; - savedObjects: CoreSetup['savedObjects']; - getStartServices: CoreSetup['getStartServices']; + savedObjects: SavedObjectsServiceSetup; + getStartServices: StartServicesAccessor; } export interface SavedObjectsSetup { diff --git a/x-pack/plugins/security/public/account_management/account_management_app.ts b/x-pack/plugins/security/public/account_management/account_management_app.ts index 8a14a772a1eef..cd3ef34858b19 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.ts +++ b/x-pack/plugins/security/public/account_management/account_management_app.ts @@ -5,14 +5,14 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, AppMountParameters } from 'src/core/public'; +import { StartServicesAccessor, ApplicationSetup, AppMountParameters } from 'src/core/public'; import { AuthenticationServiceSetup } from '../authentication'; import { UserAPIClient } from '../management'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; authc: AuthenticationServiceSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const accountManagementApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/authentication/authentication_service.ts b/x-pack/plugins/security/public/authentication/authentication_service.ts index 7b88b0f8573ba..979f7095cf933 100644 --- a/x-pack/plugins/security/public/authentication/authentication_service.ts +++ b/x-pack/plugins/security/public/authentication/authentication_service.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ApplicationSetup, CoreSetup, HttpSetup } from 'src/core/public'; +import { ApplicationSetup, StartServicesAccessor, HttpSetup } from 'src/core/public'; import { AuthenticatedUser } from '../../common/model'; import { ConfigType } from '../config'; import { PluginStartDependencies } from '../plugin'; @@ -17,7 +17,7 @@ interface SetupParams { application: ApplicationSetup; config: ConfigType; http: HttpSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export interface AuthenticationServiceSetup { diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts index b7f2615318791..2849111e7efeb 100644 --- a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts @@ -5,12 +5,17 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, AppMountParameters, HttpSetup } from 'src/core/public'; +import { + StartServicesAccessor, + ApplicationSetup, + AppMountParameters, + HttpSetup, +} from 'src/core/public'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; http: HttpSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const loggedOutApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/authentication/login/login_app.ts b/x-pack/plugins/security/public/authentication/login/login_app.ts index 1642aba51c1ae..1ecb5dcfd7990 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.ts @@ -5,13 +5,18 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, AppMountParameters, HttpSetup } from 'src/core/public'; +import { + StartServicesAccessor, + AppMountParameters, + ApplicationSetup, + HttpSetup, +} from 'src/core/public'; import { ConfigType } from '../../config'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; http: HttpSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; config: Pick; } diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts index 1bbe388a635e2..8e0ee73dfb613 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts @@ -5,13 +5,13 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, AppMountParameters } from 'src/core/public'; +import { StartServicesAccessor, ApplicationSetup, AppMountParameters } from 'src/core/public'; import { AuthenticationServiceSetup } from '../authentication_service'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; authc: AuthenticationServiceSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const overwrittenSessionApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx index 35de732b84ce9..272fc9cfc2fe6 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { PluginStartDependencies } from '../../plugin'; import { APIKeysGridPage } from './api_keys_grid'; @@ -15,7 +15,7 @@ import { APIKeysAPIClient } from './api_keys_api_client'; import { DocumentationLinksService } from './documentation_links'; interface CreateParams { - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const apiKeysManagementApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/management/management_service.ts b/x-pack/plugins/security/public/management/management_service.ts index 5ad3681590fbf..7c4c470730ffe 100644 --- a/x-pack/plugins/security/public/management/management_service.ts +++ b/x-pack/plugins/security/public/management/management_service.ts @@ -6,7 +6,7 @@ import { Subscription } from 'rxjs'; import { i18n } from '@kbn/i18n'; -import { CoreSetup, FatalErrorsSetup } from 'src/core/public'; +import { StartServicesAccessor, FatalErrorsSetup } from 'src/core/public'; import { ManagementApp, ManagementSetup, @@ -25,7 +25,7 @@ interface SetupParams { license: SecurityLicense; authc: AuthenticationServiceSetup; fatalErrors: FatalErrorsSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } interface StartParams { diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx index 8e1ac8d7f6957..ea090520fdd46 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HashRouter as Router, Route, Switch, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { PluginStartDependencies } from '../../plugin'; import { RolesAPIClient } from '../roles'; @@ -18,7 +18,7 @@ import { RoleMappingsGridPage } from './role_mappings_grid'; import { EditRoleMappingPage } from './edit_role_mapping'; interface CreateParams { - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const roleMappingsManagementApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.tsx index 4e8c95b61c2f1..4265cac22ece0 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HashRouter as Router, Route, Switch, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup, FatalErrorsSetup } from 'src/core/public'; +import { StartServicesAccessor, FatalErrorsSetup } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { SecurityLicense } from '../../../common/licensing'; import { PluginStartDependencies } from '../../plugin'; @@ -23,7 +23,7 @@ import { PrivilegesAPIClient } from './privileges_api_client'; interface CreateParams { fatalErrors: FatalErrorsSetup; license: SecurityLicense; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const rolesManagementApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/management/users/users_management_app.tsx b/x-pack/plugins/security/public/management/users/users_management_app.tsx index 7874b810676b5..82a2b8d2a98ad 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HashRouter as Router, Route, Switch, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { AuthenticationServiceSetup } from '../../authentication'; import { PluginStartDependencies } from '../../plugin'; @@ -19,7 +19,7 @@ import { EditUserPage } from './edit_user'; interface CreateParams { authc: AuthenticationServiceSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const usersManagementApp = Object.freeze({ diff --git a/x-pack/plugins/spaces/public/management/management_service.tsx b/x-pack/plugins/spaces/public/management/management_service.tsx index c81a3497762a5..cec4bee1373ca 100644 --- a/x-pack/plugins/spaces/public/management/management_service.tsx +++ b/x-pack/plugins/spaces/public/management/management_service.tsx @@ -5,7 +5,7 @@ */ import { ManagementSetup, ManagementApp } from 'src/plugins/management/public'; -import { CoreSetup, Capabilities } from 'src/core/public'; +import { StartServicesAccessor, Capabilities } from 'src/core/public'; import { SecurityLicense } from '../../../security/public'; import { SpacesManager } from '../spaces_manager'; import { PluginsStart } from '../plugin'; @@ -13,7 +13,7 @@ import { spacesManagementApp } from './spaces_management_app'; interface SetupDeps { management: ManagementSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; spacesManager: SpacesManager; securityLicense?: SecurityLicense; } diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx index 663237cfc2e8a..2a93e684bb716 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HashRouter as Router, Route, Switch, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { SecurityLicense } from '../../../security/public'; import { RegisterManagementAppArgs } from '../../../../../src/plugins/management/public'; import { PluginsStart } from '../plugin'; @@ -18,7 +18,7 @@ import { ManageSpacePage } from './edit_space'; import { Space } from '..'; interface CreateParams { - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; spacesManager: SpacesManager; securityLicense?: SecurityLicense; } diff --git a/x-pack/plugins/spaces/public/space_selector/space_selector_app.tsx b/x-pack/plugins/spaces/public/space_selector/space_selector_app.tsx index 6fab1767e4b6d..048f0e30cd469 100644 --- a/x-pack/plugins/spaces/public/space_selector/space_selector_app.tsx +++ b/x-pack/plugins/spaces/public/space_selector/space_selector_app.tsx @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, AppMountParameters } from 'src/core/public'; +import { StartServicesAccessor, ApplicationSetup, AppMountParameters } from 'src/core/public'; import { i18n } from '@kbn/i18n'; import { SpacesManager } from '../spaces_manager'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; spacesManager: SpacesManager; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const spaceSelectorApp = Object.freeze({ From 04360ef6f00707e3b640e146fc8673bd02a76c6a Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 24 Mar 2020 14:25:59 +0000 Subject: [PATCH 03/56] [ML] Renaming ML setup and start contracts (#60980) --- x-pack/plugins/ml/public/index.ts | 6 +++--- x-pack/plugins/ml/public/plugin.ts | 6 +++--- x-pack/plugins/ml/server/index.ts | 2 +- x-pack/plugins/ml/server/plugin.ts | 10 +++++----- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/ml/public/index.ts b/x-pack/plugins/ml/public/index.ts index f20f3836ab433..f9f2be390e05f 100755 --- a/x-pack/plugins/ml/public/index.ts +++ b/x-pack/plugins/ml/public/index.ts @@ -6,8 +6,8 @@ import { PluginInitializer } from 'kibana/public'; import './index.scss'; -import { MlPlugin, Setup, Start } from './plugin'; +import { MlPlugin, MlPluginSetup, MlPluginStart } from './plugin'; -export const plugin: PluginInitializer = () => new MlPlugin(); +export const plugin: PluginInitializer = () => new MlPlugin(); -export { Setup, Start }; +export { MlPluginSetup, MlPluginStart }; diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 79aebece85af2..30b7133f4147e 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -28,7 +28,7 @@ export interface MlSetupDependencies { usageCollection: UsageCollectionSetup; } -export class MlPlugin implements Plugin { +export class MlPlugin implements Plugin { setup(core: CoreSetup, pluginsSetup: MlSetupDependencies) { core.application.register({ id: PLUGIN_ID, @@ -77,5 +77,5 @@ export class MlPlugin implements Plugin { public stop() {} } -export type Setup = ReturnType; -export type Start = ReturnType; +export type MlPluginSetup = ReturnType; +export type MlPluginStart = ReturnType; diff --git a/x-pack/plugins/ml/server/index.ts b/x-pack/plugins/ml/server/index.ts index 6cfa1b23408c0..175c20bf49c94 100644 --- a/x-pack/plugins/ml/server/index.ts +++ b/x-pack/plugins/ml/server/index.ts @@ -6,6 +6,6 @@ import { PluginInitializerContext } from 'kibana/server'; import { MlServerPlugin } from './plugin'; -export { MlStartContract, MlSetupContract } from './plugin'; +export { MlPluginSetup, MlPluginStart } from './plugin'; export const plugin = (ctx: PluginInitializerContext) => new MlServerPlugin(ctx); diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 8948d232b9e5e..dc42a1f7fcbbb 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -49,10 +49,10 @@ declare module 'kibana/server' { } } -export type MlSetupContract = SharedServices; -export type MlStartContract = void; +export type MlPluginSetup = SharedServices; +export type MlPluginStart = void; -export class MlServerPlugin implements Plugin { +export class MlServerPlugin implements Plugin { private log: Logger; private version: string; private mlLicense: MlServerLicense; @@ -63,7 +63,7 @@ export class MlServerPlugin implements Plugin Date: Tue, 24 Mar 2020 15:26:40 +0100 Subject: [PATCH 04/56] [SearchProfiler] Minor fixes (#60919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix auto-expanding of shards in profile tree * Fix bad error message for ES errors that don't have line numbers * Add error message for bad profile data * Fix type and test issues and rename __test__ folder to __jest__ * ! 👉🏻. --- .../fixtures/breakdown.ts | 0 .../fixtures/normalize_indices.ts | 0 .../fixtures/normalize_times.ts | 0 .../fixtures/processed_search_response.ts | 0 .../fixtures/search_response.ts | 0 .../{__tests__ => __jest__}/init_data.test.ts | 0 .../profile_tree.test.tsx | 3 +++ .../unsafe_utils.test.ts | 0 .../{__tests__ => __jest__}/utils.test.ts | 0 .../components/profile_tree/profile_tree.tsx | 6 +++--- .../shard_details/shard_details.tsx | 16 +++++++++++++++- .../application/containers/main/main.tsx | 19 +++++++++++++++++-- .../application/hooks/use_request_profile.ts | 4 +++- .../public/application/types.ts | 9 ++++++++- 14 files changed, 49 insertions(+), 8 deletions(-) rename x-pack/plugins/searchprofiler/public/application/components/profile_tree/{__tests__ => __jest__}/fixtures/breakdown.ts (100%) rename x-pack/plugins/searchprofiler/public/application/components/profile_tree/{__tests__ => __jest__}/fixtures/normalize_indices.ts (100%) rename x-pack/plugins/searchprofiler/public/application/components/profile_tree/{__tests__ => __jest__}/fixtures/normalize_times.ts (100%) rename x-pack/plugins/searchprofiler/public/application/components/profile_tree/{__tests__ => __jest__}/fixtures/processed_search_response.ts (100%) rename x-pack/plugins/searchprofiler/public/application/components/profile_tree/{__tests__ => __jest__}/fixtures/search_response.ts (100%) rename x-pack/plugins/searchprofiler/public/application/components/profile_tree/{__tests__ => __jest__}/init_data.test.ts (100%) rename x-pack/plugins/searchprofiler/public/application/components/profile_tree/{__tests__ => __jest__}/profile_tree.test.tsx (89%) rename x-pack/plugins/searchprofiler/public/application/components/profile_tree/{__tests__ => __jest__}/unsafe_utils.test.ts (100%) rename x-pack/plugins/searchprofiler/public/application/components/profile_tree/{__tests__ => __jest__}/utils.test.ts (100%) diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/breakdown.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/breakdown.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/breakdown.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/breakdown.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/normalize_indices.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/normalize_indices.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/normalize_indices.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/normalize_indices.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/normalize_times.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/normalize_times.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/normalize_times.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/normalize_times.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/processed_search_response.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/processed_search_response.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/processed_search_response.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/processed_search_response.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/search_response.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/search_response.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/search_response.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/search_response.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/init_data.test.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/init_data.test.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/init_data.test.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/init_data.test.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/profile_tree.test.tsx b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/profile_tree.test.tsx similarity index 89% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/profile_tree.test.tsx rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/profile_tree.test.tsx index 1286f30d69c26..64f77e8b4e52c 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/profile_tree.test.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/profile_tree.test.tsx @@ -14,6 +14,7 @@ describe('ProfileTree', () => { onHighlight: () => {}, target: 'searches', data: searchResponse, + onDataInitError: jest.fn(), }; const init = registerTestBed(ProfileTree); await init(props); @@ -24,10 +25,12 @@ describe('ProfileTree', () => { const props: Props = { onHighlight: () => {}, target: 'searches', + onDataInitError: jest.fn(), data: [{}] as any, }; const init = registerTestBed(ProfileTree); await init(props); + expect(props.onDataInitError).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/unsafe_utils.test.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/unsafe_utils.test.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/unsafe_utils.test.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/unsafe_utils.test.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/utils.test.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/utils.test.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/utils.test.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/utils.test.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/profile_tree.tsx b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/profile_tree.tsx index 1dec8f0161c52..ade547a7d440f 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/profile_tree.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/profile_tree.tsx @@ -17,9 +17,10 @@ export interface Props { target: Targets; data: ShardSerialized[] | null; onHighlight: (args: OnHighlightChangeArgs) => void; + onDataInitError: (error: Error) => void; } -export const ProfileTree = memo(({ data, target, onHighlight }: Props) => { +export const ProfileTree = memo(({ data, target, onHighlight, onDataInitError }: Props) => { if (!data || data.length === 0) { return null; } @@ -28,8 +29,7 @@ export const ProfileTree = memo(({ data, target, onHighlight }: Props) => { try { sortedIndices = initDataFor(target)(data); } catch (e) { - // eslint-disable-next-line no-console - console.error(e); + onDataInitError(e); return null; } diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details.tsx b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details.tsx index 5ca8ad4ecd979..ac2a2997515d5 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details.tsx @@ -18,10 +18,24 @@ interface Props { operations: Operation[]; } +const hasVisibleOperation = (ops: Operation[]): boolean => { + for (const op of ops) { + if (op.visible) { + return true; + } + if (op.children?.length && hasVisibleOperation(op.children)) { + return true; + } + } + return false; +}; + export const ShardDetails = ({ index, shard, operations }: Props) => { const { relative, time } = shard; - const [shardVisibility, setShardVisibility] = useState(false); + const [shardVisibility, setShardVisibility] = useState(() => + hasVisibleOperation(operations.map(op => op.treeRoot ?? op)) + ); return ( <> diff --git a/x-pack/plugins/searchprofiler/public/application/containers/main/main.tsx b/x-pack/plugins/searchprofiler/public/application/containers/main/main.tsx index aa6c20aa6a7f3..11dbc6b320531 100644 --- a/x-pack/plugins/searchprofiler/public/application/containers/main/main.tsx +++ b/x-pack/plugins/searchprofiler/public/application/containers/main/main.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import React, { useCallback } from 'react'; import { @@ -33,7 +34,7 @@ import { useProfilerActionContext, useProfilerReadContext } from '../../contexts import { hasAggregations, hasSearch } from '../../utils'; export const Main = () => { - const { getLicenseStatus } = useAppContext(); + const { getLicenseStatus, notifications } = useAppContext(); const { activeTab, @@ -42,8 +43,17 @@ export const Main = () => { pristine, profiling, } = useProfilerReadContext(); + const dispatch = useProfilerActionContext(); + const handleProfileTreeError = (e: Error) => { + notifications.addError(e, { + title: i18n.translate('xpack.searchProfiler.profileTreeErrorRenderTitle', { + defaultMessage: 'Profile data cannot be parsed.', + }), + }); + }; + const setActiveTab = useCallback( (target: Targets) => dispatch({ type: 'setActiveTab', value: target }), [dispatch] @@ -70,7 +80,12 @@ export const Main = () => { if (activeTab) { return (
- +
); } diff --git a/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts b/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts index 3d8bee1d62b27..435db4a98c552 100644 --- a/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts +++ b/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts @@ -22,7 +22,9 @@ interface ReturnValue { const extractProfilerErrorMessage = (e: any): string | undefined => { if (e.body?.attributes?.error?.reason) { const { reason, line, col } = e.body.attributes.error; - return `${reason} at line: ${line - 1} col: ${col}`; + if (typeof line === 'number' && typeof col === 'number') { + return `${reason} at line: ${line - 1} col: ${col}`; + } } if (e.body?.message) { diff --git a/x-pack/plugins/searchprofiler/public/application/types.ts b/x-pack/plugins/searchprofiler/public/application/types.ts index 9866f8d5b1ccb..896af0851eb52 100644 --- a/x-pack/plugins/searchprofiler/public/application/types.ts +++ b/x-pack/plugins/searchprofiler/public/application/types.ts @@ -49,7 +49,14 @@ export interface Operation { parent: Operation | null; children: Operation[]; - // Only exists on top level + /** + * Only exists on top level. + * + * @remark + * For now, when we init profile data for rendering we take a top-level + * operation and designate it the root of the operations tree - this is not + * information we get from ES. + */ treeRoot?: Operation; depth?: number; From 6d2aa8974d6f15d3b24881f9c8aa168b51e6c356 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 24 Mar 2020 10:43:48 -0400 Subject: [PATCH 05/56] [Lens] Fix bug in metric config panel (#60982) * [Lens] Fix bug in metric config panel * Fix test --- .../metric_visualization.test.ts | 40 +++++++++++++++++++ .../metric_visualization.tsx | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts index 276f24433c670..62f47a21c85b0 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts +++ b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts @@ -72,6 +72,46 @@ describe('metric_visualization', () => { }); }); + describe('#getConfiguration', () => { + it('can add a metric when there is no accessor', () => { + expect( + metricVisualization.getConfiguration({ + state: { + accessor: undefined, + layerId: 'l1', + }, + layerId: 'l1', + frame: mockFrame(), + }) + ).toEqual({ + groups: [ + expect.objectContaining({ + supportsMoreColumns: true, + }), + ], + }); + }); + + it('is not allowed to add a metric once one accessor is set', () => { + expect( + metricVisualization.getConfiguration({ + state: { + accessor: 'a', + layerId: 'l1', + }, + layerId: 'l1', + frame: mockFrame(), + }) + ).toEqual({ + groups: [ + expect.objectContaining({ + supportsMoreColumns: false, + }), + ], + }); + }); + }); + describe('#setDimension', () => { it('sets the accessor', () => { expect( diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx index 44256df5aed6d..73b8019a31eaa 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx @@ -94,7 +94,7 @@ export const metricVisualization: Visualization = { groupLabel: i18n.translate('xpack.lens.metric.label', { defaultMessage: 'Metric' }), layerId: props.state.layerId, accessors: props.state.accessor ? [props.state.accessor] : [], - supportsMoreColumns: false, + supportsMoreColumns: !props.state.accessor, filterOperations: (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number', }, ], From 8657caaffdef9b5b782710f3794e235478a21b52 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Tue, 24 Mar 2020 14:51:15 +0000 Subject: [PATCH 06/56] allow users to unset the throttle of an alert (#60964) --- .../sections/alert_form/alert_form.tsx | 22 +++- .../apps/triggers_actions_ui/alerts.ts | 102 ++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) 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 c6346ba002a7f..2c601eeb75645 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 @@ -24,6 +24,8 @@ import { EuiButtonIcon, EuiHorizontalRule, } from '@elastic/eui'; +import { some, filter, map, fold } from 'fp-ts/lib/Option'; +import { pipe } from 'fp-ts/lib/pipeable'; import { getDurationNumberInItsUnit, getDurationUnitValue, @@ -408,9 +410,23 @@ export const AlertForm = ({ name="throttle" data-test-subj="throttleInput" onChange={e => { - const throttle = e.target.value !== '' ? parseInt(e.target.value, 10) : null; - setAlertThrottle(throttle); - setAlertProperty('throttle', `${e.target.value}${alertThrottleUnit}`); + pipe( + some(e.target.value.trim()), + filter(value => value !== ''), + map(value => parseInt(value, 10)), + filter(value => !isNaN(value)), + fold( + () => { + // unset throttle + setAlertThrottle(null); + setAlertProperty('throttle', null); + }, + throttle => { + setAlertThrottle(throttle); + setAlertProperty('throttle', `${throttle}${alertThrottleUnit}`); + } + ) + ); }} /> diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 7e5825d88ec13..beedd6390388d 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -185,6 +185,108 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ]); }); + it('should set an alert throttle', async () => { + const alertName = `edit throttle ${generateUniqueKey()}`; + const createdAlert = await createAlert({ + alertTypeId: '.index-threshold', + name: alertName, + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000, 5000], + index: ['.kibana_1'], + timeField: 'alert', + }, + }); + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.eql([ + { + name: createdAlert.name, + tagsText: 'foo, bar', + alertType: 'Index Threshold', + interval: '1m', + }, + ]); + + const editLink = await testSubjects.findAll('alertsTableCell-editLink'); + await editLink[0].click(); + + const throttleInputToSetInitialValue = await testSubjects.find('throttleInput'); + await throttleInputToSetInitialValue.click(); + await throttleInputToSetInitialValue.clearValue(); + await throttleInputToSetInitialValue.type('1'); + + await find.clickByCssSelector('[data-test-subj="saveEditedAlertButton"]:not(disabled)'); + + expect(await pageObjects.common.closeToast()).to.eql(`Updated '${createdAlert.name}'`); + + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + await (await testSubjects.findAll('alertsTableCell-editLink'))[0].click(); + const throttleInput = await testSubjects.find('throttleInput'); + expect(await throttleInput.getAttribute('value')).to.eql('1'); + }); + + it('should unset an alert throttle', async () => { + const alertName = `edit throttle ${generateUniqueKey()}`; + const createdAlert = await createAlert({ + alertTypeId: '.index-threshold', + name: alertName, + throttle: '10m', + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000, 5000], + index: ['.kibana_1'], + timeField: 'alert', + }, + }); + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.eql([ + { + name: createdAlert.name, + tagsText: 'foo, bar', + alertType: 'Index Threshold', + interval: '1m', + }, + ]); + + const editLink = await testSubjects.findAll('alertsTableCell-editLink'); + await editLink[0].click(); + + const throttleInputToUnsetValue = await testSubjects.find('throttleInput'); + + expect(await throttleInputToUnsetValue.getAttribute('value')).to.eql('10'); + await throttleInputToUnsetValue.click(); + await throttleInputToUnsetValue.clearValueWithKeyboard(); + + expect(await throttleInputToUnsetValue.getAttribute('value')).to.eql(''); + + await find.clickByCssSelector('[data-test-subj="saveEditedAlertButton"]:not(disabled)'); + + expect(await pageObjects.common.closeToast()).to.eql(`Updated '${createdAlert.name}'`); + + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + await (await testSubjects.findAll('alertsTableCell-editLink'))[0].click(); + const throttleInput = await testSubjects.find('throttleInput'); + expect(await throttleInput.getAttribute('value')).to.eql(''); + }); + it('should reset alert when canceling an edit', async () => { const createdAlert = await createAlert({ alertTypeId: '.index-threshold', From 4b9126b2c3502d7730f8304bc8fcbcc9c25de1a3 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Tue, 24 Mar 2020 07:57:50 -0700 Subject: [PATCH 07/56] Updating our direct usage of https-proxy-agent to 5.0.0 (#58296) Co-authored-by: Elastic Machine --- package.json | 2 +- yarn.lock | 40 ++++++++++++++++++++-------------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 08668730f9a9d..9bb9b505f54a2 100644 --- a/package.json +++ b/package.json @@ -190,7 +190,7 @@ "hjson": "3.2.1", "hoek": "^5.0.4", "http-proxy-agent": "^2.1.0", - "https-proxy-agent": "^2.2.2", + "https-proxy-agent": "^5.0.0", "immer": "^1.5.0", "inert": "^5.1.0", "inline-style": "^2.0.0", diff --git a/yarn.lock b/yarn.lock index bd9a764cfdb22..8b73ceeaef904 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6037,6 +6037,13 @@ agent-base@4: dependencies: es6-promisify "^5.0.0" +agent-base@6: + version "6.0.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.0.tgz#5d0101f19bbfaed39980b22ae866de153b93f09a" + integrity sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw== + dependencies: + debug "4" + agent-base@^4.1.0: version "4.2.1" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" @@ -6044,13 +6051,6 @@ agent-base@^4.1.0: dependencies: es6-promisify "^5.0.0" -agent-base@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" - integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== - dependencies: - es6-promisify "^5.0.0" - agentkeepalive@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-3.4.1.tgz#aa95aebc3a749bca5ed53e3880a09f5235b48f0c" @@ -11171,6 +11171,13 @@ debug@3.2.6, debug@3.X, debug@^3.1.0, debug@^3.2.5, debug@^3.2.6: dependencies: ms "^2.1.1" +debug@4, debug@4.1.1, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + debug@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.0.tgz#373687bffa678b38b1cd91f861b63850035ddc87" @@ -11178,13 +11185,6 @@ debug@4.1.0: dependencies: ms "^2.1.1" -debug@4.1.1, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== - dependencies: - ms "^2.1.1" - debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" @@ -16519,13 +16519,13 @@ https-proxy-agent@2.2.1, https-proxy-agent@^2.2.1: agent-base "^4.1.0" debug "^3.1.0" -https-proxy-agent@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.2.tgz#271ea8e90f836ac9f119daccd39c19ff7dfb0793" - integrity sha512-c8Ndjc9Bkpfx/vCJueCPy0jlP4ccCCSNDp8xwCZzPjKJUm+B+u9WX2x98Qx4n1PiMNTWo3D7KK5ifNV/yJyRzg== +https-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== dependencies: - agent-base "^4.3.0" - debug "^3.1.0" + agent-base "6" + debug "4" human-signals@^1.1.1: version "1.1.1" From f371acff33d628e2364cb31eb1d0b7ddab8d02f0 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Tue, 24 Mar 2020 16:12:35 +0100 Subject: [PATCH 08/56] do not warn when switching capabilities for resources with optional auth (#61043) * do not switch capabilities for optional routes * downgrade message to debug --- .../spaces/server/capabilities/capabilities_switcher.test.ts | 4 ++-- .../spaces/server/capabilities/capabilities_switcher.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts index 3f7b93c754aef..fcd756c2aca10 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts @@ -154,7 +154,7 @@ describe('capabilitiesSwitcher', () => { expect(result).toEqual(buildCapabilities()); }); - it('logs a warning, and does not toggle capabilities if an error is encountered', async () => { + it('logs a debug message, and does not toggle capabilities if an error is encountered', async () => { const space: Space = { id: 'space', name: '', @@ -171,7 +171,7 @@ describe('capabilitiesSwitcher', () => { const result = await switcher(request, capabilities); expect(result).toEqual(buildCapabilities()); - expect(logger.warn).toHaveBeenCalledWith( + expect(logger.debug).toHaveBeenCalledWith( `Error toggling capabilities for request to /path: Error: Something terrible happened` ); }); diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts index 317cc7fe0e3c3..ddbea91f7268c 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts @@ -30,9 +30,10 @@ export function setupCapabilitiesSwitcher( const registeredFeatures = features.getFeatures(); + // try to retrieve capabilities for authenticated or "maybe authenticated" users return toggleCapabilities(registeredFeatures, capabilities, activeSpace); } catch (e) { - logger.warn(`Error toggling capabilities for request to ${request.url.pathname}: ${e}`); + logger.debug(`Error toggling capabilities for request to ${request.url.pathname}: ${e}`); return capabilities; } }; From b82cc6ed4a1c6c08ebba856b9bba71fafbf23789 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 24 Mar 2020 11:12:49 -0400 Subject: [PATCH 09/56] Support for sub-feature privileges (#60563) * initial server-side support for sub-feature privileges (#57507) * initial server-side support for sub-feature privileges * start addressing PR feedback * renaming interfaces * move privilege id collision check to security plugin * additional testing * change featurePrivilegeIterator import location * fix link assertions following rebase from master * Initial UI support for sub-feature privileges (#59198) * Initial UI support for sub-feature privileges * Address PR feedback * display deleted spaces correctly in the privilege summary * additional testing * update snapshot * Enables sub-feature privileges for gold+ licenses (#59750) * enables sub-feature privileges for gold+ licenses * Address PR feedback * address platform review feedback --- .../np_ready/dashboard_app_controller.tsx | 3 +- x-pack/legacy/plugins/apm/index.ts | 3 + x-pack/legacy/plugins/graph/index.ts | 5 + x-pack/legacy/plugins/maps/server/plugin.js | 5 + x-pack/legacy/plugins/siem/server/plugin.ts | 5 + .../plugins/xpack_main/server/xpack_main.d.ts | 4 +- x-pack/plugins/canvas/server/plugin.ts | 5 + x-pack/plugins/endpoint/server/plugin.ts | 2 + x-pack/plugins/features/common/feature.ts | 88 +- .../common/feature_kibana_privileges.ts | 2 - x-pack/plugins/features/common/index.ts | 9 +- x-pack/plugins/features/common/sub_feature.ts | 87 + x-pack/plugins/features/kibana.json | 2 +- .../public/features_api_client.test.ts | 44 + .../features/public/features_api_client.ts | 17 + x-pack/plugins/features/public/index.ts | 16 +- x-pack/plugins/features/public/mocks.ts | 17 + x-pack/plugins/features/public/plugin.test.ts | 53 + x-pack/plugins/features/public/plugin.ts | 27 + .../__snapshots__/oss_features.test.ts.snap | 458 +++++ .../features/server/feature_registry.test.ts | 517 +++++- .../features/server/feature_registry.ts | 25 +- .../plugins/features/server/feature_schema.ts | 211 ++- x-pack/plugins/features/server/index.ts | 2 +- .../features/server/oss_features.test.ts | 15 + .../plugins/features/server/oss_features.ts | 159 +- x-pack/plugins/features/server/plugin.ts | 4 +- .../features/server/routes/index.test.ts | 196 ++- .../plugins/features/server/routes/index.ts | 20 +- .../ui_capabilities_for_features.test.ts | 173 +- .../server/ui_capabilities_for_features.ts | 9 +- x-pack/plugins/infra/server/features.ts | 10 + .../plugins/ingest_manager/server/plugin.ts | 2 + x-pack/plugins/ml/server/plugin.ts | 5 +- x-pack/plugins/monitoring/server/plugin.ts | 4 +- .../common/licensing/license_features.ts | 5 + .../common/licensing/license_service.test.ts | 14 +- .../common/licensing/license_service.ts | 7 +- x-pack/plugins/security/common/model/index.ts | 3 +- .../kibana_privileges/feature_privileges.ts | 36 - .../kibana_privileges/global_privileges.ts | 17 - .../kibana_privileges/kibana_privileges.ts | 26 - .../kibana_privileges/spaces_privileges.ts | 17 - .../roles/__fixtures__/kibana_features.ts | 208 +++ .../roles/__fixtures__/kibana_privileges.ts | 41 + .../roles/edit_role/edit_role_page.test.tsx | 122 +- .../roles/edit_role/edit_role_page.tsx | 30 +- .../roles/edit_role/privilege_utils.test.ts | 38 +- .../roles/edit_role/privilege_utils.ts | 9 - .../kibana_privileges_region.test.tsx.snap | 26 +- .../feature_table/__fixtures__/index.ts | 76 + .../__snapshots__/feature_table.test.tsx.snap | 39 - .../feature_table/change_all_privileges.tsx | 30 +- .../feature_table/feature_table.test.tsx | 868 ++++++++-- .../kibana/feature_table/feature_table.tsx | 402 +++-- .../feature_table_expanded_row.test.tsx | 199 +++ .../feature_table_expanded_row.tsx | 95 ++ .../feature_table/sub_feature_form.test.tsx | 237 +++ .../kibana/feature_table/sub_feature_form.tsx | 134 ++ .../feature_table_cell.test.tsx | 60 + .../feature_table_cell/feature_table_cell.tsx | 41 + .../index.ts | 2 +- .../__fixtures__/build_role.ts | 38 - .../__fixtures__/common_allowed_privileges.ts | 60 - .../default_privilege_definition.ts | 43 - ...bana_allowed_privileges_calculator.test.ts | 313 ---- .../kibana_allowed_privileges_calculator.ts | 155 -- .../kibana_base_privilege_calculator.test.ts | 321 ---- .../kibana_base_privilege_calculator.ts | 98 -- ...ibana_feature_privilege_calculator.test.ts | 959 ----------- .../kibana_feature_privilege_calculator.ts | 209 --- .../kibana_privilege_calculator.test.ts | 940 ----------- .../kibana_privilege_calculator.ts | 113 -- .../kibana_privilege_calculator_types.ts | 63 - .../kibana_privileges_calculator_factory.ts | 81 - .../kibana/kibana_privileges_region.test.tsx | 21 +- .../kibana/kibana_privileges_region.tsx | 17 +- .../index.ts | 3 +- .../privilege_form_calculator.test.ts | 833 ++++++++++ .../privilege_form_calculator.ts | 303 ++++ .../privilege_summary/__fixtures__/index.ts | 129 ++ .../kibana/privilege_summary}/index.ts | 2 +- .../privilege_summary.test.tsx | 82 + .../privilege_summary/privilege_summary.tsx | 73 + .../privilege_summary_calculator.test.ts | 338 ++++ .../privilege_summary_calculator.ts | 109 ++ .../privilege_summary_expanded_row.tsx | 131 ++ .../privilege_summary_table.test.tsx | 922 +++++++++++ .../privilege_summary_table.tsx | 174 ++ .../space_column_header.test.tsx | 123 ++ .../privilege_summary/space_column_header.tsx | 78 + .../simple_privilege_section.test.tsx.snap | 266 +-- .../simple_privilege_section.test.tsx | 82 +- .../simple_privilege_section.tsx | 323 ++-- .../__fixtures__/raw_kibana_privileges.ts | 38 - .../privilege_display.test.tsx.snap | 118 -- .../privilege_space_form.test.tsx.snap | 497 ------ .../privilege_display.test.tsx | 42 +- .../privilege_display.tsx | 133 +- .../privilege_matrix.test.tsx | 128 -- .../privilege_matrix.tsx | 342 ---- .../privilege_space_form.test.tsx | 454 +++-- .../privilege_space_form.tsx | 249 ++- .../privilege_space_table.test.tsx | 283 +++- .../privilege_space_table.tsx | 182 +- .../space_aware_privilege_section.test.tsx | 43 +- .../space_aware_privilege_section.tsx | 92 +- .../space_selector.tsx | 9 +- .../spaces_popover_list.test.tsx | 112 ++ .../spaces_popover_list.tsx | 19 +- .../public/management/roles/model/index.ts | 14 + .../roles/model/kibana_privilege.ts | 31 + .../roles/model/kibana_privileges.test.ts | 144 ++ .../roles/model/kibana_privileges.ts | 86 + .../roles/model/primary_feature_privilege.ts | 29 + .../roles/model/privilege_collection.test.ts | 66 + .../roles/model/privilege_collection.ts | 33 + .../management/roles/model/secured_feature.ts | 77 + .../roles/model/secured_sub_feature.ts | 41 + .../roles/model/sub_feature_privilege.ts | 21 + .../model/sub_feature_privilege_group.ts | 25 + .../roles/roles_management_app.test.tsx | 7 +- .../management/roles/roles_management_app.tsx | 3 +- .../plugins/security/public/plugin.test.tsx | 4 + x-pack/plugins/security/public/plugin.tsx | 2 + .../server/authorization/actions/actions.ts | 7 - .../server/authorization/actions/api.test.ts | 7 - .../server/authorization/actions/api.ts | 4 - .../server/authorization/actions/app.test.ts | 7 - .../server/authorization/actions/app.ts | 4 - .../actions/saved_object.test.ts | 7 - .../authorization/actions/saved_object.ts | 4 - .../server/authorization/actions/ui.test.ts | 28 - .../server/authorization/actions/ui.ts | 16 - .../disable_ui_capabilities.test.ts | 55 +- .../server/authorization/index.test.ts | 2 +- .../security/server/authorization/index.ts | 5 +- .../feature_privilege_builder/app.ts | 2 +- .../feature_privilege_builder/catalogue.ts | 2 +- .../feature_privilege_builder/management.ts | 2 +- .../feature_privilege_iterator.test.ts | 891 ++++++++++ .../feature_privilege_iterator.ts | 83 + .../feature_privilege_iterator}/index.ts | 5 +- .../sub_feature_privilege_iterator.ts | 18 + .../server/authorization/privileges/index.ts | 1 + .../privileges/privileges.test.ts | 1473 ++++++++++++----- .../authorization/privileges/privileges.ts | 104 +- .../validate_feature_privileges.test.ts | 218 ++- .../validate_feature_privileges.ts | 34 +- x-pack/plugins/security/server/plugin.test.ts | 1 - .../server/routes/views/login.test.ts | 1 + .../enabled_features.test.tsx.snap | 4 +- .../enabled_features.test.tsx | 8 +- .../enabled_features/enabled_features.tsx | 4 +- .../enabled_features/feature_table.tsx | 9 +- .../edit_space/manage_space_page.test.tsx | 66 +- .../edit_space/manage_space_page.tsx | 30 +- .../public/management/lib/feature_utils.ts | 4 +- .../management/management_service.test.ts | 22 +- .../spaces_grid/spaces_grid_page.tsx | 19 +- .../spaces_grid/spaces_grid_pages.test.tsx | 85 +- .../management/spaces_management_app.test.tsx | 13 +- .../management/spaces_management_app.tsx | 11 +- x-pack/plugins/spaces/public/plugin.test.ts | 3 +- x-pack/plugins/spaces/public/plugin.tsx | 4 +- .../capabilities_switcher.test.ts | 5 +- .../translations/translations/ja-JP.json | 15 - .../translations/translations/zh-CN.json | 15 - x-pack/plugins/uptime/server/kibana.index.ts | 5 + .../common/fixtures/plugins/actions/index.ts | 2 + .../common/fixtures/plugins/alerts/index.ts | 2 + .../api_integration/apis/security/index.js | 3 + .../apis/security/privileges.ts | 82 +- .../apis/security/privileges_basic.ts | 75 + .../apis/security/security_basic.ts | 24 + .../api_integration/config_security_basic.js | 2 +- .../feature_controls/dashboard_security.ts | 109 ++ .../feature_controls/discover_security.ts | 91 + .../feature_controls/visualize_security.ts | 107 ++ .../fixtures/plugins/foo_plugin/index.js | 4 + 180 files changed, 12447 insertions(+), 7069 deletions(-) create mode 100644 x-pack/plugins/features/common/sub_feature.ts create mode 100644 x-pack/plugins/features/public/features_api_client.test.ts create mode 100644 x-pack/plugins/features/public/features_api_client.ts create mode 100644 x-pack/plugins/features/public/mocks.ts create mode 100644 x-pack/plugins/features/public/plugin.test.ts create mode 100644 x-pack/plugins/features/public/plugin.ts create mode 100644 x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap delete mode 100644 x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts delete mode 100644 x-pack/plugins/security/common/model/kibana_privileges/global_privileges.ts delete mode 100644 x-pack/plugins/security/common/model/kibana_privileges/kibana_privileges.ts delete mode 100644 x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts create mode 100644 x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts create mode 100644 x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__fixtures__/index.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__snapshots__/feature_table.test.tsx.snap create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx rename x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/{space_aware_privilege_section/__fixtures__ => feature_table_cell}/index.ts (79%) delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/build_role.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.test.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator_types.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts rename x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/{kibana_privilege_calculator => privilege_form_calculator}/index.ts (62%) create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/__fixtures__/index.ts rename x-pack/plugins/security/{common/model/kibana_privileges => public/management/roles/edit_role/privileges/kibana/privilege_summary}/index.ts (81%) create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/raw_kibana_privileges.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_display.test.tsx.snap delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_space_form.test.tsx.snap delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.test.tsx delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/model/index.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/kibana_privilege.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/privilege_collection.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/secured_feature.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/secured_sub_feature.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts create mode 100644 x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts create mode 100644 x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts rename x-pack/plugins/security/{public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__ => server/authorization/privileges/feature_privilege_iterator}/index.ts (57%) create mode 100644 x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts create mode 100644 x-pack/test/api_integration/apis/security/privileges_basic.ts create mode 100644 x-pack/test/api_integration/apis/security/security_basic.ts diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx index f1e1f20de1ce6..0c6686c993371 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx @@ -890,7 +890,8 @@ export class DashboardAppController { share.toggleShareContextMenu({ anchorElement, allowEmbed: true, - allowShortUrl: !dashboardConfig.getHideWriteControls(), + allowShortUrl: + !dashboardConfig.getHideWriteControls() || dashboardCapabilities.createShortUrl, shareableUrl: unhashUrl(window.location.href), objectId: dash.id, objectType: 'dashboard', diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 502e910caae51..d1f7ce325d23e 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -96,6 +96,7 @@ export const apm: LegacyPluginInitializer = kibana => { name: i18n.translate('xpack.apm.featureRegistry.apmFeatureName', { defaultMessage: 'APM' }), + order: 900, icon: 'apmApp', navLinkId: 'apm', app: ['apm', 'kibana'], @@ -103,6 +104,7 @@ export const apm: LegacyPluginInitializer = kibana => { // see x-pack/plugins/features/common/feature_kibana_privileges.ts privileges: { all: { + app: ['apm', 'kibana'], api: ['apm', 'apm_write', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { @@ -121,6 +123,7 @@ export const apm: LegacyPluginInitializer = kibana => { ] }, read: { + app: ['apm', 'kibana'], api: ['apm', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { diff --git a/x-pack/legacy/plugins/graph/index.ts b/x-pack/legacy/plugins/graph/index.ts index 5122796335e45..53d32a836cfa1 100644 --- a/x-pack/legacy/plugins/graph/index.ts +++ b/x-pack/legacy/plugins/graph/index.ts @@ -37,6 +37,7 @@ export const graph: LegacyPluginInitializer = kibana => { name: i18n.translate('xpack.graph.featureRegistry.graphFeatureName', { defaultMessage: 'Graph', }), + order: 1200, icon: 'graphApp', navLinkId: 'graph', app: ['graph', 'kibana'], @@ -44,6 +45,8 @@ export const graph: LegacyPluginInitializer = kibana => { validLicenses: ['platinum', 'enterprise', 'trial'], privileges: { all: { + app: ['graph', 'kibana'], + catalogue: ['graph'], savedObject: { all: ['graph-workspace'], read: ['index-pattern'], @@ -51,6 +54,8 @@ export const graph: LegacyPluginInitializer = kibana => { ui: ['save', 'delete'], }, read: { + app: ['graph', 'kibana'], + catalogue: ['graph'], savedObject: { all: [], read: ['index-pattern', 'graph-workspace'], diff --git a/x-pack/legacy/plugins/maps/server/plugin.js b/x-pack/legacy/plugins/maps/server/plugin.js index 02e38ff54b300..5b52a3eba2f23 100644 --- a/x-pack/legacy/plugins/maps/server/plugin.js +++ b/x-pack/legacy/plugins/maps/server/plugin.js @@ -23,12 +23,15 @@ export class MapPlugin { name: i18n.translate('xpack.maps.featureRegistry.mapsFeatureName', { defaultMessage: 'Maps', }), + order: 600, icon: APP_ICON, navLinkId: APP_ID, app: [APP_ID, 'kibana'], catalogue: [APP_ID], privileges: { all: { + app: [APP_ID, 'kibana'], + catalogue: [APP_ID], savedObject: { all: [MAP_SAVED_OBJECT_TYPE, 'query'], read: ['index-pattern'], @@ -36,6 +39,8 @@ export class MapPlugin { ui: ['save', 'show', 'saveQuery'], }, read: { + app: [APP_ID, 'kibana'], + catalogue: [APP_ID], savedObject: { all: [], read: [MAP_SAVED_OBJECT_TYPE, 'index-pattern', 'query'], diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index 7008872a6f3cd..d785de32eab7e 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -97,12 +97,15 @@ export class Plugin { name: i18n.translate('xpack.siem.featureRegistry.linkSiemTitle', { defaultMessage: 'SIEM', }), + order: 1100, icon: 'securityAnalyticsApp', navLinkId: 'siem', app: ['siem', 'kibana'], catalogue: ['siem'], privileges: { all: { + app: ['siem', 'kibana'], + catalogue: ['siem'], api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], savedObject: { all: [ @@ -128,6 +131,8 @@ export class Plugin { ], }, read: { + app: ['siem', 'kibana'], + catalogue: ['siem'], api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], savedObject: { all: ['alert', 'action', 'action_task_params'], diff --git a/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts b/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts index a9abc733775d2..7b5dc19760627 100644 --- a/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts +++ b/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts @@ -5,12 +5,12 @@ */ import KbnServer from 'src/legacy/server/kbn_server'; -import { Feature, FeatureWithAllOrReadPrivileges } from '../../../../plugins/features/server'; +import { Feature, FeatureConfig } from '../../../../plugins/features/server'; import { XPackInfo, XPackInfoOptions } from './lib/xpack_info'; export { XPackFeature } from './lib/xpack_info'; export interface XPackMainPlugin { info: XPackInfo; getFeatures(): Feature[]; - registerFeature(feature: FeatureWithAllOrReadPrivileges): void; + registerFeature(feature: FeatureConfig): void; } diff --git a/x-pack/plugins/canvas/server/plugin.ts b/x-pack/plugins/canvas/server/plugin.ts index bfda7ef5885bc..0325de9cf29e2 100644 --- a/x-pack/plugins/canvas/server/plugin.ts +++ b/x-pack/plugins/canvas/server/plugin.ts @@ -32,12 +32,15 @@ export class CanvasPlugin implements Plugin { plugins.features.registerFeature({ id: 'canvas', name: 'Canvas', + order: 400, icon: 'canvasApp', navLinkId: 'canvas', app: ['canvas', 'kibana'], catalogue: ['canvas'], privileges: { all: { + app: ['canvas', 'kibana'], + catalogue: ['canvas'], savedObject: { all: ['canvas-workpad', 'canvas-element'], read: ['index-pattern'], @@ -45,6 +48,8 @@ export class CanvasPlugin implements Plugin { ui: ['save', 'show'], }, read: { + app: ['canvas', 'kibana'], + catalogue: ['canvas'], savedObject: { all: [], read: ['index-pattern', 'canvas-workpad', 'canvas-element'], diff --git a/x-pack/plugins/endpoint/server/plugin.ts b/x-pack/plugins/endpoint/server/plugin.ts index aef85f39e0382..4b4afd8088744 100644 --- a/x-pack/plugins/endpoint/server/plugin.ts +++ b/x-pack/plugins/endpoint/server/plugin.ts @@ -43,6 +43,7 @@ export class EndpointPlugin app: ['endpoint', 'kibana'], privileges: { all: { + app: ['endpoint', 'kibana'], api: ['resolver'], savedObject: { all: [], @@ -51,6 +52,7 @@ export class EndpointPlugin ui: ['save'], }, read: { + app: ['endpoint', 'kibana'], api: [], savedObject: { all: [], diff --git a/x-pack/plugins/features/common/feature.ts b/x-pack/plugins/features/common/feature.ts index 748076b95ad77..82fcc33f5c8ce 100644 --- a/x-pack/plugins/features/common/feature.ts +++ b/x-pack/plugins/features/common/feature.ts @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FeatureKibanaPrivileges, FeatureKibanaPrivilegesSet } from './feature_kibana_privileges'; +import { RecursiveReadonly } from '@kbn/utility-types'; +import { FeatureKibanaPrivileges } from './feature_kibana_privileges'; +import { SubFeatureConfig, SubFeature } from './sub_feature'; /** * Interface for registering a feature. * Feature registration allows plugins to hide their applications with spaces, * and secure access when configured for security. */ -export interface Feature< - TPrivileges extends Partial = FeatureKibanaPrivilegesSet -> { +export interface FeatureConfig { /** * Unique identifier for this feature. * This identifier is also used when generating UI Capabilities. @@ -28,6 +28,11 @@ export interface Feature< */ name: string; + /** + * An ordinal used to sort features relative to one another for display. + */ + order?: number; + /** * Whether or not this feature should be excluded from the base privileges. * This is primarily helpful when migrating applications with a "legacy" privileges model @@ -98,7 +103,15 @@ export interface Feature< * ``` * @see FeatureKibanaPrivileges */ - privileges: TPrivileges; + privileges: { + all: FeatureKibanaPrivileges; + read: FeatureKibanaPrivileges; + } | null; + + /** + * Optional sub-feature privilege definitions. This can only be specified if `privileges` are are also defined. + */ + subFeatures?: SubFeatureConfig[]; /** * Optional message to display on the Role Management screen when configuring permissions for this feature. @@ -114,7 +127,64 @@ export interface Feature< }; } -export type FeatureWithAllOrReadPrivileges = Feature<{ - all?: FeatureKibanaPrivileges; - read?: FeatureKibanaPrivileges; -}>; +export class Feature { + public readonly subFeatures: SubFeature[]; + + constructor(protected readonly config: RecursiveReadonly) { + this.subFeatures = (config.subFeatures ?? []).map( + subFeatureConfig => new SubFeature(subFeatureConfig) + ); + } + + public get id() { + return this.config.id; + } + + public get name() { + return this.config.name; + } + + public get order() { + return this.config.order; + } + + public get navLinkId() { + return this.config.navLinkId; + } + + public get app() { + return this.config.app; + } + + public get catalogue() { + return this.config.catalogue; + } + + public get management() { + return this.config.management; + } + + public get icon() { + return this.config.icon; + } + + public get validLicenses() { + return this.config.validLicenses; + } + + public get privileges() { + return this.config.privileges; + } + + public get excludeFromBasePrivileges() { + return this.config.excludeFromBasePrivileges ?? false; + } + + public get reserved() { + return this.config.reserved; + } + + public toRaw() { + return { ...this.config } as FeatureConfig; + } +} diff --git a/x-pack/plugins/features/common/feature_kibana_privileges.ts b/x-pack/plugins/features/common/feature_kibana_privileges.ts index 1d14f3728282c..768c8c6ae1088 100644 --- a/x-pack/plugins/features/common/feature_kibana_privileges.ts +++ b/x-pack/plugins/features/common/feature_kibana_privileges.ts @@ -123,5 +123,3 @@ export interface FeatureKibanaPrivileges { */ ui: string[]; } - -export type FeatureKibanaPrivilegesSet = Record; diff --git a/x-pack/plugins/features/common/index.ts b/x-pack/plugins/features/common/index.ts index 6111d7d25a61b..e359efbda20d2 100644 --- a/x-pack/plugins/features/common/index.ts +++ b/x-pack/plugins/features/common/index.ts @@ -5,4 +5,11 @@ */ export { FeatureKibanaPrivileges } from './feature_kibana_privileges'; -export * from './feature'; +export { Feature, FeatureConfig } from './feature'; +export { + SubFeature, + SubFeatureConfig, + SubFeaturePrivilegeConfig, + SubFeaturePrivilegeGroupConfig, + SubFeaturePrivilegeGroupType, +} from './sub_feature'; diff --git a/x-pack/plugins/features/common/sub_feature.ts b/x-pack/plugins/features/common/sub_feature.ts new file mode 100644 index 0000000000000..121bb8514c8a2 --- /dev/null +++ b/x-pack/plugins/features/common/sub_feature.ts @@ -0,0 +1,87 @@ +/* + * 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 { RecursiveReadonly } from '@kbn/utility-types'; +import { FeatureKibanaPrivileges } from './feature_kibana_privileges'; + +/** + * Configuration for a sub-feature. + */ +export interface SubFeatureConfig { + /** Display name for this sub-feature */ + name: string; + + /** Collection of privilege groups */ + privilegeGroups: SubFeaturePrivilegeGroupConfig[]; +} + +/** + * The type of privilege group. + * - `mutually_exclusive`:: + * Users will be able to select at most one privilege within this group. + * Privileges must be specified in descending order of permissiveness (e.g. `All`, `Read`, not `Read`, `All) + * - `independent`:: + * Users will be able to select any combination of privileges within this group. + */ +export type SubFeaturePrivilegeGroupType = 'mutually_exclusive' | 'independent'; + +/** + * Configuration for a sub-feature privilege group. + */ +export interface SubFeaturePrivilegeGroupConfig { + /** + * The type of privilege group. + * - `mutually_exclusive`:: + * Users will be able to select at most one privilege within this group. + * Privileges must be specified in descending order of permissiveness (e.g. `All`, `Read`, not `Read`, `All) + * - `independent`:: + * Users will be able to select any combination of privileges within this group. + */ + groupType: SubFeaturePrivilegeGroupType; + + /** + * The privileges which belong to this group. + */ + privileges: SubFeaturePrivilegeConfig[]; +} + +/** + * Configuration for a sub-feature privilege. + */ +export interface SubFeaturePrivilegeConfig + extends Omit { + /** + * Identifier for this privilege. Must be unique across all other privileges within a feature. + */ + id: string; + + /** + * The display name for this privilege. + */ + name: string; + + /** + * Denotes which Primary Feature Privilege this sub-feature privilege should be included in. + * `read` is also included in `all` automatically. + */ + includeIn: 'all' | 'read' | 'none'; +} + +export class SubFeature { + constructor(protected readonly config: RecursiveReadonly) {} + + public get name() { + return this.config.name; + } + + public get privilegeGroups() { + return this.config.privilegeGroups; + } + + public toRaw() { + return { ...this.config }; + } +} diff --git a/x-pack/plugins/features/kibana.json b/x-pack/plugins/features/kibana.json index 553e920f0e720..e38d7be892904 100644 --- a/x-pack/plugins/features/kibana.json +++ b/x-pack/plugins/features/kibana.json @@ -4,5 +4,5 @@ "kibanaVersion": "kibana", "optionalPlugins": ["timelion"], "server": true, - "ui": false + "ui": true } diff --git a/x-pack/plugins/features/public/features_api_client.test.ts b/x-pack/plugins/features/public/features_api_client.test.ts new file mode 100644 index 0000000000000..e3a25ad57425c --- /dev/null +++ b/x-pack/plugins/features/public/features_api_client.test.ts @@ -0,0 +1,44 @@ +/* + * 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 { coreMock } from 'src/core/public/mocks'; +import { FeaturesAPIClient } from './features_api_client'; + +describe('Features API Client', () => { + describe('#getFeatures', () => { + it('returns an array of Features', async () => { + const rawFeatures = [ + { + id: 'feature-a', + }, + { + id: 'feature-b', + }, + { + id: 'feature-c', + }, + { + id: 'feature-d', + }, + { + id: 'feature-e', + }, + ]; + const coreSetup = coreMock.createSetup(); + coreSetup.http.get.mockResolvedValue(rawFeatures); + + const client = new FeaturesAPIClient(coreSetup.http); + const result = await client.getFeatures(); + expect(result.map(f => f.id)).toEqual([ + 'feature-a', + 'feature-b', + 'feature-c', + 'feature-d', + 'feature-e', + ]); + }); + }); +}); diff --git a/x-pack/plugins/features/public/features_api_client.ts b/x-pack/plugins/features/public/features_api_client.ts new file mode 100644 index 0000000000000..b93c9bf917d79 --- /dev/null +++ b/x-pack/plugins/features/public/features_api_client.ts @@ -0,0 +1,17 @@ +/* + * 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 { HttpSetup } from 'src/core/public'; +import { FeatureConfig, Feature } from '.'; + +export class FeaturesAPIClient { + constructor(private readonly http: HttpSetup) {} + + public async getFeatures() { + const features = await this.http.get('/api/features'); + return features.map(config => new Feature(config)); + } +} diff --git a/x-pack/plugins/features/public/index.ts b/x-pack/plugins/features/public/index.ts index 6a2c99aad4bd8..f19c7f947d97f 100644 --- a/x-pack/plugins/features/public/index.ts +++ b/x-pack/plugins/features/public/index.ts @@ -4,4 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -export { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common'; +import { PluginInitializer } from 'src/core/public'; +import { FeaturesPlugin, FeaturesPluginSetup, FeaturesPluginStart } from './plugin'; + +export { + Feature, + FeatureConfig, + FeatureKibanaPrivileges, + SubFeatureConfig, + SubFeaturePrivilegeConfig, +} from '../common'; + +export { FeaturesPluginSetup, FeaturesPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new FeaturesPlugin(); diff --git a/x-pack/plugins/features/public/mocks.ts b/x-pack/plugins/features/public/mocks.ts new file mode 100644 index 0000000000000..014883f3ce9cf --- /dev/null +++ b/x-pack/plugins/features/public/mocks.ts @@ -0,0 +1,17 @@ +/* + * 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 { FeaturesPluginStart } from './plugin'; + +const createStart = (): jest.Mocked => { + return { + getFeatures: jest.fn(), + }; +}; + +export const featuresPluginMock = { + createStart, +}; diff --git a/x-pack/plugins/features/public/plugin.test.ts b/x-pack/plugins/features/public/plugin.test.ts new file mode 100644 index 0000000000000..aab712d647508 --- /dev/null +++ b/x-pack/plugins/features/public/plugin.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FeaturesPlugin } from './plugin'; + +import { coreMock, httpServiceMock } from 'src/core/public/mocks'; + +jest.mock('./features_api_client', () => { + const instance = { + getFeatures: jest.fn(), + }; + return { + FeaturesAPIClient: jest.fn().mockImplementation(() => instance), + }; +}); + +import { FeaturesAPIClient } from './features_api_client'; + +describe('Features Plugin', () => { + describe('#setup', () => { + it('returns expected public contract', () => { + const plugin = new FeaturesPlugin(); + expect(plugin.setup(coreMock.createSetup())).toMatchInlineSnapshot(`undefined`); + }); + }); + + describe('#start', () => { + it('returns expected public contract', () => { + const plugin = new FeaturesPlugin(); + plugin.setup(coreMock.createSetup()); + + expect(plugin.start()).toMatchInlineSnapshot(` + Object { + "getFeatures": [Function], + } + `); + }); + + it('#getFeatures calls the underlying FeaturesAPIClient', () => { + const plugin = new FeaturesPlugin(); + const apiClient = new FeaturesAPIClient(httpServiceMock.createSetupContract()); + + plugin.setup(coreMock.createSetup()); + + const start = plugin.start(); + start.getFeatures(); + expect(apiClient.getFeatures).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/features/public/plugin.ts b/x-pack/plugins/features/public/plugin.ts new file mode 100644 index 0000000000000..c168384dae78f --- /dev/null +++ b/x-pack/plugins/features/public/plugin.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 { Plugin, CoreSetup } from 'src/core/public'; +import { FeaturesAPIClient } from './features_api_client'; + +export class FeaturesPlugin implements Plugin { + private apiClient?: FeaturesAPIClient; + + public setup(core: CoreSetup) { + this.apiClient = new FeaturesAPIClient(core.http); + } + + public start() { + return { + getFeatures: () => this.apiClient!.getFeatures(), + }; + } + + public stop() {} +} + +export type FeaturesPluginSetup = ReturnType; +export type FeaturesPluginStart = ReturnType; diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap new file mode 100644 index 0000000000000..ee94d0d40b853 --- /dev/null +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -0,0 +1,458 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`buildOSSFeatures returns the advancedSettings feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "advanced_settings", + ], + "management": Object { + "kibana": Array [ + "settings", + ], + }, + "savedObject": Object { + "all": Array [ + "config", + ], + "read": Array [], + }, + "ui": Array [ + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "advanced_settings", + ], + "management": Object { + "kibana": Array [ + "settings", + ], + }, + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the dashboard feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "dashboard", + ], + "management": Object {}, + "savedObject": Object { + "all": Array [ + "dashboard", + "url", + "query", + ], + "read": Array [ + "index-pattern", + "search", + "visualization", + "timelion-sheet", + "canvas-workpad", + "lens", + "map", + ], + }, + "ui": Array [ + "createNew", + "show", + "showWriteControls", + "saveQuery", + "createShortUrl", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "dashboard", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "search", + "visualization", + "timelion-sheet", + "canvas-workpad", + "map", + "dashboard", + "query", + ], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the dev_tools feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [ + "console", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "console", + "searchprofiler", + "grokdebugger", + ], + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "show", + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "api": Array [ + "console", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "console", + "searchprofiler", + "grokdebugger", + ], + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the discover feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "discover", + ], + "management": Object {}, + "savedObject": Object { + "all": Array [ + "search", + "query", + "url", + ], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [ + "show", + "save", + "saveQuery", + "createShortUrl", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "discover", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "search", + "query", + ], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the indexPatterns feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "index_patterns", + ], + "management": Object { + "kibana": Array [ + "index_patterns", + ], + }, + "savedObject": Object { + "all": Array [ + "index-pattern", + ], + "read": Array [], + }, + "ui": Array [ + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "index_patterns", + ], + "management": Object { + "kibana": Array [ + "index_patterns", + ], + }, + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the savedObjectsManagement feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [ + "copySavedObjectsToSpaces", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "saved_objects", + ], + "management": Object { + "kibana": Array [ + "objects", + ], + }, + "savedObject": Object { + "all": Array [ + "foo", + "bar", + ], + "read": Array [], + }, + "ui": Array [ + "read", + "edit", + "delete", + "copyIntoSpace", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "api": Array [ + "copySavedObjectsToSpaces", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "saved_objects", + ], + "management": Object { + "kibana": Array [ + "objects", + ], + }, + "savedObject": Object { + "all": Array [], + "read": Array [ + "foo", + "bar", + ], + }, + "ui": Array [ + "read", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the timelion feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "timelion", + "kibana", + ], + "catalogue": Array [ + "timelion", + ], + "savedObject": Object { + "all": Array [ + "timelion-sheet", + ], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [ + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "timelion", + "kibana", + ], + "catalogue": Array [ + "timelion", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "timelion-sheet", + ], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the visualize feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [], + "app": Array [ + "kibana", + "lens", + ], + "catalogue": Array [ + "visualize", + ], + "management": Object {}, + "savedObject": Object { + "all": Array [ + "visualization", + "query", + "lens", + "url", + ], + "read": Array [ + "index-pattern", + "search", + ], + }, + "ui": Array [ + "show", + "delete", + "save", + "saveQuery", + "createShortUrl", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + "lens", + ], + "catalogue": Array [ + "visualize", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "search", + "visualization", + "query", + "lens", + ], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index 7b25035892668..5b4f7728c9f31 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -5,15 +5,15 @@ */ import { FeatureRegistry } from './feature_registry'; -import { Feature } from '../common/feature'; +import { FeatureConfig } from '../common/feature'; describe('FeatureRegistry', () => { it('allows a minimal feature to be registered', () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], - privileges: {}, + privileges: null, }; const featureRegistry = new FeatureRegistry(); @@ -22,18 +22,18 @@ describe('FeatureRegistry', () => { expect(result).toHaveLength(1); // Should be the equal, but not the same instance (i.e., a defensive copy) - expect(result[0]).not.toBe(feature); - expect(result[0]).toEqual(feature); + expect(result[0].toRaw()).not.toBe(feature); + expect(result[0].toRaw()).toEqual(feature); }); it('allows a complex feature to be registered', () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', excludeFromBasePrivileges: true, icon: 'addDataApp', navLinkId: 'someNavLink', - app: ['app1', 'app2'], + app: ['app1'], validLicenses: ['standard', 'basic', 'gold', 'platinum'], catalogue: ['foo'], management: { @@ -53,7 +53,61 @@ describe('FeatureRegistry', () => { api: ['someApiEndpointTag', 'anotherEndpointTag'], ui: ['allowsFoo', 'showBar', 'showBaz'], }, + read: { + savedObject: { + all: [], + read: ['config', 'url'], + }, + ui: [], + }, }, + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'foo', + name: 'foo', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'bar', + name: 'bar', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + { + id: 'baz', + name: 'baz', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + ], + }, + ], privilegesTooltip: 'some fancy tooltip', reserved: { privilege: { @@ -79,12 +133,61 @@ describe('FeatureRegistry', () => { expect(result).toHaveLength(1); // Should be the equal, but not the same instance (i.e., a defensive copy) - expect(result[0]).not.toBe(feature); - expect(result[0]).toEqual(feature); + expect(result[0].toRaw()).not.toBe(feature); + expect(result[0].toRaw()).toEqual(feature); + }); + + it(`requires a value for privileges`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + } as any; + + const featureRegistry = new FeatureRegistry(); + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"child \\"privileges\\" fails because [\\"privileges\\" is required]"` + ); + }); + + it(`does not allow sub-features to be registered when no primary privileges are not registered`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'my-sub-priv', + name: 'my sub priv', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"child \\"subFeatures\\" fails because [\\"subFeatures\\" must contain less than or equal to 0 items]"` + ); }); it(`automatically grants 'all' access to telemetry saved objects for the 'all' privilege`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -96,6 +199,13 @@ describe('FeatureRegistry', () => { read: [], }, }, + read: { + ui: [], + savedObject: { + all: [], + read: [], + }, + }, }, }; @@ -103,12 +213,15 @@ describe('FeatureRegistry', () => { featureRegistry.register(feature); const result = featureRegistry.getAll(); - const allPrivilege = result[0].privileges.all; - expect(allPrivilege.savedObject.all).toEqual(['telemetry']); + expect(result[0].privileges).toHaveProperty('all'); + expect(result[0].privileges).toHaveProperty('read'); + + const allPrivilege = result[0].privileges?.all; + expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); }); it(`automatically grants 'read' access to config and url saved objects for both privileges`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -134,18 +247,21 @@ describe('FeatureRegistry', () => { featureRegistry.register(feature); const result = featureRegistry.getAll(); - const allPrivilege = result[0].privileges.all; - const readPrivilege = result[0].privileges.read; - expect(allPrivilege.savedObject.read).toEqual(['config', 'url']); - expect(readPrivilege.savedObject.read).toEqual(['config', 'url']); + expect(result[0].privileges).toHaveProperty('all'); + expect(result[0].privileges).toHaveProperty('read'); + + const allPrivilege = result[0].privileges?.all; + const readPrivilege = result[0].privileges?.read; + expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); + expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); }); it(`automatically grants 'all' access to telemetry and 'read' to [config, url] saved objects for the reserved privilege`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], - privileges: {}, + privileges: null, reserved: { description: 'foo', privilege: { @@ -168,7 +284,7 @@ describe('FeatureRegistry', () => { }); it(`does not duplicate the automatic grants if specified on the incoming feature`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -194,26 +310,29 @@ describe('FeatureRegistry', () => { featureRegistry.register(feature); const result = featureRegistry.getAll(); - const allPrivilege = result[0].privileges.all; - const readPrivilege = result[0].privileges.read; - expect(allPrivilege.savedObject.all).toEqual(['telemetry']); - expect(allPrivilege.savedObject.read).toEqual(['config', 'url']); - expect(readPrivilege.savedObject.read).toEqual(['config', 'url']); + expect(result[0].privileges).toHaveProperty('all'); + expect(result[0].privileges).toHaveProperty('read'); + + const allPrivilege = result[0].privileges!.all; + const readPrivilege = result[0].privileges!.read; + expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); + expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); + expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); }); it(`does not allow duplicate features to be registered`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], - privileges: {}, + privileges: null, }; - const duplicateFeature: Feature = { + const duplicateFeature: FeatureConfig = { id: 'test-feature', name: 'Duplicate Test Feature', app: [], - privileges: {}, + privileges: null, }; const featureRegistry = new FeatureRegistry(); @@ -233,7 +352,7 @@ describe('FeatureRegistry', () => { name: 'some feature', navLinkId: prohibitedChars, app: [], - privileges: {}, + privileges: null, }) ).toThrowErrorMatchingSnapshot(); }); @@ -248,7 +367,7 @@ describe('FeatureRegistry', () => { kibana: [prohibitedChars], }, app: [], - privileges: {}, + privileges: null, }) ).toThrowErrorMatchingSnapshot(); }); @@ -261,7 +380,7 @@ describe('FeatureRegistry', () => { name: 'some feature', catalogue: [prohibitedChars], app: [], - privileges: {}, + privileges: null, }) ).toThrowErrorMatchingSnapshot(); }); @@ -275,19 +394,20 @@ describe('FeatureRegistry', () => { id: prohibitedId, name: 'some feature', app: [], - privileges: {}, + privileges: null, }) ).toThrowErrorMatchingSnapshot(); }); }); it('prevents features from being registered with invalid privilege names', () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: ['app1', 'app2'], privileges: { foo: { + name: 'Foo', app: ['app1', 'app2'], savedObject: { all: ['config', 'space', 'etc'], @@ -296,7 +416,7 @@ describe('FeatureRegistry', () => { api: ['someApiEndpointTag', 'anotherEndpointTag'], ui: ['allowsFoo', 'showBar', 'showBaz'], }, - }, + } as any, }; const featureRegistry = new FeatureRegistry(); @@ -306,7 +426,7 @@ describe('FeatureRegistry', () => { }); it(`prevents privileges from specifying app entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: ['bar'], @@ -319,6 +439,14 @@ describe('FeatureRegistry', () => { ui: [], app: ['foo', 'bar', 'baz'], }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo', 'bar', 'baz'], + }, }, }; @@ -329,12 +457,67 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying app entries that don't exist at the privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: ['foo', 'bar', 'baz'], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['bar'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo'], + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies app entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents reserved privileges from specifying app entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: ['bar'], - privileges: {}, + privileges: null, reserved: { description: 'something', privilege: { @@ -355,8 +538,34 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying app entries that don't exist at the reserved privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: ['foo', 'bar', 'baz'], + privileges: null, + reserved: { + description: 'something', + privilege: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo', 'bar'], + }, + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies app entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents privileges from specifying catalogue entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -371,6 +580,15 @@ describe('FeatureRegistry', () => { ui: [], app: [], }, + read: { + catalogue: ['foo', 'bar', 'baz'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, }, }; @@ -381,13 +599,71 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying catalogue entries that don't exist at the privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['foo', 'bar', 'baz'], + privileges: { + all: { + catalogue: ['foo'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + read: { + catalogue: ['foo'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + catalogue: ['bar'], + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents reserved privileges from specifying catalogue entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], catalogue: ['bar'], - privileges: {}, + privileges: null, reserved: { description: 'something', privilege: { @@ -409,8 +685,36 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying catalogue entries that don't exist at the reserved privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['foo', 'bar', 'baz'], + privileges: null, + reserved: { + description: 'something', + privilege: { + catalogue: ['foo', 'bar'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents privileges from specifying management sections that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -431,6 +735,18 @@ describe('FeatureRegistry', () => { ui: [], app: [], }, + read: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, }, }; @@ -441,8 +757,79 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying management sections that don't exist at the privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['bar'], + management: { + kibana: ['hey'], + elasticsearch: ['hey', 'there'], + }, + privileges: { + all: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + read: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + management: { + kibana: ['hey'], + elasticsearch: ['hey'], + }, + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies management entries which are not granted to any privileges: elasticsearch.there"` + ); + }); + it(`prevents reserved privileges from specifying management entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -450,7 +837,7 @@ describe('FeatureRegistry', () => { management: { kibana: ['hey'], }, - privileges: {}, + privileges: null, reserved: { description: 'something', privilege: { @@ -475,18 +862,52 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying management entries that don't exist at the reserved privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['bar'], + management: { + kibana: ['hey', 'hey-there'], + }, + privileges: null, + reserved: { + description: 'something', + privilege: { + catalogue: ['bar'], + management: { + kibana: ['hey-there'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies management entries which are not granted to any privileges: kibana.hey"` + ); + }); + it('cannot register feature after getAll has been called', () => { - const feature1: Feature = { + const feature1: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], - privileges: {}, + privileges: null, }; - const feature2: Feature = { + const feature2: FeatureConfig = { id: 'test-feature-2', name: 'Test Feature 2', app: [], - privileges: {}, + privileges: null, }; const featureRegistry = new FeatureRegistry(); diff --git a/x-pack/plugins/features/server/feature_registry.ts b/x-pack/plugins/features/server/feature_registry.ts index 60a229fc58612..73a353cd27471 100644 --- a/x-pack/plugins/features/server/feature_registry.ts +++ b/x-pack/plugins/features/server/feature_registry.ts @@ -5,14 +5,14 @@ */ import { cloneDeep, uniq } from 'lodash'; -import { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common'; +import { FeatureConfig, Feature, FeatureKibanaPrivileges } from '../common'; import { validateFeature } from './feature_schema'; export class FeatureRegistry { private locked = false; - private features: Record = {}; + private features: Record = {}; - public register(feature: FeatureWithAllOrReadPrivileges) { + public register(feature: FeatureConfig) { if (this.locked) { throw new Error( `Features are locked, can't register new features. Attempt to register ${feature.id} failed.` @@ -25,20 +25,21 @@ export class FeatureRegistry { throw new Error(`Feature with id ${feature.id} is already registered.`); } - const featureCopy: Feature = cloneDeep(feature as Feature); + const featureCopy = cloneDeep(feature); - this.features[feature.id] = applyAutomaticPrivilegeGrants(featureCopy as Feature); + this.features[feature.id] = applyAutomaticPrivilegeGrants(featureCopy); } public getAll(): Feature[] { this.locked = true; - return cloneDeep(Object.values(this.features)); + return Object.values(this.features).map(featureConfig => new Feature(featureConfig)); } } -function applyAutomaticPrivilegeGrants(feature: Feature): Feature { - const { all: allPrivilege, read: readPrivilege } = feature.privileges; - const reservedPrivilege = feature.reserved ? feature.reserved.privilege : null; +function applyAutomaticPrivilegeGrants(feature: FeatureConfig): FeatureConfig { + const allPrivilege = feature.privileges?.all; + const readPrivilege = feature.privileges?.read; + const reservedPrivilege = feature.reserved?.privilege; applyAutomaticAllPrivilegeGrants(allPrivilege, reservedPrivilege); applyAutomaticReadPrivilegeGrants(readPrivilege); @@ -46,7 +47,9 @@ function applyAutomaticPrivilegeGrants(feature: Feature): Feature { return feature; } -function applyAutomaticAllPrivilegeGrants(...allPrivileges: Array) { +function applyAutomaticAllPrivilegeGrants( + ...allPrivileges: Array +) { allPrivileges.forEach(allPrivilege => { if (allPrivilege) { allPrivilege.savedObject.all = uniq([...allPrivilege.savedObject.all, 'telemetry']); @@ -56,7 +59,7 @@ function applyAutomaticAllPrivilegeGrants(...allPrivileges: Array + ...readPrivileges: Array ) { readPrivileges.forEach(readPrivilege => { if (readPrivilege) { diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index cc12ea1b78dce..fdeceb30b4e3d 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -8,13 +8,15 @@ import Joi from 'joi'; import { difference } from 'lodash'; import { Capabilities as UICapabilities } from '../../../../src/core/server'; -import { FeatureWithAllOrReadPrivileges } from '../common/feature'; +import { FeatureConfig } from '../common/feature'; +import { FeatureKibanaPrivileges } from '.'; // Each feature gets its own property on the UICapabilities object, // but that object has a few built-in properties which should not be overwritten. const prohibitedFeatureIds: Array = ['catalogue', 'management', 'navLinks']; const featurePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/; +const subFeaturePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/; const managementSectionIdRegex = /^[a-zA-Z0-9_-]+$/; export const uiCapabilitiesRegex = /^[a-zA-Z0-9:_-]+$/; @@ -43,12 +45,52 @@ const privilegeSchema = Joi.object({ .required(), }); +const subFeaturePrivilegeSchema = Joi.object({ + id: Joi.string() + .regex(subFeaturePrivilegePartRegex) + .required(), + name: Joi.string().required(), + includeIn: Joi.string() + .allow('all', 'read', 'none') + .required(), + management: managementSchema, + catalogue: catalogueSchema, + api: Joi.array().items(Joi.string()), + app: Joi.array().items(Joi.string()), + savedObject: Joi.object({ + all: Joi.array() + .items(Joi.string()) + .required(), + read: Joi.array() + .items(Joi.string()) + .required(), + }).required(), + ui: Joi.array() + .items(Joi.string().regex(uiCapabilitiesRegex)) + .required(), +}); + +const subFeatureSchema = Joi.object({ + name: Joi.string().required(), + privilegeGroups: Joi.array().items( + Joi.object({ + groupType: Joi.string() + .valid('mutually_exclusive', 'independent') + .required(), + privileges: Joi.array() + .items(subFeaturePrivilegeSchema) + .min(1), + }) + ), +}); + const schema = Joi.object({ id: Joi.string() .regex(featurePrivilegePartRegex) .invalid(...prohibitedFeatureIds) .required(), name: Joi.string().required(), + order: Joi.number(), excludeFromBasePrivileges: Joi.boolean(), validLicenses: Joi.array().items( Joi.string().valid('basic', 'standard', 'gold', 'platinum', 'enterprise', 'trial') @@ -64,7 +106,16 @@ const schema = Joi.object({ privileges: Joi.object({ all: privilegeSchema, read: privilegeSchema, - }).required(), + }) + .allow(null) + .required(), + subFeatures: Joi.when('privileges', { + is: null, + then: Joi.array() + .items(subFeatureSchema) + .max(0), + otherwise: Joi.array().items(subFeatureSchema), + }), privilegesTooltip: Joi.string(), reserved: Joi.object({ privilege: privilegeSchema.required(), @@ -72,7 +123,7 @@ const schema = Joi.object({ }), }); -export function validateFeature(feature: FeatureWithAllOrReadPrivileges) { +export function validateFeature(feature: FeatureConfig) { const validateResult = Joi.validate(feature, schema); if (validateResult.error) { throw validateResult.error; @@ -80,17 +131,21 @@ export function validateFeature(feature: FeatureWithAllOrReadPrivileges) { // the following validation can't be enforced by the Joi schema, since it'd require us looking "up" the object graph for the list of valid value, which they explicitly forbid. const { app = [], management = {}, catalogue = [] } = feature; - const privilegeEntries = [...Object.entries(feature.privileges)]; - if (feature.reserved) { - privilegeEntries.push(['reserved', feature.reserved.privilege]); - } + const unseenApps = new Set(app); - privilegeEntries.forEach(([privilegeId, privilegeDefinition]) => { - if (!privilegeDefinition) { - throw new Error('Privilege definition may not be null or undefined'); - } + const managementSets = Object.entries(management).map(entry => [ + entry[0], + new Set(entry[1]), + ]) as Array<[string, Set]>; + + const unseenManagement = new Map>(managementSets); + + const unseenCatalogue = new Set(catalogue); + + function validateAppEntry(privilegeId: string, entry: string[] = []) { + entry.forEach(privilegeApp => unseenApps.delete(privilegeApp)); - const unknownAppEntries = difference(privilegeDefinition.app || [], app); + const unknownAppEntries = difference(entry, app); if (unknownAppEntries.length > 0) { throw new Error( `Feature privilege ${ @@ -98,8 +153,12 @@ export function validateFeature(feature: FeatureWithAllOrReadPrivileges) { }.${privilegeId} has unknown app entries: ${unknownAppEntries.join(', ')}` ); } + } + + function validateCatalogueEntry(privilegeId: string, entry: string[] = []) { + entry.forEach(privilegeCatalogue => unseenCatalogue.delete(privilegeCatalogue)); - const unknownCatalogueEntries = difference(privilegeDefinition.catalogue || [], catalogue); + const unknownCatalogueEntries = difference(entry || [], catalogue); if (unknownCatalogueEntries.length > 0) { throw new Error( `Feature privilege ${ @@ -107,27 +166,113 @@ export function validateFeature(feature: FeatureWithAllOrReadPrivileges) { }.${privilegeId} has unknown catalogue entries: ${unknownCatalogueEntries.join(', ')}` ); } + } - Object.entries(privilegeDefinition.management || {}).forEach( - ([managementSectionId, managementEntry]) => { - if (!management[managementSectionId]) { - throw new Error( - `Feature privilege ${feature.id}.${privilegeId} has unknown management section: ${managementSectionId}` - ); - } - - const unknownSectionEntries = difference(managementEntry, management[managementSectionId]); - - if (unknownSectionEntries.length > 0) { - throw new Error( - `Feature privilege ${ - feature.id - }.${privilegeId} has unknown management entries for section ${managementSectionId}: ${unknownSectionEntries.join( - ', ' - )}` - ); - } + function validateManagementEntry( + privilegeId: string, + managementEntry: Record = {} + ) { + Object.entries(managementEntry).forEach(([managementSectionId, managementSectionEntry]) => { + if (unseenManagement.has(managementSectionId)) { + managementSectionEntry.forEach(entry => { + unseenManagement.get(managementSectionId)!.delete(entry); + if (unseenManagement.get(managementSectionId)?.size === 0) { + unseenManagement.delete(managementSectionId); + } + }); } - ); + if (!management[managementSectionId]) { + throw new Error( + `Feature privilege ${feature.id}.${privilegeId} has unknown management section: ${managementSectionId}` + ); + } + + const unknownSectionEntries = difference( + managementSectionEntry, + management[managementSectionId] + ); + + if (unknownSectionEntries.length > 0) { + throw new Error( + `Feature privilege ${ + feature.id + }.${privilegeId} has unknown management entries for section ${managementSectionId}: ${unknownSectionEntries.join( + ', ' + )}` + ); + } + }); + } + + const privilegeEntries: Array<[string, FeatureKibanaPrivileges]> = []; + if (feature.privileges) { + privilegeEntries.push(...Object.entries(feature.privileges)); + } + if (feature.reserved) { + privilegeEntries.push(['reserved', feature.reserved.privilege]); + } + + if (privilegeEntries.length === 0) { + return; + } + + privilegeEntries.forEach(([privilegeId, privilegeDefinition]) => { + if (!privilegeDefinition) { + throw new Error('Privilege definition may not be null or undefined'); + } + + validateAppEntry(privilegeId, privilegeDefinition.app); + + validateCatalogueEntry(privilegeId, privilegeDefinition.catalogue); + + validateManagementEntry(privilegeId, privilegeDefinition.management); + }); + + const subFeatureEntries = feature.subFeatures ?? []; + subFeatureEntries.forEach(subFeature => { + subFeature.privilegeGroups.forEach(subFeaturePrivilegeGroup => { + subFeaturePrivilegeGroup.privileges.forEach(subFeaturePrivilege => { + validateAppEntry(subFeaturePrivilege.id, subFeaturePrivilege.app); + validateCatalogueEntry(subFeaturePrivilege.id, subFeaturePrivilege.catalogue); + validateManagementEntry(subFeaturePrivilege.id, subFeaturePrivilege.management); + }); + }); }); + + if (unseenApps.size > 0) { + throw new Error( + `Feature ${ + feature.id + } specifies app entries which are not granted to any privileges: ${Array.from( + unseenApps.values() + ).join(',')}` + ); + } + + if (unseenCatalogue.size > 0) { + throw new Error( + `Feature ${ + feature.id + } specifies catalogue entries which are not granted to any privileges: ${Array.from( + unseenCatalogue.values() + ).join(',')}` + ); + } + + if (unseenManagement.size > 0) { + const ungrantedManagement = Array.from(unseenManagement.entries()).reduce((acc, entry) => { + const values = Array.from(entry[1].values()).map( + managementPage => `${entry[0]}.${managementPage}` + ); + return [...acc, ...values]; + }, [] as string[]); + + throw new Error( + `Feature ${ + feature.id + } specifies management entries which are not granted to any privileges: ${ungrantedManagement.join( + ',' + )}` + ); + } } diff --git a/x-pack/plugins/features/server/index.ts b/x-pack/plugins/features/server/index.ts index 48ef97a494f7e..48a350ae8f8fd 100644 --- a/x-pack/plugins/features/server/index.ts +++ b/x-pack/plugins/features/server/index.ts @@ -13,7 +13,7 @@ import { Plugin } from './plugin'; // run-time contracts. export { uiCapabilitiesRegex } from './feature_schema'; -export { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common'; +export { Feature, FeatureConfig, FeatureKibanaPrivileges } from '../common'; export { PluginSetupContract, PluginStartContract } from './plugin'; export const plugin = (initializerContext: PluginInitializerContext) => diff --git a/x-pack/plugins/features/server/oss_features.test.ts b/x-pack/plugins/features/server/oss_features.test.ts index 987af08fe7cda..72beff02173d2 100644 --- a/x-pack/plugins/features/server/oss_features.test.ts +++ b/x-pack/plugins/features/server/oss_features.test.ts @@ -5,6 +5,8 @@ */ import { buildOSSFeatures } from './oss_features'; +import { featurePrivilegeIterator } from '../../security/server/authorization'; +import { Feature } from '.'; describe('buildOSSFeatures', () => { it('returns features including timelion', () => { @@ -39,4 +41,17 @@ Array [ ] `); }); + + const features = buildOSSFeatures({ savedObjectTypes: ['foo', 'bar'], includeTimelion: true }); + features.forEach(featureConfig => { + it(`returns the ${featureConfig.id} feature augmented with appropriate sub feature privileges`, () => { + const privileges = []; + for (const featurePrivilege of featurePrivilegeIterator(new Feature(featureConfig), { + augmentWithSubFeaturePrivileges: true, + })) { + privileges.push(featurePrivilege); + } + expect(privileges).toMatchSnapshot(); + }); + }); }); diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index b48963ebb8139..3e8ce37fd1578 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { Feature } from '../common/feature'; +import { FeatureConfig } from '../common/feature'; export interface BuildOSSFeaturesParams { savedObjectTypes: string[]; @@ -18,19 +18,24 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.discoverFeatureName', { defaultMessage: 'Discover', }), + order: 100, icon: 'discoverApp', navLinkId: 'kibana:discover', app: ['kibana'], catalogue: ['discover'], privileges: { all: { + app: ['kibana'], + catalogue: ['discover'], savedObject: { - all: ['search', 'url', 'query'], + all: ['search', 'query'], read: ['index-pattern'], }, - ui: ['show', 'createShortUrl', 'save', 'saveQuery'], + ui: ['show', 'save', 'saveQuery'], }, read: { + app: ['kibana'], + catalogue: ['discover'], savedObject: { all: [], read: ['index-pattern', 'search', 'query'], @@ -38,25 +43,59 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['show'], }, }, + subFeatures: [ + { + name: i18n.translate('xpack.features.ossFeatures.discoverShortUrlSubFeatureName', { + defaultMessage: 'Short URLs', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'url_create', + name: i18n.translate( + 'xpack.features.ossFeatures.discoverCreateShortUrlPrivilegeName', + { + defaultMessage: 'Create Short URLs', + } + ), + includeIn: 'all', + savedObject: { + all: ['url'], + read: [], + }, + ui: ['createShortUrl'], + }, + ], + }, + ], + }, + ], }, { id: 'visualize', name: i18n.translate('xpack.features.visualizeFeatureName', { defaultMessage: 'Visualize', }), + order: 200, icon: 'visualizeApp', navLinkId: 'kibana:visualize', app: ['kibana', 'lens'], catalogue: ['visualize'], privileges: { all: { + app: ['kibana', 'lens'], + catalogue: ['visualize'], savedObject: { - all: ['visualization', 'url', 'query', 'lens'], + all: ['visualization', 'query', 'lens'], read: ['index-pattern', 'search'], }, - ui: ['show', 'createShortUrl', 'delete', 'save', 'saveQuery'], + ui: ['show', 'delete', 'save', 'saveQuery'], }, read: { + app: ['kibana', 'lens'], + catalogue: ['visualize'], savedObject: { all: [], read: ['index-pattern', 'search', 'visualization', 'query', 'lens'], @@ -64,18 +103,50 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['show'], }, }, + subFeatures: [ + { + name: i18n.translate('xpack.features.ossFeatures.visualizeShortUrlSubFeatureName', { + defaultMessage: 'Short URLs', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'url_create', + name: i18n.translate( + 'xpack.features.ossFeatures.visualizeCreateShortUrlPrivilegeName', + { + defaultMessage: 'Create Short URLs', + } + ), + includeIn: 'all', + savedObject: { + all: ['url'], + read: [], + }, + ui: ['createShortUrl'], + }, + ], + }, + ], + }, + ], }, { id: 'dashboard', name: i18n.translate('xpack.features.dashboardFeatureName', { defaultMessage: 'Dashboard', }), + order: 300, icon: 'dashboardApp', navLinkId: 'kibana:dashboard', app: ['kibana'], catalogue: ['dashboard'], privileges: { all: { + app: ['kibana'], + catalogue: ['dashboard'], savedObject: { all: ['dashboard', 'url', 'query'], read: [ @@ -91,6 +162,8 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['createNew', 'show', 'showWriteControls', 'saveQuery'], }, read: { + app: ['kibana'], + catalogue: ['dashboard'], savedObject: { all: [], read: [ @@ -107,18 +180,50 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['show'], }, }, + subFeatures: [ + { + name: i18n.translate('xpack.features.ossFeatures.dashboardShortUrlSubFeatureName', { + defaultMessage: 'Short URLs', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'url_create', + name: i18n.translate( + 'xpack.features.ossFeatures.dashboardCreateShortUrlPrivilegeName', + { + defaultMessage: 'Create Short URLs', + } + ), + includeIn: 'all', + savedObject: { + all: ['url'], + read: [], + }, + ui: ['createShortUrl'], + }, + ], + }, + ], + }, + ], }, { id: 'dev_tools', name: i18n.translate('xpack.features.devToolsFeatureName', { defaultMessage: 'Dev Tools', }), + order: 1300, icon: 'devToolsApp', navLinkId: 'kibana:dev_tools', app: ['kibana'], catalogue: ['console', 'searchprofiler', 'grokdebugger'], privileges: { all: { + app: ['kibana'], + catalogue: ['console', 'searchprofiler', 'grokdebugger'], api: ['console'], savedObject: { all: [], @@ -127,6 +232,8 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['show', 'save'], }, read: { + app: ['kibana'], + catalogue: ['console', 'searchprofiler', 'grokdebugger'], api: ['console'], savedObject: { all: [], @@ -145,6 +252,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.advancedSettingsFeatureName', { defaultMessage: 'Advanced Settings', }), + order: 1500, icon: 'advancedSettingsApp', app: ['kibana'], catalogue: ['advanced_settings'], @@ -153,6 +261,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, privileges: { all: { + app: ['kibana'], + catalogue: ['advanced_settings'], + management: { + kibana: ['settings'], + }, savedObject: { all: ['config'], read: [], @@ -160,6 +273,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['save'], }, read: { + app: ['kibana'], + catalogue: ['advanced_settings'], + management: { + kibana: ['settings'], + }, savedObject: { all: [], read: [], @@ -173,6 +291,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.indexPatternFeatureName', { defaultMessage: 'Index Pattern Management', }), + order: 1600, icon: 'indexPatternApp', app: ['kibana'], catalogue: ['index_patterns'], @@ -181,6 +300,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, privileges: { all: { + app: ['kibana'], + catalogue: ['index_patterns'], + management: { + kibana: ['index_patterns'], + }, savedObject: { all: ['index-pattern'], read: [], @@ -188,6 +312,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['save'], }, read: { + app: ['kibana'], + catalogue: ['index_patterns'], + management: { + kibana: ['index_patterns'], + }, savedObject: { all: [], read: ['index-pattern'], @@ -201,6 +330,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.savedObjectsManagementFeatureName', { defaultMessage: 'Saved Objects Management', }), + order: 1700, icon: 'savedObjectsApp', app: ['kibana'], catalogue: ['saved_objects'], @@ -209,6 +339,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, privileges: { all: { + app: ['kibana'], + catalogue: ['saved_objects'], + management: { + kibana: ['objects'], + }, api: ['copySavedObjectsToSpaces'], savedObject: { all: [...savedObjectTypes], @@ -217,6 +352,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['read', 'edit', 'delete', 'copyIntoSpace'], }, read: { + app: ['kibana'], + catalogue: ['saved_objects'], + management: { + kibana: ['objects'], + }, api: ['copySavedObjectsToSpaces'], savedObject: { all: [], @@ -227,18 +367,21 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, }, ...(includeTimelion ? [timelionFeature] : []), - ]; + ] as FeatureConfig[]; }; -const timelionFeature: Feature = { +const timelionFeature: FeatureConfig = { id: 'timelion', name: 'Timelion', + order: 350, icon: 'timelionApp', navLinkId: 'timelion', app: ['timelion', 'kibana'], catalogue: ['timelion'], privileges: { all: { + app: ['timelion', 'kibana'], + catalogue: ['timelion'], savedObject: { all: ['timelion-sheet'], read: ['index-pattern'], @@ -246,6 +389,8 @@ const timelionFeature: Feature = { ui: ['save'], }, read: { + app: ['timelion', 'kibana'], + catalogue: ['timelion'], savedObject: { all: [], read: ['index-pattern', 'timelion-sheet'], diff --git a/x-pack/plugins/features/server/plugin.ts b/x-pack/plugins/features/server/plugin.ts index e77fa218c0681..cebf67243fb28 100644 --- a/x-pack/plugins/features/server/plugin.ts +++ b/x-pack/plugins/features/server/plugin.ts @@ -15,7 +15,7 @@ import { deepFreeze } from '../../../../src/core/utils'; import { XPackInfo } from '../../../legacy/plugins/xpack_main/server/lib/xpack_info'; import { PluginSetupContract as TimelionSetupContract } from '../../../../src/plugins/timelion/server'; import { FeatureRegistry } from './feature_registry'; -import { Feature, FeatureWithAllOrReadPrivileges } from '../common/feature'; +import { Feature, FeatureConfig } from '../common/feature'; import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features'; import { buildOSSFeatures } from './oss_features'; import { defineRoutes } from './routes'; @@ -24,7 +24,7 @@ import { defineRoutes } from './routes'; * Describes public Features plugin contract returned at the `setup` stage. */ export interface PluginSetupContract { - registerFeature(feature: FeatureWithAllOrReadPrivileges): void; + registerFeature(feature: FeatureConfig): void; getFeatures(): Feature[]; getFeaturesUICapabilities(): UICapabilities; registerLegacyAPI: (legacyAPI: LegacyAPI) => void; diff --git a/x-pack/plugins/features/server/routes/index.test.ts b/x-pack/plugins/features/server/routes/index.test.ts index b0f8417b7175d..c43e2a5195fe7 100644 --- a/x-pack/plugins/features/server/routes/index.test.ts +++ b/x-pack/plugins/features/server/routes/index.test.ts @@ -10,6 +10,7 @@ import { defineRoutes } from './index'; import { httpServerMock, httpServiceMock } from '../../../../../src/core/server/mocks'; import { XPackInfoLicense } from '../../../../legacy/plugins/xpack_main/server/lib/xpack_info_license'; import { RequestHandler } from '../../../../../src/core/server'; +import { FeatureConfig } from '../../common'; let currentLicenseLevel: string = 'gold'; @@ -21,7 +22,23 @@ describe('GET /api/features', () => { id: 'feature_1', name: 'Feature 1', app: [], - privileges: {}, + privileges: null, + }); + + featureRegistry.register({ + id: 'feature_2', + name: 'Feature 2', + order: 2, + app: [], + privileges: null, + }); + + featureRegistry.register({ + id: 'feature_3', + name: 'Feature 2', + order: 1, + app: [], + privileges: null, }); featureRegistry.register({ @@ -29,7 +46,7 @@ describe('GET /api/features', () => { name: 'Licensed Feature', app: ['bar-app'], validLicenses: ['gold'], - privileges: {}, + privileges: null, }); const routerMock = httpServiceMock.createRouter(); @@ -51,37 +68,33 @@ describe('GET /api/features', () => { routeHandler = routerMock.get.mock.calls[0][1]; }); - it('returns a list of available features', async () => { + it('returns a list of available features, sorted by their configured order', async () => { const mockResponse = httpServerMock.createResponseFactory(); routeHandler(undefined as any, { query: {} } as any, mockResponse); - expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "body": Array [ - Object { - "app": Array [], - "id": "feature_1", - "name": "Feature 1", - "privileges": Object {}, - }, - Object { - "app": Array [ - "bar-app", - ], - "id": "licensed_feature", - "name": "Licensed Feature", - "privileges": Object {}, - "validLicenses": Array [ - "gold", - ], - }, - ], - }, - ], - ] - `); + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body as FeatureConfig[]; + + const features = body.map(feature => ({ id: feature.id, order: feature.order })); + expect(features).toEqual([ + { + id: 'feature_3', + order: 1, + }, + { + id: 'feature_2', + order: 2, + }, + { + id: 'feature_1', + order: undefined, + }, + { + id: 'licensed_feature', + order: undefined, + }, + ]); }); it(`by default does not return features that arent allowed by current license`, async () => { @@ -90,22 +103,26 @@ describe('GET /api/features', () => { const mockResponse = httpServerMock.createResponseFactory(); routeHandler(undefined as any, { query: {} } as any, mockResponse); - expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "body": Array [ - Object { - "app": Array [], - "id": "feature_1", - "name": "Feature 1", - "privileges": Object {}, - }, - ], - }, - ], - ] - `); + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body as FeatureConfig[]; + + const features = body.map(feature => ({ id: feature.id, order: feature.order })); + + expect(features).toEqual([ + { + id: 'feature_3', + order: 1, + }, + { + id: 'feature_2', + order: 2, + }, + { + id: 'feature_1', + order: undefined, + }, + ]); }); it(`ignoreValidLicenses=false does not return features that arent allowed by current license`, async () => { @@ -114,22 +131,26 @@ describe('GET /api/features', () => { const mockResponse = httpServerMock.createResponseFactory(); routeHandler(undefined as any, { query: { ignoreValidLicenses: false } } as any, mockResponse); - expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "body": Array [ - Object { - "app": Array [], - "id": "feature_1", - "name": "Feature 1", - "privileges": Object {}, - }, - ], - }, - ], - ] - `); + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body as FeatureConfig[]; + + const features = body.map(feature => ({ id: feature.id, order: feature.order })); + + expect(features).toEqual([ + { + id: 'feature_3', + order: 1, + }, + { + id: 'feature_2', + order: 2, + }, + { + id: 'feature_1', + order: undefined, + }, + ]); }); it(`ignoreValidLicenses=true returns features that arent allowed by current license`, async () => { @@ -138,32 +159,29 @@ describe('GET /api/features', () => { const mockResponse = httpServerMock.createResponseFactory(); routeHandler(undefined as any, { query: { ignoreValidLicenses: true } } as any, mockResponse); - expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "body": Array [ - Object { - "app": Array [], - "id": "feature_1", - "name": "Feature 1", - "privileges": Object {}, - }, - Object { - "app": Array [ - "bar-app", - ], - "id": "licensed_feature", - "name": "Licensed Feature", - "privileges": Object {}, - "validLicenses": Array [ - "gold", - ], - }, - ], - }, - ], - ] - `); + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body as FeatureConfig[]; + + const features = body.map(feature => ({ id: feature.id, order: feature.order })); + + expect(features).toEqual([ + { + id: 'feature_3', + order: 1, + }, + { + id: 'feature_2', + order: 2, + }, + { + id: 'feature_1', + order: undefined, + }, + { + id: 'licensed_feature', + order: undefined, + }, + ]); }); }); diff --git a/x-pack/plugins/features/server/routes/index.ts b/x-pack/plugins/features/server/routes/index.ts index cf4d61ccac88b..428500c3daa88 100644 --- a/x-pack/plugins/features/server/routes/index.ts +++ b/x-pack/plugins/features/server/routes/index.ts @@ -31,13 +31,19 @@ export function defineRoutes({ router, featureRegistry, getLegacyAPI }: RouteDef const allFeatures = featureRegistry.getAll(); return response.ok({ - body: allFeatures.filter( - feature => - request.query.ignoreValidLicenses || - !feature.validLicenses || - !feature.validLicenses.length || - getLegacyAPI().xpackInfo.license.isOneOf(feature.validLicenses) - ), + body: allFeatures + .filter( + feature => + request.query.ignoreValidLicenses || + !feature.validLicenses || + !feature.validLicenses.length || + getLegacyAPI().xpackInfo.license.isOneOf(feature.validLicenses) + ) + .sort( + (f1, f2) => + (f1.order ?? Number.MAX_SAFE_INTEGER) - (f2.order ?? Number.MAX_SAFE_INTEGER) + ) + .map(feature => feature.toRaw()), }); } ); diff --git a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts index bb2cd82891a15..73c399878b17b 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts @@ -5,17 +5,31 @@ */ import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features'; +import { Feature } from '.'; +import { SubFeaturePrivilegeGroupConfig } from '../common'; -function createFeaturePrivilege(key: string, capabilities: string[] = []) { +function createFeaturePrivilege(capabilities: string[] = []) { return { - [key]: { - savedObject: { - all: [], - read: [], - }, - app: [], - ui: [...capabilities], + savedObject: { + all: [], + read: [], + }, + app: [], + ui: [...capabilities], + }; +} + +function createSubFeaturePrivilege(privilegeId: string, capabilities: string[] = []) { + return { + id: privilegeId, + name: `sub-feature privilege ${privilegeId}`, + includeIn: 'none', + savedObject: { + all: [], + read: [], }, + app: [], + ui: [...capabilities], }; } @@ -27,14 +41,15 @@ describe('populateUICapabilities', () => { it('handles features with no registered capabilities', () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ id: 'newFeature', name: 'my new feature', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('all'), + all: createFeaturePrivilege(), + read: createFeaturePrivilege(), }, - }, + }), ]) ).toEqual({ catalogue: {}, @@ -45,15 +60,16 @@ describe('populateUICapabilities', () => { it('augments the original uiCapabilities with registered feature capabilities', () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ id: 'newFeature', name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('all', ['capability1', 'capability2']), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(), }, - }, + }), ]) ).toEqual({ catalogue: {}, @@ -67,18 +83,17 @@ describe('populateUICapabilities', () => { it('combines catalogue entries from multiple features', () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ id: 'newFeature', name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], catalogue: ['anotherFooEntry', 'anotherBarEntry'], privileges: { - ...createFeaturePrivilege('foo', ['capability1', 'capability2']), - ...createFeaturePrivilege('bar', ['capability3', 'capability4']), - ...createFeaturePrivilege('baz'), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - }, + }), ]) ).toEqual({ catalogue: { @@ -97,17 +112,75 @@ describe('populateUICapabilities', () => { it(`merges capabilities from all feature privileges`, () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: { + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4', 'capability5']), + }, + }), + ]) + ).toEqual({ + catalogue: {}, + newFeature: { + capability1: true, + capability2: true, + capability3: true, + capability4: true, + capability5: true, + }, + }); + }); + + it(`supports merging features with sub privileges`, () => { + expect( + uiCapabilitiesForFeatures([ + new Feature({ id: 'newFeature', name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('foo', ['capability1', 'capability2']), - ...createFeaturePrivilege('bar', ['capability3', 'capability4']), - ...createFeaturePrivilege('baz', ['capability1', 'capability5']), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - }, + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + createSubFeaturePrivilege('privilege-1', ['capability5']), + createSubFeaturePrivilege('privilege-2', ['capability6']), + ], + } as SubFeaturePrivilegeGroupConfig, + { + groupType: 'mutually_exclusive', + privileges: [ + createSubFeaturePrivilege('privilege-3', ['capability7']), + createSubFeaturePrivilege('privilege-4', ['capability8']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + { + name: 'sub-feature-2', + privilegeGroups: [ + { + name: 'Group Name', + groupType: 'independent', + privileges: [ + createSubFeaturePrivilege('privilege-5', ['capability9', 'capability10']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + ], + }), ]) ).toEqual({ catalogue: {}, @@ -117,6 +190,11 @@ describe('populateUICapabilities', () => { capability3: true, capability4: true, capability5: true, + capability6: true, + capability7: true, + capability8: true, + capability9: true, + capability10: true, }, }); }); @@ -124,41 +202,49 @@ describe('populateUICapabilities', () => { it('supports merging multiple features with multiple privileges each', () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ id: 'newFeature', name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('foo', ['capability1', 'capability2']), - ...createFeaturePrivilege('bar', ['capability3', 'capability4']), - ...createFeaturePrivilege('baz', ['capability1', 'capability5']), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - }, - { + }), + new Feature({ id: 'anotherNewFeature', name: 'another new feature', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('foo', ['capability1', 'capability2']), - ...createFeaturePrivilege('bar', ['capability3', 'capability4']), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - }, - { + }), + new Feature({ id: 'yetAnotherNewFeature', name: 'yet another new feature', navLinkId: 'yetAnotherNavLink', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('all', ['capability1', 'capability2']), - ...createFeaturePrivilege('read', []), - ...createFeaturePrivilege('somethingInBetween', [ - 'something1', - 'something2', - 'something3', - ]), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['something1', 'something2', 'something3']), }, - }, + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + createSubFeaturePrivilege('privilege-1', ['capability3']), + createSubFeaturePrivilege('privilege-2', ['capability4']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + ], + }), ]) ).toEqual({ anotherNewFeature: { @@ -173,11 +259,12 @@ describe('populateUICapabilities', () => { capability2: true, capability3: true, capability4: true, - capability5: true, }, yetAnotherNewFeature: { capability1: true, capability2: true, + capability3: true, + capability4: true, something1: true, something2: true, something3: true, diff --git a/x-pack/plugins/features/server/ui_capabilities_for_features.ts b/x-pack/plugins/features/server/ui_capabilities_for_features.ts index a13afa854de52..d3d3230822749 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.ts @@ -39,7 +39,14 @@ function getCapabilitiesFromFeature(feature: Feature): FeatureCapabilities { }; } - Object.values(feature.privileges).forEach(privilege => { + const featurePrivileges = Object.values(feature.privileges ?? {}); + if (feature.subFeatures) { + featurePrivileges.push( + ...feature.subFeatures.map(sf => sf.privilegeGroups.map(pg => pg.privileges)).flat(2) + ); + } + + featurePrivileges.forEach(privilege => { UIFeatureCapabilities[feature.id] = { ...UIFeatureCapabilities[feature.id], ...privilege.ui.reduce( diff --git a/x-pack/plugins/infra/server/features.ts b/x-pack/plugins/infra/server/features.ts index edf94beab43a7..5301e1e9cbd0b 100644 --- a/x-pack/plugins/infra/server/features.ts +++ b/x-pack/plugins/infra/server/features.ts @@ -11,12 +11,15 @@ export const METRICS_FEATURE = { name: i18n.translate('xpack.infra.featureRegistry.linkInfrastructureTitle', { defaultMessage: 'Metrics', }), + order: 700, icon: 'metricsApp', navLinkId: 'metrics', app: ['infra', 'kibana'], catalogue: ['infraops'], privileges: { all: { + app: ['infra', 'kibana'], + catalogue: ['infraops'], api: ['infra'], savedObject: { all: ['infrastructure-ui-source'], @@ -25,6 +28,8 @@ export const METRICS_FEATURE = { ui: ['show', 'configureSource', 'save'], }, read: { + app: ['infra', 'kibana'], + catalogue: ['infraops'], api: ['infra'], savedObject: { all: [], @@ -40,12 +45,15 @@ export const LOGS_FEATURE = { name: i18n.translate('xpack.infra.featureRegistry.linkLogsTitle', { defaultMessage: 'Logs', }), + order: 800, icon: 'logsApp', navLinkId: 'logs', app: ['infra', 'kibana'], catalogue: ['infralogging'], privileges: { all: { + app: ['infra', 'kibana'], + catalogue: ['infralogging'], api: ['infra'], savedObject: { all: ['infrastructure-ui-source'], @@ -54,6 +62,8 @@ export const LOGS_FEATURE = { ui: ['show', 'configureSource', 'save'], }, read: { + app: ['infra', 'kibana'], + catalogue: ['infralogging'], api: ['infra'], savedObject: { all: [], diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 67737c6fe502e..45c847fe1f68a 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -88,6 +88,7 @@ export class IngestManagerPlugin implements Plugin { privileges: { all: { api: [`${PLUGIN_ID}-read`, `${PLUGIN_ID}-all`], + app: [PLUGIN_ID, 'kibana'], savedObject: { all: allSavedObjectTypes, read: [], @@ -96,6 +97,7 @@ export class IngestManagerPlugin implements Plugin { }, read: { api: [`${PLUGIN_ID}-read`], + app: [PLUGIN_ID, 'kibana'], savedObject: { all: [], read: allSavedObjectTypes, diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index dc42a1f7fcbbb..674c3886c12f8 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -70,12 +70,15 @@ export class MlServerPlugin implements Plugin { + it('should show login page and other security elements, allow RBAC but forbid role mappings, DLS, and sub-feature privileges if license is basic.', () => { const mockRawLicense = licensingMock.createLicense({ features: { security: { isEnabled: true, isAvailable: true } }, }); @@ -108,6 +112,7 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: true, + allowSubFeaturePrivileges: false, }); expect(getFeatureSpy).toHaveBeenCalledTimes(1); expect(getFeatureSpy).toHaveBeenCalledWith('security'); @@ -129,10 +134,11 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, + allowSubFeaturePrivileges: false, }); }); - it('should allow role mappings, but not DLS/FLS if license = gold', () => { + it('should allow role mappings and sub-feature privileges, but not DLS/FLS if license = gold', () => { const mockRawLicense = licensingMock.createLicense({ license: { mode: 'gold', type: 'gold' }, features: { security: { isEnabled: true, isAvailable: true } }, @@ -149,10 +155,11 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: true, + allowSubFeaturePrivileges: true, }); }); - it('should allow to login, allow RBAC, allow role mappings, and document level security if license >= platinum', () => { + it('should allow to login, allow RBAC, role mappings, sub-feature privileges, and DLS if license >= platinum', () => { const mockRawLicense = licensingMock.createLicense({ license: { mode: 'platinum', type: 'platinum' }, features: { security: { isEnabled: true, isAvailable: true } }, @@ -169,6 +176,7 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: true, allowRoleFieldLevelSecurity: true, allowRbac: true, + allowSubFeaturePrivileges: true, }); }); }); diff --git a/x-pack/plugins/security/common/licensing/license_service.ts b/x-pack/plugins/security/common/licensing/license_service.ts index 2c2039c5e2e92..34bc44b88e40d 100644 --- a/x-pack/plugins/security/common/licensing/license_service.ts +++ b/x-pack/plugins/security/common/licensing/license_service.ts @@ -74,6 +74,7 @@ export class SecurityLicenseService { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, + allowSubFeaturePrivileges: false, layout: rawLicense !== undefined && !rawLicense?.isAvailable ? 'error-xpack-unavailable' @@ -90,16 +91,18 @@ export class SecurityLicenseService { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, + allowSubFeaturePrivileges: false, }; } - const showRoleMappingsManagement = rawLicense.hasAtLeast('gold'); + const isLicenseGoldOrBetter = rawLicense.hasAtLeast('gold'); const isLicensePlatinumOrBetter = rawLicense.hasAtLeast('platinum'); return { showLogin: true, allowLogin: true, showLinks: true, - showRoleMappingsManagement, + showRoleMappingsManagement: isLicenseGoldOrBetter, + allowSubFeaturePrivileges: isLicenseGoldOrBetter, // Only platinum and trial licenses are compliant with field- and document-level security. allowRoleDocumentLevelSecurity: isLicensePlatinumOrBetter, allowRoleFieldLevelSecurity: isLicensePlatinumOrBetter, diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 88da416cf715b..59d4908c67ffb 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -8,8 +8,8 @@ export { ApiKey, ApiKeyToInvalidate } from './api_key'; export { User, EditUser, getUserDisplayName } from './user'; export { AuthenticatedUser, canUserChangePassword } from './authenticated_user'; export { BuiltinESPrivileges } from './builtin_es_privileges'; -export { FeaturesPrivileges } from './features_privileges'; export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; +export { FeaturesPrivileges } from './features_privileges'; export { Role, RoleIndexPrivilege, @@ -22,7 +22,6 @@ export { prepareRoleClone, getExtendedRoleDeprecationNotice, } from './role'; -export { KibanaPrivileges } from './kibana_privileges'; export { InlineRoleTemplate, StoredRoleTemplate, diff --git a/x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts deleted file mode 100644 index fd4cdf33028eb..0000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts +++ /dev/null @@ -1,36 +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 { FeaturesPrivileges } from '../features_privileges'; -import { RawKibanaFeaturePrivileges } from '../raw_kibana_privileges'; - -export class KibanaFeaturePrivileges { - constructor(private readonly featurePrivilegesMap: RawKibanaFeaturePrivileges) {} - - public getAllPrivileges(): FeaturesPrivileges { - return Object.entries(this.featurePrivilegesMap).reduce((acc, [featureId, privileges]) => { - return { - ...acc, - [featureId]: Object.keys(privileges), - }; - }, {}); - } - - public getPrivileges(featureId: string): string[] { - const featurePrivileges = this.featurePrivilegesMap[featureId]; - if (featurePrivileges == null) { - return []; - } - - return Object.keys(featurePrivileges); - } - - public getActions(featureId: string, privilege: string): string[] { - if (!this.featurePrivilegesMap[featureId]) { - return []; - } - return this.featurePrivilegesMap[featureId][privilege] || []; - } -} diff --git a/x-pack/plugins/security/common/model/kibana_privileges/global_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/global_privileges.ts deleted file mode 100644 index ffe55b813217f..0000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/global_privileges.ts +++ /dev/null @@ -1,17 +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. - */ - -export class KibanaGlobalPrivileges { - constructor(private readonly globalPrivilegesMap: Record) {} - - public getAllPrivileges(): string[] { - return Object.keys(this.globalPrivilegesMap); - } - - public getActions(privilege: string): string[] { - return this.globalPrivilegesMap[privilege] || []; - } -} diff --git a/x-pack/plugins/security/common/model/kibana_privileges/kibana_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/kibana_privileges.ts deleted file mode 100644 index 61e5f083a7798..0000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/kibana_privileges.ts +++ /dev/null @@ -1,26 +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 { RawKibanaPrivileges } from '../raw_kibana_privileges'; -import { KibanaFeaturePrivileges } from './feature_privileges'; -import { KibanaGlobalPrivileges } from './global_privileges'; -import { KibanaSpacesPrivileges } from './spaces_privileges'; - -export class KibanaPrivileges { - constructor(private readonly rawKibanaPrivileges: RawKibanaPrivileges) {} - - public getGlobalPrivileges() { - return new KibanaGlobalPrivileges(this.rawKibanaPrivileges.global); - } - - public getSpacesPrivileges() { - return new KibanaSpacesPrivileges(this.rawKibanaPrivileges.space); - } - - public getFeaturePrivileges() { - return new KibanaFeaturePrivileges(this.rawKibanaPrivileges.features); - } -} diff --git a/x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts deleted file mode 100644 index 5c8b4196a2b55..0000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts +++ /dev/null @@ -1,17 +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. - */ - -export class KibanaSpacesPrivileges { - constructor(private readonly spacesPrivilegesMap: Record) {} - - public getAllPrivileges(): string[] { - return Object.keys(this.spacesPrivilegesMap); - } - - public getActions(privilege: string): string[] { - return this.spacesPrivilegesMap[privilege] || []; - } -} diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts new file mode 100644 index 0000000000000..68d352363d363 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts @@ -0,0 +1,208 @@ +/* + * 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 { Feature, FeatureConfig } from '../../../../../features/public'; + +export const createFeature = ( + config: Pick & { + excludeFromBaseAll?: boolean; + excludeFromBaseRead?: boolean; + } +) => { + const { excludeFromBaseAll, excludeFromBaseRead, ...rest } = config; + return new Feature({ + icon: 'discoverApp', + navLinkId: 'kibana:discover', + app: [], + catalogue: [], + privileges: { + all: { + excludeFromBasePrivileges: excludeFromBaseAll, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['read-ui', 'all-ui', `read-${config.id}`, `all-${config.id}`], + }, + read: { + excludeFromBasePrivileges: excludeFromBaseRead, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['read-ui', `read-${config.id}`], + }, + }, + ...rest, + }); +}; + +export const kibanaFeatures = [ + createFeature({ + id: 'no_sub_features', + name: 'Feature 1: No Sub Features', + }), + createFeature({ + id: 'with_sub_features', + name: 'Mutually Exclusive Sub Features', + subFeatures: [ + { + name: 'Cool Sub Feature', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'cool_all', + name: 'All', + includeIn: 'all', + savedObject: { + all: ['all-cool-type'], + read: ['read-cool-type'], + }, + ui: ['cool_read-ui', 'cool_all-ui'], + }, + { + id: 'cool_read', + name: 'Read', + includeIn: 'read', + savedObject: { + all: [], + read: ['read-cool-type'], + }, + ui: ['cool_read-ui'], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'cool_toggle_1', + name: 'Cool toggle 1', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_1-ui'], + }, + { + id: 'cool_toggle_2', + name: 'Cool toggle 2', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_2-ui'], + }, + { + id: 'cool_excluded_toggle', + name: 'Cool excluded toggle', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_excluded_toggle-ui'], + }, + ], + }, + ], + }, + ], + }), + createFeature({ + id: 'with_excluded_sub_features', + name: 'Excluded Sub Features', + subFeatures: [ + { + name: 'Excluded Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool_toggle_1', + name: 'Cool toggle 1', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_1-ui'], + }, + ], + }, + ], + }, + ], + }), + createFeature({ + id: 'excluded_from_base', + name: 'Excluded from base', + excludeFromBaseAll: true, + excludeFromBaseRead: true, + subFeatures: [ + { + name: 'Cool Sub Feature', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'cool_all', + name: 'All', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_read-ui', 'cool_all-ui'], + }, + { + id: 'cool_read', + name: 'Read', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_read-ui'], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'cool_toggle_1', + name: 'Cool toggle 2', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_1-ui'], + }, + { + id: 'cool_toggle_2', + name: 'Cool toggle 2', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_2-ui'], + }, + ], + }, + ], + }, + ], + }), +]; diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts new file mode 100644 index 0000000000000..98110a83103aa --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Actions } from '../../../../server/authorization'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { privilegesFactory } from '../../../../server/authorization/privileges'; +import { Feature } from '../../../../../features/public'; +import { KibanaPrivileges } from '../model'; +import { SecurityLicenseFeatures } from '../../..'; + +export const createRawKibanaPrivileges = ( + features: Feature[], + { allowSubFeaturePrivileges = true } = {} +) => { + const featuresService = { + getFeatures: () => features, + }; + + const licensingService = { + getFeatures: () => ({ allowSubFeaturePrivileges } as SecurityLicenseFeatures), + }; + + return privilegesFactory( + new Actions('unit_test_version'), + featuresService, + licensingService + ).get(); +}; + +export const createKibanaPrivileges = ( + features: Feature[], + { allowSubFeaturePrivileges = true } = {} +) => { + return new KibanaPrivileges( + createRawKibanaPrivileges(features, { allowSubFeaturePrivileges }), + features + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx index 23a3f327a2c5c..f1ee681331005 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx @@ -10,16 +10,10 @@ import { act } from '@testing-library/react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { Capabilities } from 'src/core/public'; import { Feature } from '../../../../../features/public'; -// These modules should be moved into a common directory -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Actions } from '../../../../server/authorization/actions'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { privilegesFactory } from '../../../../server/authorization/privileges'; import { Role } from '../../../../common/model'; import { DocumentationLinksService } from '../documentation_links'; import { EditRolePage } from './edit_role_page'; import { SimplePrivilegeSection } from './privileges/kibana/simple_privilege_section'; -import { SpaceAwarePrivilegeSection } from './privileges/kibana/space_aware_privilege_section'; import { TransformErrorSection } from './privileges/kibana/transform_error_section'; import { coreMock } from '../../../../../../../src/core/public/mocks'; @@ -28,10 +22,12 @@ import { licenseMock } from '../../../../common/licensing/index.mock'; import { userAPIClientMock } from '../../users/index.mock'; import { rolesAPIClientMock, indicesAPIClientMock, privilegesAPIClientMock } from '../index.mock'; import { Space } from '../../../../../spaces/public'; +import { SpaceAwarePrivilegeSection } from './privileges/kibana/space_aware_privilege_section'; +import { createRawKibanaPrivileges } from '../__fixtures__/kibana_privileges'; const buildFeatures = () => { return [ - { + new Feature({ id: 'feature1', name: 'Feature 1', icon: 'addDataApp', @@ -45,9 +41,17 @@ const buildFeatures = () => { read: [], }, }, + read: { + app: ['feature1App'], + ui: ['feature1-ui'], + savedObject: { + all: [], + read: [], + }, + }, }, - }, - { + }), + new Feature({ id: 'feature2', name: 'Feature 2', icon: 'addDataApp', @@ -61,17 +65,19 @@ const buildFeatures = () => { read: ['config'], }, }, + read: { + app: ['feature2App'], + ui: ['feature2-ui'], + savedObject: { + all: [], + read: ['config'], + }, + }, }, - }, + }), ] as Feature[]; }; -const buildRawKibanaPrivileges = () => { - return privilegesFactory(new Actions('unit_test_version'), { - getFeatures: () => buildFeatures(), - }).get(); -}; - const buildBuiltinESPrivileges = () => { return { cluster: ['all', 'manage', 'monitor'], @@ -144,7 +150,7 @@ function getProps({ userAPIClient.getUsers.mockResolvedValue([]); const privilegesAPIClient = privilegesAPIClientMock.create(); - privilegesAPIClient.getAll.mockResolvedValue(buildRawKibanaPrivileges()); + privilegesAPIClient.getAll.mockResolvedValue(createRawKibanaPrivileges(buildFeatures())); privilegesAPIClient.getBuiltIn.mockResolvedValue(buildBuiltinESPrivileges()); const license = licenseMock.create(); @@ -156,10 +162,6 @@ function getProps({ const { fatalErrors } = coreMock.createSetup(); const { http, docLinks, notifications } = coreMock.createStart(); http.get.mockImplementation(async (path: any) => { - if (path === '/api/features') { - return buildFeatures(); - } - if (path === '/api/spaces/space') { return buildSpaces(); } @@ -175,6 +177,7 @@ function getProps({ privilegesAPIClient, rolesAPIClient, userAPIClient, + getFeatures: () => Promise.resolve(buildFeatures()), notifications, docLinks: new DocumentationLinksService(docLinks), fatalErrors, @@ -200,10 +203,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(1); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); @@ -226,10 +226,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); @@ -240,10 +237,7 @@ describe('', () => { it('can render when creating a new role', async () => { const wrapper = mountWithIntl(); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); @@ -275,10 +269,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); @@ -301,10 +292,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0); @@ -333,10 +321,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(TransformErrorSection)).toHaveLength(1); expectReadOnlyFormButtons(wrapper); @@ -360,10 +345,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(1); expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1); @@ -387,10 +369,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0); expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1); @@ -403,10 +382,7 @@ describe('', () => { ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1); expectSaveFormButtons(wrapper); @@ -438,10 +414,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1); expectSaveFormButtons(wrapper); @@ -464,10 +437,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0); @@ -497,10 +467,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(TransformErrorSection)).toHaveLength(1); expectReadOnlyFormButtons(wrapper); @@ -522,10 +489,7 @@ describe('', () => { const wrapper = mountWithIntl(); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); @@ -540,13 +504,17 @@ describe('', () => { ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); expectSaveFormButtons(wrapper); }); }); + +async function waitForRender(wrapper: ReactWrapper) { + await act(async () => { + await nextTick(); + wrapper.update(); + }); +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx index cd7766ef38748..f0d5abf89dd2e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx @@ -37,11 +37,11 @@ import { IHttpFetchError, NotificationsStart, } from 'src/core/public'; +import { FeaturesPluginStart } from '../../../../../features/public'; +import { Feature } from '../../../../../features/common'; import { IndexPatternsContract } from '../../../../../../../src/plugins/data/public'; import { Space } from '../../../../../spaces/public'; -import { Feature } from '../../../../../features/public'; import { - KibanaPrivileges, RawKibanaPrivileges, Role, BuiltinESPrivileges, @@ -64,6 +64,7 @@ import { DocumentationLinksService } from '../documentation_links'; import { IndicesAPIClient } from '../indices_api_client'; import { RolesAPIClient } from '../roles_api_client'; import { PrivilegesAPIClient } from '../privileges_api_client'; +import { KibanaPrivileges } from '../model'; interface Props { action: 'edit' | 'clone'; @@ -73,6 +74,7 @@ interface Props { indicesAPIClient: PublicMethodsOf; rolesAPIClient: PublicMethodsOf; privilegesAPIClient: PublicMethodsOf; + getFeatures: FeaturesPluginStart['getFeatures']; docLinks: DocumentationLinksService; http: HttpStart; license: SecurityLicense; @@ -231,11 +233,13 @@ function useSpaces(http: HttpStart, fatalErrors: FatalErrorsSetup, spacesEnabled return spaces; } -function useFeatures(http: HttpStart, fatalErrors: FatalErrorsSetup) { +function useFeatures( + getFeatures: FeaturesPluginStart['getFeatures'], + fatalErrors: FatalErrorsSetup +) { const [features, setFeatures] = useState(null); useEffect(() => { - http - .get('/api/features') + getFeatures() .catch((err: IHttpFetchError) => { // Currently, the `/api/features` endpoint effectively requires the "Global All" kibana privilege (e.g., what // the `kibana_user` grants), because it returns information about all registered features (#35841). It's @@ -246,14 +250,15 @@ function useFeatures(http: HttpStart, fatalErrors: FatalErrorsSetup) { // 404 here, and respond in a way that still allows the UI to render itself. const unauthorizedForFeatures = err.response?.status === 404; if (unauthorizedForFeatures) { - return []; + return [] as Feature[]; } fatalErrors.add(err); - throw err; }) - .then(setFeatures); - }, [http, fatalErrors]); + .then(retrievedFeatures => { + setFeatures(retrievedFeatures); + }); + }, [fatalErrors, getFeatures]); return features; } @@ -268,6 +273,7 @@ export const EditRolePage: FunctionComponent = ({ rolesAPIClient, indicesAPIClient, privilegesAPIClient, + getFeatures, http, roleName, action, @@ -287,7 +293,7 @@ export const EditRolePage: FunctionComponent = ({ const indexPatternsTitles = useIndexPatternsTitles(indexPatterns, fatalErrors, notifications); const privileges = usePrivileges(privilegesAPIClient, fatalErrors); const spaces = useSpaces(http, fatalErrors, spacesEnabled); - const features = useFeatures(http, fatalErrors); + const features = useFeatures(getFeatures, fatalErrors); const [role, setRole] = useRole( rolesAPIClient, fatalErrors, @@ -425,11 +431,11 @@ export const EditRolePage: FunctionComponent = ({
{ it('returns true if no spaces are defined', () => { @@ -47,39 +47,3 @@ describe('isGlobalPrivilegeDefinition', () => { ).toEqual(false); }); }); - -describe('hasAssignedFeaturePrivileges', () => { - it('returns false if no feature privileges are defined', () => { - expect( - hasAssignedFeaturePrivileges({ - spaces: [], - base: [], - feature: {}, - }) - ).toEqual(false); - }); - - it('returns false if feature privileges are defined but not assigned', () => { - expect( - hasAssignedFeaturePrivileges({ - spaces: [], - base: [], - feature: { - foo: [], - }, - }) - ).toEqual(false); - }); - - it('returns true if feature privileges are defined and assigned', () => { - expect( - hasAssignedFeaturePrivileges({ - spaces: [], - base: [], - feature: { - foo: ['all'], - }, - }) - ).toEqual(true); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts b/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts index 3fd8536951967..1fad9057665da 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts @@ -16,12 +16,3 @@ export function isGlobalPrivilegeDefinition(privilegeSpec: RoleKibanaPrivilege): } return privilegeSpec.spaces.includes('*'); } - -/** - * Determines if the passed privilege spec defines feature privileges. - * @param privilegeSpec - */ -export function hasAssignedFeaturePrivileges(privilegeSpec: RoleKibanaPrivilege): boolean { - const featureKeys = Object.keys(privilegeSpec.feature); - return featureKeys.length > 0 && featureKeys.some(key => privilegeSpec.feature[key].length > 0); -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/__snapshots__/kibana_privileges_region.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/__snapshots__/kibana_privileges_region.test.tsx.snap index 617335dc9fb34..a911455f95b5d 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/__snapshots__/kibana_privileges_region.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/__snapshots__/kibana_privileges_region.test.tsx.snap @@ -5,33 +5,17 @@ exports[` renders without crashing 1`] = ` iconType="logoKibana" title="Kibana" > - ) { + const allExpanderButtons = findTestSubject(wrapper, 'expandFeaturePrivilegeRow'); + allExpanderButtons.forEach(button => button.simulate('click')); + + // each expanded row renders its own `EuiTableRow`, so there are 2 rows + // for each feature: one for the primary feature privilege, and one for the sub privilege form + const rows = wrapper.find(EuiTableRow); + + return rows.reduce((acc, row) => { + const subFeaturePrivileges = []; + const subFeatureForm = row.find(SubFeatureForm); + if (subFeatureForm.length > 0) { + const { featureId } = subFeatureForm.props(); + const independentPrivileges = (subFeatureForm.find(EuiCheckbox) as ReactWrapper< + EuiCheckboxProps + >).reduce((acc2, checkbox) => { + const { id: privilegeId, checked } = checkbox.props(); + return checked ? [...acc2, privilegeId] : acc2; + }, [] as string[]); + + const mutuallyExclusivePrivileges = (subFeatureForm.find(EuiButtonGroup) as ReactWrapper< + EuiButtonGroupProps + >).reduce((acc2, subPrivButtonGroup) => { + const { idSelected: selectedSubPrivilege } = subPrivButtonGroup.props(); + return selectedSubPrivilege && selectedSubPrivilege !== 'none' + ? [...acc2, selectedSubPrivilege] + : acc2; + }, [] as string[]); + + subFeaturePrivileges.push(...independentPrivileges, ...mutuallyExclusivePrivileges); + + return { + ...acc, + [featureId]: { + ...acc[featureId], + subFeaturePrivileges, + }, + }; + } else { + const buttonGroup = row.find(EuiButtonGroup); + const { name, idSelected } = buttonGroup.props(); + expect(name).toBeDefined(); + expect(idSelected).toBeDefined(); + + const featureId = name!.substr(`featurePrivilege_`.length); + const primaryFeaturePrivilege = idSelected!.substr(`${featureId}_`.length); + + return { + ...acc, + [featureId]: { + ...acc[featureId], + primaryFeaturePrivilege, + }, + }; + } + }, {} as Record); +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__snapshots__/feature_table.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__snapshots__/feature_table.test.tsx.snap deleted file mode 100644 index 799ff205e2540..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__snapshots__/feature_table.test.tsx.snap +++ /dev/null @@ -1,39 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FeatureTable can render without spaces 1`] = ` - - - - , - "render": [Function], - }, - ] - } - items={Array []} - responsive={false} - tableLayout="fixed" -/> -`; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx index c480f33b57899..2083778e53998 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx @@ -7,9 +7,11 @@ import { EuiContextMenuItem, EuiContextMenuPanel, EuiLink, EuiPopover } from '@e import { FormattedMessage } from '@kbn/i18n/react'; import _ from 'lodash'; import React, { Component } from 'react'; +import { KibanaPrivilege } from '../../../../model'; +import { NO_PRIVILEGE_VALUE } from '../constants'; interface Props { onChange: (privilege: string) => void; - privileges: string[]; + privileges: KibanaPrivilege[]; disabled?: boolean; } @@ -24,7 +26,11 @@ export class ChangeAllPrivilegesControl extends Component { public render() { const button = ( - + { const items = this.props.privileges.map(privilege => { return ( { - this.onSelectPrivilege(privilege); + this.onSelectPrivilege(privilege.id); }} disabled={this.props.disabled} > - {_.capitalize(privilege)} + {_.capitalize(privilege.id)} ); }); + items.push( + { + this.onSelectPrivilege(NO_PRIVILEGE_VALUE); + }} + disabled={this.props.disabled} + > + {_.capitalize(NO_PRIVILEGE_VALUE)} + + ); + return ( { + return { + name: 'my_role', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, }; - spacesPrivileges?: Array<{ - spaces: string[]; - base: string[]; - feature: FeaturesPrivileges; - }>; +}; + +interface TestConfig { + features: Feature[]; + role: Role; + privilegeIndex: number; + calculateDisplayedPrivileges: boolean; + canCustomizeSubFeaturePrivileges: boolean; } -const buildRole = (options: BuildRoleOpts = {}) => { - const role: Role = { - name: 'unit test role', - elasticsearch: { - indices: [], - cluster: [], - run_as: [], - }, - kibana: [], +const setup = (config: TestConfig) => { + const kibanaPrivileges = createKibanaPrivileges(config.features, { + allowSubFeaturePrivileges: config.canCustomizeSubFeaturePrivileges, + }); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, config.role); + const onChange = jest.fn(); + const onChangeAll = jest.fn(); + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = config.calculateDisplayedPrivileges + ? getDisplayedFeaturePrivileges(wrapper) + : undefined; + + return { + wrapper, + onChange, + onChangeAll, + displayedPrivileges, }; +}; + +describe('FeatureTable', () => { + [true, false].forEach(canCustomizeSubFeaturePrivileges => { + describe(`with sub feature privileges ${ + canCustomizeSubFeaturePrivileges ? 'allowed' : 'disallowed' + }`, () => { + it('renders with no granted privileges for an empty role', () => { + const role = createRole([ + { + spaces: [], + base: [], + feature: {}, + }, + ]); + + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges, + }); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + with_sub_features: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + }); + }); + + it('renders with all included privileges granted at the space when space base privilege is "all"', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: ['all'], + feature: {}, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges, + }); + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + no_sub_features: { + primaryFeaturePrivilege: 'all', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'all', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + with_sub_features: { + primaryFeaturePrivilege: 'all', + ...(canCustomizeSubFeaturePrivileges + ? { + subFeaturePrivileges: [ + 'with_sub_features_cool_toggle_1', + 'with_sub_features_cool_toggle_2', + 'cool_all', + ], + } + : {}), + }, + }); + }); + + it('renders the most permissive primary feature privilege when multiple are assigned', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['read', 'minimal_all', 'all', 'minimal_read'], + }, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges, + }); - if (options.globalPrivilege) { - role.kibana.push({ - spaces: ['*'], - ...options.globalPrivilege, + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + with_sub_features: { + primaryFeaturePrivilege: 'all', + ...(canCustomizeSubFeaturePrivileges + ? { + subFeaturePrivileges: [ + 'with_sub_features_cool_toggle_1', + 'with_sub_features_cool_toggle_2', + 'cool_all', + ], + } + : {}), + }, + }); + }); + + it('allows all feature privileges to be toggled via "change all"', () => { + const role = createRole([ + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ]); + const { wrapper, onChangeAll } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: false, + canCustomizeSubFeaturePrivileges, + }); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); + + expect(onChangeAll).toHaveBeenCalledWith(['read']); + }); + + it('allows all feature privileges to be unassigned via "change all"', () => { + const role = createRole([ + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['all'], + no_sub_features: ['read'], + with_excluded_sub_features: ['all', 'something else'], + }, + }, + ]); + const { wrapper, onChangeAll } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: false, + canCustomizeSubFeaturePrivileges, + }); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-none').simulate('click'); + + expect(onChangeAll).toHaveBeenCalledWith([]); + }); + }); + }); + + it('renders the most permissive sub-feature privilege when multiple are assigned in a mutually-exclusive group', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all', 'cool_read'], + }, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, }); - } - if (options.spacesPrivileges) { - role.kibana.push(...options.spacesPrivileges); - } + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + subFeaturePrivileges: [], + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + subFeaturePrivileges: [], + }, + with_sub_features: { + primaryFeaturePrivilege: 'read', + subFeaturePrivileges: ['cool_all'], + }, + }); + }); - return role; -}; + it('renders a row expander only for features with sub-features', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ]); + const { wrapper } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: false, + canCustomizeSubFeaturePrivileges: true, + }); -const buildFeatures = () => { - return []; -}; + kibanaFeatures.forEach(feature => { + const rowExpander = findTestSubject(wrapper, `expandFeaturePrivilegeRow-${feature.id}`); + if (!feature.subFeatures || feature.subFeatures.length === 0) { + expect(rowExpander).toHaveLength(0); + } else { + expect(rowExpander).toHaveLength(1); + } + }); + }); -describe('FeatureTable', () => { - it('can render without spaces', () => { - const role = buildRole({ - spacesPrivileges: [ + it('renders the when the row is expanded', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ]); + const { wrapper } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: false, + canCustomizeSubFeaturePrivileges: true, + }); + + expect(wrapper.find(FeatureTableExpandedRow)).toHaveLength(0); + + findTestSubject(wrapper, 'expandFeaturePrivilegeRow') + .first() + .simulate('click'); + + expect(wrapper.find(FeatureTableExpandedRow)).toHaveLength(1); + }); + + it('renders with sub-feature privileges granted when primary feature privilege is "all"', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: { + unit_test: ['all'], + }, + }, + ]); + const feature = createFeature({ + id: 'unit_test', + name: 'Unit Test Feature', + subFeatures: [ { - spaces: ['marketing', 'default'], - base: ['read'], - feature: { - feature1: ['all'], - }, + name: 'Some Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-toggle-1', + name: 'Sub Toggle 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-2'], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-option-1', + name: 'Sub Option 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-option-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-option-2'], + }, + ], + }, + ], + }, + ] as SubFeatureConfig[], + }); + + const { displayedPrivileges } = setup({ + role, + features: [feature], + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + unit_test: { + primaryFeaturePrivilege: 'all', + subFeaturePrivileges: ['unit_test_sub-toggle-1', 'unit_test_sub-toggle-2', 'sub-option-1'], + }, + }); + }); + + it('renders with some sub-feature privileges granted when primary feature privilege is "read"', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: { + unit_test: ['read'], + }, + }, + ]); + const feature = createFeature({ + id: 'unit_test', + name: 'Unit Test Feature', + subFeatures: [ + { + name: 'Some Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-toggle-1', + name: 'Sub Toggle 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-2'], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-option-1', + name: 'Sub Option 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-option-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-option-2'], + }, + ], + }, + ], + }, + ] as SubFeatureConfig[], + }); + + const { displayedPrivileges } = setup({ + role, + features: [feature], + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + unit_test: { + primaryFeaturePrivilege: 'read', + subFeaturePrivileges: ['unit_test_sub-toggle-2', 'sub-toggle-2'], + }, + }); + }); + + it('renders with excluded sub-feature privileges not granted when primary feature privilege is "all"', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: { + unit_test: ['all'], + }, + }, + ]); + const feature = createFeature({ + id: 'unit_test', + name: 'Unit Test Feature', + subFeatures: [ + { + name: 'Some Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-toggle-1', + name: 'Sub Toggle 1', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-2'], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-option-1', + name: 'Sub Option 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-option-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-option-2'], + }, + ], + }, + ], + }, + ] as SubFeatureConfig[], + }); + + const { displayedPrivileges } = setup({ + role, + features: [feature], + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + unit_test: { + primaryFeaturePrivilege: 'all', + subFeaturePrivileges: ['unit_test_sub-toggle-2', 'sub-option-1'], + }, + }); + }); + + it('renders with excluded sub-feature privileges granted when explicitly assigned', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: { + unit_test: ['all', 'sub-toggle-1'], + }, + }, + ]); + const feature = createFeature({ + id: 'unit_test', + name: 'Unit Test Feature', + subFeatures: [ + { + name: 'Some Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-toggle-1', + name: 'Sub Toggle 1', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-2'], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-option-1', + name: 'Sub Option 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-option-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-option-2'], + }, + ], + }, + ], + }, + ] as SubFeatureConfig[], + }); + + const { displayedPrivileges } = setup({ + role, + features: [feature], + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + unit_test: { + primaryFeaturePrivilege: 'all', + subFeaturePrivileges: ['unit_test_sub-toggle-1', 'unit_test_sub-toggle-2', 'sub-option-1'], + }, + }); + }); + + it('renders with all included sub-feature privileges granted at the space when primary feature privileges are granted', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['all'], }, - ], - }); - - const calculator = new KibanaPrivilegeCalculatorFactory(defaultPrivilegeDefinition).getInstance( - role - ); - - const wrapper = shallowWithIntl( - - ); - - expect(wrapper).toMatchSnapshot(); + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + subFeaturePrivileges: [], + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + subFeaturePrivileges: [], + }, + with_sub_features: { + primaryFeaturePrivilege: 'all', + subFeaturePrivileges: [ + 'with_sub_features_cool_toggle_1', + 'with_sub_features_cool_toggle_2', + 'cool_all', + ], + }, + }); }); - it('can render for a specific spaces entry', () => { - const role = buildRole(); - const calculator = new KibanaPrivilegeCalculatorFactory(defaultPrivilegeDefinition).getInstance( - role - ); - const wrapper = mountWithIntl( - - ); - - expect(wrapper.find(EuiInMemoryTable)).toHaveLength(1); + it('renders with no privileges granted when minimal feature privileges are assigned, and sub-feature privileges are disallowed', () => { + const role = createRole([ + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['minimal_all'], + }, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: false, + }); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_sub_features: { + primaryFeaturePrivilege: 'none', + }, + }); + }); + + it('renders with no privileges granted when sub feature privileges are assigned, and sub-feature privileges are disallowed', () => { + const role = createRole([ + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all'], + }, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: false, + }); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_sub_features: { + primaryFeaturePrivilege: 'none', + }, + }); }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx index 8283efe23260a..4610da95e9649 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx @@ -4,103 +4,112 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; -import React, { Component } from 'react'; import { EuiButtonGroup, - EuiIcon, EuiIconTip, EuiInMemoryTable, EuiText, - IconType, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Feature } from '../../../../../../../../features/public'; -import { FeaturesPrivileges, KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { - AllowedPrivilege, - CalculatedPrivilege, - PrivilegeExplanation, -} from '../kibana_privilege_calculator'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { PrivilegeDisplay } from '../space_aware_privilege_section/privilege_display'; +import { i18n } from '@kbn/i18n'; +import _ from 'lodash'; +import React, { Component } from 'react'; +import { Role } from '../../../../../../../common/model'; import { ChangeAllPrivilegesControl } from './change_all_privileges'; +import { FeatureTableExpandedRow } from './feature_table_expanded_row'; +import { NO_PRIVILEGE_VALUE } from '../constants'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { FeatureTableCell } from '../feature_table_cell'; +import { KibanaPrivileges, SecuredFeature, KibanaPrivilege } from '../../../../model'; interface Props { role: Role; - features: Feature[]; - calculatedPrivileges: CalculatedPrivilege; - allowedPrivileges: AllowedPrivilege; - rankedFeaturePrivileges: FeaturesPrivileges; + privilegeCalculator: PrivilegeFormCalculator; kibanaPrivileges: KibanaPrivileges; - spacesIndex: number; + privilegeIndex: number; onChange: (featureId: string, privileges: string[]) => void; onChangeAll: (privileges: string[]) => void; + canCustomizeSubFeaturePrivileges: boolean; disabled?: boolean; } -interface TableFeature extends Feature { - hasAnyPrivilegeAssigned: boolean; +interface State { + expandedFeatures: string[]; } interface TableRow { - feature: TableFeature; + featureId: string; + feature: SecuredFeature; + inherited: KibanaPrivilege[]; + effective: KibanaPrivilege[]; role: Role; } -export class FeatureTable extends Component { +export class FeatureTable extends Component { public static defaultProps = { - spacesIndex: -1, + privilegeIndex: -1, showLocks: true, }; + constructor(props: Props) { + super(props); + this.state = { + expandedFeatures: [], + }; + } + public render() { - const { role, features, calculatedPrivileges, rankedFeaturePrivileges } = this.props; + const { role, kibanaPrivileges } = this.props; + + const featurePrivileges = kibanaPrivileges.getSecuredFeatures(); - const items: TableRow[] = features + const items: TableRow[] = featurePrivileges .sort((feature1, feature2) => { - if ( - Object.keys(feature1.privileges).length === 0 && - Object.keys(feature2.privileges).length > 0 - ) { + if (feature1.reserved && !feature2.reserved) { return 1; } - if ( - Object.keys(feature2.privileges).length === 0 && - Object.keys(feature1.privileges).length > 0 - ) { + if (feature2.reserved && !feature1.reserved) { return -1; } return 0; }) .map(feature => { - const calculatedFeaturePrivileges = calculatedPrivileges.feature[feature.id]; - const hasAnyPrivilegeAssigned = Boolean( - calculatedFeaturePrivileges && - calculatedFeaturePrivileges.actualPrivilege !== NO_PRIVILEGE_VALUE - ); return { - feature: { - ...feature, - hasAnyPrivilegeAssigned, - }, + featureId: feature.id, + feature, + inherited: [], + effective: [], role, }; }); - // TODO: This simply grabs the available privileges from the first feature we encounter. - // As of now, features can have 'all' and 'read' as available privileges. Once that assumption breaks, - // this will need updating. This is a simplifying measure to enable the new UI. - const availablePrivileges = Object.values(rankedFeaturePrivileges)[0]; - return ( { + return { + ...acc, + [featureId]: ( + f.id === featureId)!} + privilegeIndex={this.props.privilegeIndex} + onChange={this.props.onChange} + privilegeCalculator={this.props.privilegeCalculator} + selectedFeaturePrivileges={ + this.props.role.kibana[this.props.privilegeIndex].feature[featureId] ?? [] + } + disabled={this.props.disabled} + /> + ), + }; + }, {})} items={items} /> ); @@ -115,171 +124,157 @@ export class FeatureTable extends Component { } }; - private getColumns = (availablePrivileges: string[]) => [ - { - field: 'feature', - name: i18n.translate( - 'xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle', - { defaultMessage: 'Feature' } - ), - render: (feature: TableFeature) => { - let tooltipElement = null; - if (feature.privilegesTooltip) { - const tooltipContent = ( - -

{feature.privilegesTooltip}

-
- ); - tooltipElement = ( - { + const basePrivileges = this.props.kibanaPrivileges.getBasePrivileges( + this.props.role.kibana[this.props.privilegeIndex] + ); + + const columns = []; + + if (this.props.canCustomizeSubFeaturePrivileges) { + columns.push({ + width: '30px', + isExpander: true, + field: 'featureId', + name: '', + render: (featureId: string, record: TableRow) => { + const { feature } = record; + const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0; + if (!hasSubFeaturePrivileges) { + return null; + } + return ( + this.toggleExpandedFeature(featureId)} + data-test-subj={`expandFeaturePrivilegeRow expandFeaturePrivilegeRow-${featureId}`} + aria-label={this.state.expandedFeatures.includes(featureId) ? 'Collapse' : 'Expand'} + iconType={this.state.expandedFeatures.includes(featureId) ? 'arrowUp' : 'arrowDown'} /> ); - } + }, + }); + } - return ( - - - {feature.name} {tooltipElement} - - ); + columns.push( + { + field: 'feature', + width: '200px', + name: i18n.translate( + 'xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle', + { + defaultMessage: 'Feature', + } + ), + render: (feature: SecuredFeature) => { + return ; + }, }, - }, - { - field: 'privilege', - name: ( - - - {!this.props.disabled && ( - - )} - - ), - render: (roleEntry: Role, record: TableRow) => { - const { id: featureId, name: featureName, reserved, privileges } = record.feature; - - if (reserved && Object.keys(privileges).length === 0) { - return {reserved.description}; - } - - const featurePrivileges = this.props.kibanaPrivileges - .getFeaturePrivileges() - .getPrivileges(featureId); - - if (featurePrivileges.length === 0) { - return null; - } - - const enabledFeaturePrivileges = this.getEnabledFeaturePrivileges( - featurePrivileges, - featureId - ); - - const privilegeExplanation = this.getPrivilegeExplanation(featureId); - - const allowsNone = this.allowsNoneForPrivilegeAssignment(featureId); - - const actualPrivilegeValue = privilegeExplanation.actualPrivilege; - - const canChangePrivilege = - !this.props.disabled && (allowsNone || enabledFeaturePrivileges.length > 1); - - if (!canChangePrivilege) { - const assignedBasePrivilege = - this.props.role.kibana[this.props.spacesIndex].base.length > 0; - - const excludedFromBasePrivilegsTooltip = ( + { + field: 'privilege', + width: '200px', + name: ( + + {!this.props.disabled && ( + + )} + + ), + mobileOptions: { + // Table isn't responsive, so skip rendering this for mobile. isn't free... + header: false, + }, + render: (roleEntry: Role, record: TableRow) => { + const { feature } = record; + + if (feature.reserved) { + return {feature.reserved.description}; + } + + const primaryFeaturePrivileges = feature.getPrimaryFeaturePrivileges(); + + if (primaryFeaturePrivileges.length === 0) { + return null; + } + + const selectedPrivilegeId = this.props.privilegeCalculator.getDisplayedPrimaryFeaturePrivilegeId( + feature.id, + this.props.privilegeIndex ); + const options = primaryFeaturePrivileges.map(privilege => { + return { + id: `${feature.id}_${privilege.id}`, + label: privilege.name, + isDisabled: this.props.disabled, + }; + }); + + options.push({ + id: `${feature.id}_${NO_PRIVILEGE_VALUE}`, + label: 'None', + isDisabled: this.props.disabled, + }); + + let warningIcon = ; + if ( + this.props.privilegeCalculator.hasCustomizedSubFeaturePrivileges( + feature.id, + this.props.privilegeIndex + ) + ) { + warningIcon = ( + + } + /> + ); + } + return ( - + + {warningIcon} + + + + ); - } - - const options = availablePrivileges.map(priv => { - return { - id: `${featureId}_${priv}`, - label: _.capitalize(priv), - isDisabled: !enabledFeaturePrivileges.includes(priv), - }; - }); - - options.push({ - id: `${featureId}_${NO_PRIVILEGE_VALUE}`, - label: 'None', - isDisabled: !allowsNone, - }); - - return ( - - ); - }, - }, - ]; - - private getEnabledFeaturePrivileges = (featurePrivileges: string[], featureId: string) => { - const { allowedPrivileges } = this.props; - - if (this.isConfiguringGlobalPrivileges()) { - // Global feature privileges are not limited by effective privileges. - return featurePrivileges; - } - - const allowedFeaturePrivileges = allowedPrivileges.feature[featureId]; - if (allowedFeaturePrivileges == null) { - throw new Error('Unable to get enabled feature privileges for a feature without privileges'); - } - - return allowedFeaturePrivileges.privileges; - }; - - private getPrivilegeExplanation = (featureId: string): PrivilegeExplanation => { - const { calculatedPrivileges } = this.props; - const calculatedFeaturePrivileges = calculatedPrivileges.feature[featureId]; - if (calculatedFeaturePrivileges == null) { - throw new Error('Unable to get privilege explanation for a feature without privileges'); - } - - return calculatedFeaturePrivileges; + }, + } + ); + return columns; }; - private allowsNoneForPrivilegeAssignment = (featureId: string): boolean => { - const { allowedPrivileges } = this.props; - const allowedFeaturePrivileges = allowedPrivileges.feature[featureId]; - if (allowedFeaturePrivileges == null) { - throw new Error('Unable to determine if none is allowed for a feature without privileges'); + private toggleExpandedFeature = (featureId: string) => { + if (this.state.expandedFeatures.includes(featureId)) { + this.setState({ + expandedFeatures: this.state.expandedFeatures.filter(ef => ef !== featureId), + }); + } else { + this.setState({ + expandedFeatures: [...this.state.expandedFeatures, featureId], + }); } - - return allowedFeaturePrivileges.canUnassign; }; private onChangeAllFeaturePrivileges = (privilege: string) => { @@ -289,7 +284,4 @@ export class FeatureTable extends Component { this.props.onChangeAll([privilege]); } }; - - private isConfiguringGlobalPrivileges = () => - isGlobalPrivilegeDefinition(this.props.role.kibana[this.props.spacesIndex]); } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx new file mode 100644 index 0000000000000..8897d89a39926 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx @@ -0,0 +1,199 @@ +/* + * 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 { Role } from '../../../../../../../common/model'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { FeatureTableExpandedRow } from './feature_table_expanded_row'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { act } from '@testing-library/react'; + +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'my_role', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, + }; +}; + +describe('FeatureTableExpandedRow', () => { + it('indicates sub-feature privileges are being customized if a minimal feature privilege is set', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props() + ).toMatchObject({ + disabled: false, + checked: true, + }); + }); + + it('indicates sub-feature privileges are not being customized if a primary feature privilege is set', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props() + ).toMatchObject({ + disabled: false, + checked: false, + }); + }); + + it('does not allow customizing if a primary privilege is not set', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props() + ).toMatchObject({ + disabled: true, + checked: false, + }); + }); + + it('switches to the minimal privilege when customizing privileges, including corresponding sub-feature privileges', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + act(() => { + findTestSubject(wrapper, 'customizeSubFeaturePrivileges').simulate('click'); + }); + + expect(onChange).toHaveBeenCalledWith('with_sub_features', [ + 'minimal_read', + 'cool_read', + 'cool_toggle_2', + ]); + }); + + it('switches to the primary privilege when not customizing privileges, removing any other privileges', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_read', 'cool_toggle_2'], + }, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + act(() => { + findTestSubject(wrapper, 'customizeSubFeaturePrivileges').simulate('click'); + }); + + expect(onChange).toHaveBeenCalledWith('with_sub_features', ['read']); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx new file mode 100644 index 0000000000000..fb302c2269485 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx @@ -0,0 +1,95 @@ +/* + * 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, useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexItem, EuiFlexGroup, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; +import { SubFeatureForm } from './sub_feature_form'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { SecuredFeature } from '../../../../model'; + +interface Props { + feature: SecuredFeature; + privilegeCalculator: PrivilegeFormCalculator; + privilegeIndex: number; + selectedFeaturePrivileges: string[]; + disabled?: boolean; + onChange: (featureId: string, featurePrivileges: string[]) => void; +} + +export const FeatureTableExpandedRow = ({ + feature, + onChange, + privilegeIndex, + privilegeCalculator, + selectedFeaturePrivileges, + disabled, +}: Props) => { + const [isCustomizing, setIsCustomizing] = useState(() => { + return feature + .getMinimalFeaturePrivileges() + .some(p => selectedFeaturePrivileges.includes(p.id)); + }); + + useEffect(() => { + const hasMinimalFeaturePrivilegeSelected = feature + .getMinimalFeaturePrivileges() + .some(p => selectedFeaturePrivileges.includes(p.id)); + + if (!hasMinimalFeaturePrivilegeSelected && isCustomizing) { + setIsCustomizing(false); + } + }, [feature, isCustomizing, selectedFeaturePrivileges]); + + const onCustomizeSubFeatureChange = (e: EuiSwitchEvent) => { + onChange( + feature.id, + privilegeCalculator.updateSelectedFeaturePrivilegesForCustomization( + feature.id, + privilegeIndex, + e.target.checked + ) + ); + setIsCustomizing(e.target.checked); + }; + + return ( + + + + } + checked={isCustomizing} + onChange={onCustomizeSubFeatureChange} + data-test-subj="customizeSubFeaturePrivileges" + disabled={ + disabled || + !privilegeCalculator.canCustomizeSubFeaturePrivileges(feature.id, privilegeIndex) + } + /> + + {feature.getSubFeatures().map(subFeature => { + return ( + + onChange(feature.id, updatedPrivileges)} + selectedFeaturePrivileges={selectedFeaturePrivileges} + disabled={disabled || !isCustomizing} + /> + + ); + })} + + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx new file mode 100644 index 0000000000000..ba7eff601f4c1 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx @@ -0,0 +1,237 @@ +/* + * 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 { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { SecuredSubFeature } from '../../../../model'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { Role } from '../../../../../../../common/model'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { SubFeatureForm } from './sub_feature_form'; +import { EuiCheckbox, EuiButtonGroup } from '@elastic/eui'; +import { act } from '@testing-library/react'; + +// Note: these tests are not concerned with the proper display of privileges, +// as that is verified by the feature_table and privilege_space_form tests. + +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'my_role', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, + }; +}; + +const featureId = 'with_sub_features'; +const subFeature = kibanaFeatures.find(kf => kf.id === featureId)!.subFeatures[0]; +const securedSubFeature = new SecuredSubFeature(subFeature.toRaw()); + +describe('SubFeatureForm', () => { + it('renders disabled elements when requested', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const wrapper = mountWithIntl( + + ); + + const checkboxes = wrapper.find(EuiCheckbox); + const buttonGroups = wrapper.find(EuiButtonGroup); + + expect(checkboxes.everyWhere(checkbox => checkbox.props().disabled === true)).toBe(true); + expect(buttonGroups.everyWhere(checkbox => checkbox.props().isDisabled === true)).toBe(true); + }); + + it('fires onChange when an independent privilege is selected', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const checkbox = wrapper.find('EuiCheckbox[id="with_sub_features_cool_toggle_1"] input'); + + act(() => { + checkbox.simulate('change', { target: { checked: true } }); + }); + + expect(onChange).toHaveBeenCalledWith(['cool_toggle_1']); + }); + + it('fires onChange when an independent privilege is deselected', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['cool_toggle_1', 'cool_toggle_2'], + }, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const checkbox = wrapper.find('EuiCheckbox[id="with_sub_features_cool_toggle_1"] input'); + + act(() => { + checkbox.simulate('change', { target: { checked: false } }); + }); + + expect(onChange).toHaveBeenCalledWith(['cool_toggle_2']); + }); + + it('fires onChange when a mutually exclusive privilege is selected', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const button = wrapper.find(EuiButtonGroup); + + act(() => { + button.props().onChange('cool_all'); + }); + + expect(onChange).toHaveBeenCalledWith(['cool_all']); + }); + + it('fires onChange when switching between mutually exclusive options', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const button = wrapper.find(EuiButtonGroup); + + act(() => { + button.props().onChange('cool_read'); + }); + + expect(onChange).toHaveBeenCalledWith(['cool_toggle_1', 'cool_read']); + }); + + it('fires onChange when a mutually exclusive privilege is deselected', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['cool_all'], + }, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const button = wrapper.find(EuiButtonGroup); + + act(() => { + button.props().onChange('none'); + }); + + expect(onChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx new file mode 100644 index 0000000000000..d4b6721ddad05 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx @@ -0,0 +1,134 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiCheckbox, EuiButtonGroup } from '@elastic/eui'; + +import { NO_PRIVILEGE_VALUE } from '../constants'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { + SecuredSubFeature, + SubFeaturePrivilegeGroup, + SubFeaturePrivilege, +} from '../../../../model'; + +interface Props { + featureId: string; + subFeature: SecuredSubFeature; + selectedFeaturePrivileges: string[]; + privilegeCalculator: PrivilegeFormCalculator; + privilegeIndex: number; + onChange: (selectedPrivileges: string[]) => void; + disabled?: boolean; +} + +export const SubFeatureForm = (props: Props) => { + return ( + + + {props.subFeature.name} + + {props.subFeature.getPrivilegeGroups().map(renderPrivilegeGroup)} + + ); + + function renderPrivilegeGroup(privilegeGroup: SubFeaturePrivilegeGroup, index: number) { + switch (privilegeGroup.groupType) { + case 'independent': + return renderIndependentPrivilegeGroup(privilegeGroup, index); + case 'mutually_exclusive': + return renderMutuallyExclusivePrivilegeGroup(privilegeGroup, index); + default: + throw new Error(`Unsupported privilege group type: ${privilegeGroup.groupType}`); + } + } + + function renderIndependentPrivilegeGroup( + privilegeGroup: SubFeaturePrivilegeGroup, + index: number + ) { + return ( +
+ {privilegeGroup.privileges.map((privilege: SubFeaturePrivilege) => { + const isGranted = props.privilegeCalculator.isIndependentSubFeaturePrivilegeGranted( + props.featureId, + privilege.id, + props.privilegeIndex + ); + return ( + { + const { checked } = e.target; + if (checked) { + props.onChange([...props.selectedFeaturePrivileges, privilege.id]); + } else { + props.onChange(props.selectedFeaturePrivileges.filter(sp => sp !== privilege.id)); + } + }} + checked={isGranted} + disabled={props.disabled} + compressed={true} + /> + ); + })} +
+ ); + } + + function renderMutuallyExclusivePrivilegeGroup( + privilegeGroup: SubFeaturePrivilegeGroup, + index: number + ) { + const firstSelectedPrivilege = props.privilegeCalculator.getSelectedMutuallyExclusiveSubFeaturePrivilege( + props.featureId, + privilegeGroup, + props.privilegeIndex + ); + + const options = [ + ...privilegeGroup.privileges.map((privilege, privilegeIndex) => { + return { + id: privilege.id, + label: privilege.name, + isDisabled: props.disabled, + }; + }), + ]; + + options.push({ + id: NO_PRIVILEGE_VALUE, + label: 'None', + isDisabled: props.disabled, + }); + + return ( + { + // Deselect all privileges which belong to this mutually-exclusive group + const privilegesWithoutGroupEntries = props.selectedFeaturePrivileges.filter( + sp => !privilegeGroup.privileges.some(privilege => privilege.id === sp) + ); + // fire on-change with the newly selected privilege + if (selectedPrivilegeId === NO_PRIVILEGE_VALUE) { + props.onChange(privilegesWithoutGroupEntries); + } else { + props.onChange([...privilegesWithoutGroupEntries, selectedPrivilegeId]); + } + }} + /> + ); + } +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx new file mode 100644 index 0000000000000..316818e4deed3 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { createFeature } from '../../../../__fixtures__/kibana_features'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { FeatureTableCell } from '.'; +import { SecuredFeature } from '../../../../model'; +import { EuiIcon, EuiIconTip } from '@elastic/eui'; + +describe('FeatureTableCell', () => { + it('renders an icon and feature name', () => { + const feature = createFeature({ + id: 'test-feature', + name: 'Test Feature', + }); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature "`); + expect(wrapper.find(EuiIcon).props()).toMatchObject({ + type: feature.icon, + }); + expect(wrapper.find(EuiIconTip)).toHaveLength(0); + }); + + it('renders an icon and feature name with tooltip when configured', () => { + const feature = createFeature({ + id: 'test-feature', + name: 'Test Feature', + privilegesTooltip: 'This is my awesome tooltip content', + }); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature "`); + expect( + wrapper + .find(EuiIcon) + .first() + .props() + ).toMatchObject({ + type: feature.icon, + }); + expect(wrapper.find(EuiIconTip).props().content).toMatchInlineSnapshot(` + +

+ This is my awesome tooltip content +

+
+ `); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx new file mode 100644 index 0000000000000..9e4a3a8a99b56 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiText, EuiIconTip, EuiIcon, IconType } from '@elastic/eui'; +import { SecuredFeature } from '../../../../model'; + +interface Props { + feature: SecuredFeature; +} + +export const FeatureTableCell = ({ feature }: Props) => { + let tooltipElement = null; + if (feature.getPrivilegesTooltip()) { + const tooltipContent = ( + +

{feature.getPrivilegesTooltip()}

+
+ ); + tooltipElement = ( + + ); + } + + return ( + + + {feature.name} {tooltipElement} + + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/index.ts similarity index 79% rename from x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/index.ts rename to x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/index.ts index 09e449f61356f..8f084fcc37c50 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/index.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { rawKibanaPrivileges } from './raw_kibana_privileges'; +export { FeatureTableCell } from './feature_table_cell'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/build_role.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/build_role.ts deleted file mode 100644 index 70e48dcdc37f8..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/build_role.ts +++ /dev/null @@ -1,38 +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 { FeaturesPrivileges, Role } from '../../../../../../../../common/model'; - -export interface BuildRoleOpts { - spacesPrivileges?: Array<{ - spaces: string[]; - base: string[]; - feature: FeaturesPrivileges; - }>; -} - -export const buildRole = (options: BuildRoleOpts = {}) => { - const role: Role = { - name: 'unit test role', - elasticsearch: { - indices: [], - cluster: [], - run_as: [], - }, - kibana: [], - }; - - if (options.spacesPrivileges) { - role.kibana.push(...options.spacesPrivileges); - } else { - role.kibana.push({ - spaces: [], - base: [], - feature: {}, - }); - } - - return role; -}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts deleted file mode 100644 index ddab7eff6835e..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts +++ /dev/null @@ -1,60 +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. - */ - -export const unrestrictedBasePrivileges = { - base: { - privileges: ['all', 'read'], - canUnassign: true, - }, -}; -export const unrestrictedFeaturePrivileges = { - feature: { - feature1: { - privileges: ['all', 'read'], - canUnassign: true, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: true, - }, - feature3: { - privileges: ['all'], - canUnassign: true, - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: true, - }, - }, -}; - -export const fullyRestrictedBasePrivileges = { - base: { - privileges: ['all'], - canUnassign: false, - }, -}; - -export const fullyRestrictedFeaturePrivileges = { - feature: { - feature1: { - privileges: ['all'], - canUnassign: false, - }, - feature2: { - privileges: ['all'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: false, - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, -}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts deleted file mode 100644 index 0c794b68f95da..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts +++ /dev/null @@ -1,43 +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 { KibanaPrivileges } from '../../../../../../../../common/model'; - -export const defaultPrivilegeDefinition = new KibanaPrivileges({ - global: { - all: ['api:/*', 'ui:/*'], - read: ['ui:/feature1/foo', 'ui:/feature2/foo', 'ui:/feature3/foo/*', 'ui:/feature4/foo'], - }, - space: { - all: [ - 'api:/feature1/*', - 'ui:/feature1/*', - 'api:/feature2/*', - 'ui:/feature2/*', - 'ui:/feature3/foo', - 'ui:/feature3/foo/*', - 'ui:/feature4/foo', - ], - read: ['ui:/feature1/foo', 'ui:/feature2/foo', 'ui:/feature3/foo/bar', 'ui:/feature4/foo'], - }, - features: { - feature1: { - all: ['ui:/feature1/foo', 'ui:/feature1/bar'], - read: ['ui:/feature1/foo'], - }, - feature2: { - all: ['ui:/feature2/foo', 'api:/feature2/bar'], - read: ['ui:/feature2/foo'], - }, - feature3: { - all: ['ui:/feature3/foo', 'ui:/feature3/foo/*'], - }, - feature4: { - all: ['somethingObscure:/feature4/foo', 'ui:/feature4/foo'], - read: ['ui:/feature4/foo'], - }, - }, - reserved: {}, -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts deleted file mode 100644 index 2a1c42838a83d..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts +++ /dev/null @@ -1,313 +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 { KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { - buildRole, - defaultPrivilegeDefinition, - fullyRestrictedBasePrivileges, - fullyRestrictedFeaturePrivileges, - unrestrictedBasePrivileges, - unrestrictedFeaturePrivileges, -} from './__fixtures__'; -import { KibanaAllowedPrivilegesCalculator } from './kibana_allowed_privileges_calculator'; -import { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory'; - -const buildAllowedPrivilegesCalculator = ( - role: Role, - kibanaPrivilege: KibanaPrivileges = defaultPrivilegeDefinition -) => { - return new KibanaAllowedPrivilegesCalculator(kibanaPrivilege, role); -}; - -const buildEffectivePrivilegesCalculator = ( - role: Role, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const factory = new KibanaPrivilegeCalculatorFactory(kibanaPrivileges); - return factory.getInstance(role); -}; - -describe('AllowedPrivileges', () => { - it('allows all privileges when none are currently assigned', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivilegesCalculator(role); - const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); - - const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( - effectivePrivileges.calculateEffectivePrivileges(true) - ); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - ]); - }); - - it('allows all global base privileges, but just "all" for everything else when global is set to "all"', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivilegesCalculator(role); - const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); - - const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( - effectivePrivileges.calculateEffectivePrivileges(true) - ); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...fullyRestrictedFeaturePrivileges, - }, - { - ...fullyRestrictedBasePrivileges, - ...fullyRestrictedFeaturePrivileges, - }, - ]); - }); - - it(`allows feature privileges to be set to "all" or "read" when global base is "read"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivilegesCalculator(role); - const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); - - const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( - effectivePrivileges.calculateEffectivePrivileges(true) - ); - - const expectedFeaturePrivileges = { - feature: { - feature1: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: true, // feature 3 has no "read" privilege governed by global "all" - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }; - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...expectedFeaturePrivileges, - }, - { - base: { - privileges: ['all', 'read'], - canUnassign: false, - }, - ...expectedFeaturePrivileges, - }, - ]); - }); - - it(`allows feature privileges to be set to "all" or "read" when space base is "read"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: {}, - }, - { - spaces: ['foo'], - base: ['read'], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivilegesCalculator(role); - const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); - - const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( - effectivePrivileges.calculateEffectivePrivileges(true) - ); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - { - base: { - privileges: ['all', 'read'], - canUnassign: true, - }, - feature: { - feature1: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: true, // feature 3 has no "read" privilege governed by space "all" - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }, - ]); - }); - - it(`allows space base privilege to be set to "all" or "read" when space base is already "all"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['foo'], - base: ['all'], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivilegesCalculator(role); - const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); - - const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( - effectivePrivileges.calculateEffectivePrivileges(true) - ); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - feature: { - feature1: { - privileges: ['all'], - canUnassign: false, - }, - feature2: { - privileges: ['all'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: false, - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }, - ]); - }); - - it(`restricts space feature privileges when global feature privileges are set`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['all'], - feature2: ['read'], - feature4: ['all'], - }, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivilegesCalculator(role); - const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); - - const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( - effectivePrivileges.calculateEffectivePrivileges(true) - ); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - { - base: { - privileges: ['all', 'read'], - canUnassign: true, - }, - feature: { - feature1: { - privileges: ['all'], - canUnassign: false, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: true, // feature 3 has no "read" privilege governed by space "all" - }, - feature4: { - privileges: ['all'], - canUnassign: false, - }, - }, - }, - ]); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts deleted file mode 100644 index cea25649c43ff..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts +++ /dev/null @@ -1,155 +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 { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { - areActionsFullyCovered, - compareActions, -} from '../../../../../../../common/privilege_calculator_utils'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { - AllowedPrivilege, - CalculatedPrivilege, - PRIVILEGE_SOURCE, -} from './kibana_privilege_calculator_types'; - -export class KibanaAllowedPrivilegesCalculator { - // reference to the global privilege definition - private globalPrivilege: RoleKibanaPrivilege; - - // list of privilege actions that comprise the global base privilege - private readonly assignedGlobalBaseActions: string[]; - - constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) { - this.globalPrivilege = this.locateGlobalPrivilege(role); - this.assignedGlobalBaseActions = this.globalPrivilege.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(this.globalPrivilege.base[0]) - : []; - } - - public calculateAllowedPrivileges( - effectivePrivileges: CalculatedPrivilege[] - ): AllowedPrivilege[] { - const { kibana = [] } = this.role; - return kibana.map((privilegeSpec, index) => - this.calculateAllowedPrivilege(privilegeSpec, effectivePrivileges[index]) - ); - } - - private calculateAllowedPrivilege( - privilegeSpec: RoleKibanaPrivilege, - effectivePrivileges: CalculatedPrivilege - ): AllowedPrivilege { - const result: AllowedPrivilege = { - base: { - privileges: [], - canUnassign: true, - }, - feature: {}, - }; - - if (isGlobalPrivilegeDefinition(privilegeSpec)) { - // nothing can impede global privileges - result.base.canUnassign = true; - result.base.privileges = this.kibanaPrivileges.getGlobalPrivileges().getAllPrivileges(); - } else { - // space base privileges are restricted based on the assigned global privileges - const spacePrivileges = this.kibanaPrivileges.getSpacesPrivileges().getAllPrivileges(); - result.base.canUnassign = this.assignedGlobalBaseActions.length === 0; - result.base.privileges = spacePrivileges.filter(privilege => { - // always allowed to assign the calculated effective privilege - if (privilege === effectivePrivileges.base.actualPrivilege) { - return true; - } - - const privilegeActions = this.getBaseActions(PRIVILEGE_SOURCE.SPACE_BASE, privilege); - return !areActionsFullyCovered(this.assignedGlobalBaseActions, privilegeActions); - }); - } - - const allFeaturePrivileges = this.kibanaPrivileges.getFeaturePrivileges().getAllPrivileges(); - result.feature = Object.entries(allFeaturePrivileges).reduce( - (acc, [featureId, featurePrivileges]) => { - return { - ...acc, - [featureId]: this.getAllowedFeaturePrivileges( - effectivePrivileges, - featureId, - featurePrivileges - ), - }; - }, - {} - ); - - return result; - } - - private getAllowedFeaturePrivileges( - effectivePrivileges: CalculatedPrivilege, - featureId: string, - candidateFeaturePrivileges: string[] - ): { privileges: string[]; canUnassign: boolean } { - const effectiveFeaturePrivilegeExplanation = effectivePrivileges.feature[featureId]; - if (effectiveFeaturePrivilegeExplanation == null) { - throw new Error('To calculate allowed feature privileges, we need the effective privileges'); - } - - const effectiveFeatureActions = this.getFeatureActions( - featureId, - effectiveFeaturePrivilegeExplanation.actualPrivilege - ); - - const privileges = []; - if (effectiveFeaturePrivilegeExplanation.actualPrivilege !== NO_PRIVILEGE_VALUE) { - // Always allowed to assign the calculated effective privilege - privileges.push(effectiveFeaturePrivilegeExplanation.actualPrivilege); - } - - privileges.push( - ...candidateFeaturePrivileges.filter(privilegeId => { - const candidateActions = this.getFeatureActions(featureId, privilegeId); - return compareActions(effectiveFeatureActions, candidateActions) > 0; - }) - ); - - const result = { - privileges: privileges.sort(), - canUnassign: effectiveFeaturePrivilegeExplanation.actualPrivilege === NO_PRIVILEGE_VALUE, - }; - - return result; - } - - private getBaseActions(source: PRIVILEGE_SOURCE, privilegeId: string) { - switch (source) { - case PRIVILEGE_SOURCE.GLOBAL_BASE: - return this.assignedGlobalBaseActions; - case PRIVILEGE_SOURCE.SPACE_BASE: - return this.kibanaPrivileges.getSpacesPrivileges().getActions(privilegeId); - default: - throw new Error( - `Cannot get base actions for unsupported privilege source ${PRIVILEGE_SOURCE[source]}` - ); - } - } - - private getFeatureActions(featureId: string, privilegeId: string): string[] { - return this.kibanaPrivileges.getFeaturePrivileges().getActions(featureId, privilegeId); - } - - private locateGlobalPrivilege(role: Role) { - const spacePrivileges = role.kibana; - return ( - spacePrivileges.find(privileges => isGlobalPrivilegeDefinition(privileges)) || { - spaces: [] as string[], - base: [] as string[], - feature: {}, - } - ); - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts deleted file mode 100644 index 8d30061b92c6f..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts +++ /dev/null @@ -1,321 +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 { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { buildRole, defaultPrivilegeDefinition } from './__fixtures__'; -import { KibanaBasePrivilegeCalculator } from './kibana_base_privilege_calculator'; -import { PRIVILEGE_SOURCE, PrivilegeExplanation } from './kibana_privilege_calculator_types'; - -const buildEffectiveBasePrivilegeCalculator = ( - role: Role, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const globalPrivilegeSpec = - role.kibana.find(k => isGlobalPrivilegeDefinition(k)) || - ({ - spaces: ['*'], - base: [], - feature: {}, - } as RoleKibanaPrivilege); - - const globalActions = globalPrivilegeSpec.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0]) - : []; - - return new KibanaBasePrivilegeCalculator(kibanaPrivileges, globalPrivilegeSpec, globalActions); -}; - -describe('getMostPermissiveBasePrivilege', () => { - describe('without ignoring assigned', () => { - it('returns "none" when no privileges are granted', () => { - const role = buildRole(); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[0], - false - ); - - expect(result).toEqual({ - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - } as PrivilegeExplanation); - }); - - defaultPrivilegeDefinition - .getGlobalPrivileges() - .getAllPrivileges() - .forEach(globalBasePrivilege => { - it(`returns "${globalBasePrivilege}" when assigned directly at the global privilege`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [globalBasePrivilege], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[0], - false - ); - - expect(result).toEqual({ - actualPrivilege: globalBasePrivilege, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - } as PrivilegeExplanation); - }); - }); - - defaultPrivilegeDefinition - .getSpacesPrivileges() - .getAllPrivileges() - .forEach(spaceBasePrivilege => { - it(`returns "${spaceBasePrivilege}" when assigned directly at the space base privilege`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['foo'], - base: [spaceBasePrivilege], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[0], - false - ); - - expect(result).toEqual({ - actualPrivilege: spaceBasePrivilege, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - } as PrivilegeExplanation); - }); - }); - - it('returns the global privilege when no space base is defined', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[1], - false - ); - - expect(result).toEqual({ - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - } as PrivilegeExplanation); - }); - - it('returns the global privilege when it supercedes the space privilege', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: ['read'], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[1], - false - ); - - expect(result).toEqual({ - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - } as PrivilegeExplanation); - }); - }); - - describe('ignoring assigned', () => { - it('returns "none" when no privileges are granted', () => { - const role = buildRole(); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[0], - true - ); - - expect(result).toEqual({ - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - } as PrivilegeExplanation); - }); - - defaultPrivilegeDefinition - .getGlobalPrivileges() - .getAllPrivileges() - .forEach(globalBasePrivilege => { - it(`returns "none" when "${globalBasePrivilege}" assigned directly at the global privilege`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [globalBasePrivilege], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[0], - true - ); - - expect(result).toEqual({ - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - } as PrivilegeExplanation); - }); - }); - - defaultPrivilegeDefinition - .getSpacesPrivileges() - .getAllPrivileges() - .forEach(spaceBasePrivilege => { - it(`returns "none" when "${spaceBasePrivilege}" when assigned directly at the space base privilege`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['foo'], - base: [spaceBasePrivilege], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[0], - true - ); - - expect(result).toEqual({ - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - } as PrivilegeExplanation); - }); - }); - - it('returns the global privilege when no space base is defined', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[1], - true - ); - - expect(result).toEqual({ - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - } as PrivilegeExplanation); - }); - - it('returns the global privilege when it supercedes the space privilege, without indicating override', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: ['read'], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[1], - true - ); - - expect(result).toEqual({ - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - } as PrivilegeExplanation); - }); - - it('returns the global privilege even though it would ordinarly be overriden by space base privilege', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['foo'], - base: ['all'], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[1], - true - ); - - expect(result).toEqual({ - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - } as PrivilegeExplanation); - }); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.ts deleted file mode 100644 index 9fefea637e168..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.ts +++ /dev/null @@ -1,98 +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 { KibanaPrivileges, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { compareActions } from '../../../../../../../common/privilege_calculator_utils'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { PRIVILEGE_SOURCE, PrivilegeExplanation } from './kibana_privilege_calculator_types'; - -export class KibanaBasePrivilegeCalculator { - constructor( - private readonly kibanaPrivileges: KibanaPrivileges, - private readonly globalPrivilege: RoleKibanaPrivilege, - private readonly assignedGlobalBaseActions: string[] - ) {} - - public getMostPermissiveBasePrivilege( - privilegeSpec: RoleKibanaPrivilege, - ignoreAssigned: boolean - ): PrivilegeExplanation { - const assignedPrivilege = privilegeSpec.base[0] || NO_PRIVILEGE_VALUE; - - // If this is the global privilege definition, then there is nothing to supercede it. - if (isGlobalPrivilegeDefinition(privilegeSpec)) { - if (assignedPrivilege === NO_PRIVILEGE_VALUE || ignoreAssigned) { - return { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }; - } - return { - actualPrivilege: assignedPrivilege, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }; - } - - // Otherwise, check to see if the global privilege supercedes this one. - const baseActions = [ - ...this.kibanaPrivileges.getSpacesPrivileges().getActions(assignedPrivilege), - ]; - - const globalSupercedes = - this.hasAssignedGlobalBasePrivilege() && - (compareActions(this.assignedGlobalBaseActions, baseActions) < 0 || ignoreAssigned); - - if (globalSupercedes) { - const wasDirectlyAssigned = !ignoreAssigned && baseActions.length > 0; - - return { - actualPrivilege: this.globalPrivilege.base[0], - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - ...this.buildSupercededFields( - wasDirectlyAssigned, - assignedPrivilege, - PRIVILEGE_SOURCE.SPACE_BASE - ), - }; - } - - if (!ignoreAssigned) { - return { - actualPrivilege: assignedPrivilege, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - }; - } - - return { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - }; - } - - private hasAssignedGlobalBasePrivilege() { - return this.assignedGlobalBaseActions.length > 0; - } - - private buildSupercededFields( - isSuperceding: boolean, - supersededPrivilege?: string, - supersededPrivilegeSource?: PRIVILEGE_SOURCE - ) { - if (!isSuperceding) { - return {}; - } - return { - supersededPrivilege, - supersededPrivilegeSource, - }; - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts deleted file mode 100644 index 887fffa1b0cbc..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts +++ /dev/null @@ -1,959 +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 { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { buildRole, BuildRoleOpts, defaultPrivilegeDefinition } from './__fixtures__'; -import { KibanaBasePrivilegeCalculator } from './kibana_base_privilege_calculator'; -import { KibanaFeaturePrivilegeCalculator } from './kibana_feature_privilege_calculator'; -import { PRIVILEGE_SOURCE } from './kibana_privilege_calculator_types'; - -const buildEffectiveBasePrivilegeCalculator = ( - role: Role, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const globalPrivilegeSpec = - role.kibana.find(k => isGlobalPrivilegeDefinition(k)) || - ({ - spaces: ['*'], - base: [], - feature: {}, - } as RoleKibanaPrivilege); - - const globalActions = globalPrivilegeSpec.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0]) - : []; - - return new KibanaBasePrivilegeCalculator(kibanaPrivileges, globalPrivilegeSpec, globalActions); -}; - -const buildEffectiveFeaturePrivilegeCalculator = ( - role: Role, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const globalPrivilegeSpec = - role.kibana.find(k => isGlobalPrivilegeDefinition(k)) || - ({ - spaces: ['*'], - base: [], - feature: {}, - } as RoleKibanaPrivilege); - - const globalActions = globalPrivilegeSpec.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0]) - : []; - - const rankedFeaturePrivileges = kibanaPrivileges.getFeaturePrivileges().getAllPrivileges(); - - return new KibanaFeaturePrivilegeCalculator( - kibanaPrivileges, - globalPrivilegeSpec, - globalActions, - rankedFeaturePrivileges - ); -}; - -interface TestOpts { - only?: boolean; - role?: BuildRoleOpts; - privilegeIndex?: number; - ignoreAssigned?: boolean; - result: Record; - feature?: string; -} - -function runTest( - description: string, - { - role: roleOpts = {}, - result = {}, - privilegeIndex = 0, - ignoreAssigned = false, - only = false, - feature = 'feature1', - }: TestOpts -) { - const fn = only ? it.only : it; - fn(description, () => { - const role = buildRole(roleOpts); - const basePrivilegeCalculator = buildEffectiveBasePrivilegeCalculator(role); - const featurePrivilegeCalculator = buildEffectiveFeaturePrivilegeCalculator(role); - - const baseExplanation = basePrivilegeCalculator.getMostPermissiveBasePrivilege( - role.kibana[privilegeIndex], - // If calculations wish to ignoreAssigned, then we still need to know what the real effective base privilege is - // without ignoring assigned, in order to calculate the correct feature privileges. - false - ); - - const actualResult = featurePrivilegeCalculator.getMostPermissiveFeaturePrivilege( - role.kibana[privilegeIndex], - baseExplanation, - feature, - ignoreAssigned - ); - - expect(actualResult).toEqual(result); - }); -} - -describe('getMostPermissiveFeaturePrivilege', () => { - describe('for global feature privileges, without ignoring assigned', () => { - runTest('returns "none" when no privileges are granted', { - result: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "read" when assigned directly to the feature', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "read" when assigned as the global base privilege', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - ], - }, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest( - 'returns "all" when assigned as the global base privilege, which overrides assigned feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['read'], - }, - }, - ], - }, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - }, - } - ); - - runTest( - 'returns "all" when assigned as the feature privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - }, - }, - ], - }, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - } - ); - }); - - describe('for global feature privileges, ignoring assigned', () => { - runTest('returns "none" when no privileges are granted', { - ignoreAssigned: true, - result: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "none" when "read" is assigned directly to the feature', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - ignoreAssigned: true, - result: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "read" when assigned as the global base privilege', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - ], - }, - ignoreAssigned: true, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest( - 'returns "all" when assigned as the global base privilege, which overrides assigned feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['read'], - }, - }, - ], - }, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "read" when "all" assigned as the feature privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - }, - }, - ], - }, - ignoreAssigned: true, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - }); - - describe('for space feature privileges, without ignoring assigned', () => { - runTest('returns "none" when no privileges are granted', { - role: { - spacesPrivileges: [ - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - result: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "read" when assigned directly to the feature', { - role: { - spacesPrivileges: [ - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true, - }, - }); - - runTest('returns "read" when assigned as the global base privilege', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest( - 'returns "all" when assigned as the global base privilege, which overrides assigned global feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['read'], - }, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the global base privilege, which overrides assigned space feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - }, - } - ); - - runTest( - 'returns "all" when assigned as the global feature privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the space feature privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['all'], - }, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true, - }, - } - ); - - runTest( - 'returns "all" when assigned as the space base privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['marketing'], - base: ['all'], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the global base privilege, which overrides assigned space feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - }, - } - ); - - runTest('returns "all" when assigned everywhere, without indicating override', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['marketing'], - base: ['all'], - feature: { - feature1: ['all'], - }, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: false, - }, - }); - - runTest('returns "all" when assigned at global feature, overriding space feature', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - }, - }); - - describe('feature with "all" excluded from base privileges', () => { - runTest('returns "read" when "all" assigned as the global base privilege', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - feature: 'feature4', - privilegeIndex: 1, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest( - 'returns "read" when "all" assigned as the global base privilege, which does not override assigned space feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature4: ['read'], - }, - }, - ], - }, - feature: 'feature4', - privilegeIndex: 1, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the feature privilege, which is more permissive than the base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature4: ['all'], - }, - }, - ], - }, - feature: 'feature4', - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true, - }, - } - ); - }); - }); - - describe('for space feature privileges, ignoring assigned', () => { - runTest('returns "none" when no privileges are granted', { - role: { - spacesPrivileges: [ - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - ignoreAssigned: true, - result: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "none" when "read" assigned directly to the feature', { - role: { - spacesPrivileges: [ - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - ignoreAssigned: true, - result: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "read" when assigned as the global base privilege', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest( - 'returns "all" when assigned as the global base privilege, which overrides assigned global feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['read'], - }, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the global base privilege, which normally overrides assigned space feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the global feature privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "read" when "all" assigned as the space feature privilege, which normally overrides assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['all'], - }, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the space base privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['marketing'], - base: ['all'], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the global base privilege, which normally overrides assigned space feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest('returns "all" when assigned everywhere, without indicating override', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['marketing'], - base: ['all'], - feature: { - feature1: ['all'], - }, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest('returns "all" when assigned at global feature, normally overriding space feature', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: false, - }, - }); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts deleted file mode 100644 index 1ca87871aa892..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts +++ /dev/null @@ -1,209 +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 { - FeaturesPrivileges, - KibanaPrivileges, - RoleKibanaPrivilege, -} from '../../../../../../../common/model'; -import { areActionsFullyCovered } from '../../../../../../../common/privilege_calculator_utils'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { - PRIVILEGE_SOURCE, - PrivilegeExplanation, - PrivilegeScenario, -} from './kibana_privilege_calculator_types'; - -export class KibanaFeaturePrivilegeCalculator { - constructor( - private readonly kibanaPrivileges: KibanaPrivileges, - private readonly globalPrivilege: RoleKibanaPrivilege, - private readonly assignedGlobalBaseActions: string[], - private readonly rankedFeaturePrivileges: FeaturesPrivileges - ) {} - - public getMostPermissiveFeaturePrivilege( - privilegeSpec: RoleKibanaPrivilege, - basePrivilegeExplanation: PrivilegeExplanation, - featureId: string, - ignoreAssigned: boolean - ): PrivilegeExplanation { - const scenarios = this.buildFeaturePrivilegeScenarios( - privilegeSpec, - basePrivilegeExplanation, - featureId, - ignoreAssigned - ); - - const featurePrivileges = this.rankedFeaturePrivileges[featureId] || []; - - // inspect feature privileges in ranked order (most permissive -> least permissive) - for (const featurePrivilege of featurePrivileges) { - const actions = this.kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, featurePrivilege); - - // check if any of the scenarios satisfy the privilege - first one wins. - for (const scenario of scenarios) { - if (areActionsFullyCovered(scenario.actions, actions)) { - return { - actualPrivilege: featurePrivilege, - actualPrivilegeSource: scenario.actualPrivilegeSource, - isDirectlyAssigned: scenario.isDirectlyAssigned, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: - scenario.directlyAssignedFeaturePrivilegeMorePermissiveThanBase, - ...this.buildSupercededFields( - !scenario.isDirectlyAssigned, - scenario.supersededPrivilege, - scenario.supersededPrivilegeSource - ), - }; - } - } - } - - const isGlobal = isGlobalPrivilegeDefinition(privilegeSpec); - return { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: isGlobal - ? PRIVILEGE_SOURCE.GLOBAL_FEATURE - : PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }; - } - - private buildFeaturePrivilegeScenarios( - privilegeSpec: RoleKibanaPrivilege, - basePrivilegeExplanation: PrivilegeExplanation, - featureId: string, - ignoreAssigned: boolean - ): PrivilegeScenario[] { - const scenarios: PrivilegeScenario[] = []; - - const isGlobalPrivilege = isGlobalPrivilegeDefinition(privilegeSpec); - - const assignedGlobalFeaturePrivilege = this.getAssignedFeaturePrivilege( - this.globalPrivilege, - featureId - ); - - const assignedFeaturePrivilege = this.getAssignedFeaturePrivilege(privilegeSpec, featureId); - const hasAssignedFeaturePrivilege = - !ignoreAssigned && assignedFeaturePrivilege !== NO_PRIVILEGE_VALUE; - - scenarios.push({ - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - actions: [...this.assignedGlobalBaseActions], - ...this.buildSupercededFields( - hasAssignedFeaturePrivilege, - assignedFeaturePrivilege, - isGlobalPrivilege ? PRIVILEGE_SOURCE.GLOBAL_FEATURE : PRIVILEGE_SOURCE.SPACE_FEATURE - ), - }); - - if (!isGlobalPrivilege || !ignoreAssigned) { - scenarios.push({ - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - actions: this.getFeatureActions(featureId, assignedGlobalFeaturePrivilege), - isDirectlyAssigned: isGlobalPrivilege && hasAssignedFeaturePrivilege, - ...this.buildSupercededFields( - hasAssignedFeaturePrivilege && !isGlobalPrivilege, - assignedFeaturePrivilege, - PRIVILEGE_SOURCE.SPACE_FEATURE - ), - }); - } - - if (isGlobalPrivilege) { - return this.rankScenarios(scenarios); - } - - // Otherwise, this is a space feature privilege - - const includeSpaceBaseScenario = - basePrivilegeExplanation.actualPrivilegeSource === PRIVILEGE_SOURCE.SPACE_BASE || - basePrivilegeExplanation.supersededPrivilegeSource === PRIVILEGE_SOURCE.SPACE_BASE; - - const spaceBasePrivilege = - basePrivilegeExplanation.supersededPrivilege || basePrivilegeExplanation.actualPrivilege; - - if (includeSpaceBaseScenario) { - scenarios.push({ - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - actions: this.getBaseActions(PRIVILEGE_SOURCE.SPACE_BASE, spaceBasePrivilege), - ...this.buildSupercededFields( - hasAssignedFeaturePrivilege, - assignedFeaturePrivilege, - PRIVILEGE_SOURCE.SPACE_FEATURE - ), - }); - } - - if (!ignoreAssigned) { - const actions = this.getFeatureActions( - featureId, - this.getAssignedFeaturePrivilege(privilegeSpec, featureId) - ); - const directlyAssignedFeaturePrivilegeMorePermissiveThanBase = !areActionsFullyCovered( - this.assignedGlobalBaseActions, - actions - ); - scenarios.push({ - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase, - actions, - }); - } - - return this.rankScenarios(scenarios); - } - - private rankScenarios(scenarios: PrivilegeScenario[]): PrivilegeScenario[] { - return scenarios.sort( - (scenario1, scenario2) => scenario1.actualPrivilegeSource - scenario2.actualPrivilegeSource - ); - } - - private getBaseActions(source: PRIVILEGE_SOURCE, privilegeId: string) { - switch (source) { - case PRIVILEGE_SOURCE.GLOBAL_BASE: - return this.assignedGlobalBaseActions; - case PRIVILEGE_SOURCE.SPACE_BASE: - return this.kibanaPrivileges.getSpacesPrivileges().getActions(privilegeId); - default: - throw new Error( - `Cannot get base actions for unsupported privilege source ${PRIVILEGE_SOURCE[source]}` - ); - } - } - - private getFeatureActions(featureId: string, privilegeId: string) { - return this.kibanaPrivileges.getFeaturePrivileges().getActions(featureId, privilegeId); - } - - private getAssignedFeaturePrivilege(privilegeSpec: RoleKibanaPrivilege, featureId: string) { - const featureEntry = privilegeSpec.feature[featureId] || []; - return featureEntry[0] || NO_PRIVILEGE_VALUE; - } - - private buildSupercededFields( - isSuperceding: boolean, - supersededPrivilege?: string, - supersededPrivilegeSource?: PRIVILEGE_SOURCE - ) { - if (!isSuperceding) { - return {}; - } - return { - supersededPrivilege, - supersededPrivilegeSource, - }; - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.test.ts deleted file mode 100644 index 4c44c077f0336..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.test.ts +++ /dev/null @@ -1,940 +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 { KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { - buildRole, - defaultPrivilegeDefinition, - fullyRestrictedBasePrivileges, - fullyRestrictedFeaturePrivileges, - unrestrictedBasePrivileges, - unrestrictedFeaturePrivileges, -} from './__fixtures__'; -import { - AllowedPrivilege, - PRIVILEGE_SOURCE, - PrivilegeExplanation, -} from './kibana_privilege_calculator_types'; -import { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory'; - -const buildEffectivePrivileges = ( - role: Role, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const factory = new KibanaPrivilegeCalculatorFactory(kibanaPrivileges); - return factory.getInstance(role); -}; - -interface BuildExpectedFeaturePrivilegesOption { - features: string[]; - privilegeExplanation: PrivilegeExplanation; -} - -const buildExpectedFeaturePrivileges = (options: BuildExpectedFeaturePrivilegesOption[]) => { - return { - feature: options.reduce((acc1, option) => { - return { - ...acc1, - ...option.features.reduce((acc2, featureId) => { - return { - ...acc2, - [featureId]: option.privilegeExplanation, - }; - }, {}), - }; - }, {}), - }; -}; - -describe('calculateEffectivePrivileges', () => { - it(`returns an empty array for an empty role`, () => { - const role = buildRole(); - role.kibana = []; - - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - expect(calculatedPrivileges).toHaveLength(0); - }); - - it(`calculates "none" for all privileges when nothing is assigned`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['foo', 'bar'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3', 'feature4'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }, - }, - ]), - }, - ]); - }); - - describe(`with global base privilege of "all"`, () => { - it(`calculates global feature privilege of all for features 1-3 and read for feature 4`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - ]); - }); - - it(`calculates space base and feature privilege of all for features 1-3 and read for feature 4`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - const calculatedSpacePrivileges = calculatedPrivileges[1]; - - expect(calculatedSpacePrivileges).toEqual({ - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }); - }); - - describe(`and with feature privileges assigned`, () => { - it('returns the base privileges when they are more permissive', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['read'], - feature2: ['read'], - feature3: ['read'], - feature4: ['read'], - }, - }, - { - spaces: ['foo'], - base: [], - feature: { - feature1: ['read'], - feature2: ['read'], - feature3: ['read'], - feature4: ['read'], - }, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: false, - }, - }, - ]), - }, - ]); - }); - }); - }); - - describe(`with global base privilege of "read"`, () => { - it(`it calculates space base and feature privileges when none are provided`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature3'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature3'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - ]); - }); - - describe('and with feature privileges assigned', () => { - it('returns the feature privileges when they are more permissive', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - feature2: ['all'], - feature3: ['all'], - feature4: ['all'], - }, - }, - { - spaces: ['foo'], - base: [], - feature: { - feature1: ['all'], - feature2: ['all'], - feature3: ['all'], - feature4: ['all'], - }, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3', 'feature4'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3', 'feature4'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true, - }, - }, - ]), - }, - ]); - }); - }); - }); - - describe('with both global and space base privileges assigned', () => { - it(`does not override space base of "all" when global base is "read"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['foo'], - base: ['all'], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature3'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - ]); - }); - - it(`calculates "all" for space base and space features when superceded by global "all"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: ['read'], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - ]); - }); - - it(`does not override feature privileges when they are more permissive`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['foo'], - base: ['read'], - feature: { - feature1: ['all'], - feature2: ['all'], - feature3: ['all'], - feature4: ['all'], - }, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature3'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3', 'feature4'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true, - }, - }, - ]), - }, - ]); - }); - }); -}); - -describe('calculateAllowedPrivileges', () => { - it('allows all privileges when none are currently assigned', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - - const privilegeCalculator = buildEffectivePrivileges(role); - - const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - ]); - }); - - it('allows all global base privileges, but just "all" for everything else when global is set to "all"', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - - const privilegeCalculator = buildEffectivePrivileges(role); - - const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...fullyRestrictedFeaturePrivileges, - }, - { - ...fullyRestrictedBasePrivileges, - ...fullyRestrictedFeaturePrivileges, - }, - ]); - }); - - it(`allows feature privileges to be set to "all" or "read" when global base is "read"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - - const privilegeCalculator = buildEffectivePrivileges(role); - - const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); - - const expectedFeaturePrivileges = { - feature: { - feature1: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: true, // feature 3 has no "read" privilege governed by global "all" - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }; - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...expectedFeaturePrivileges, - }, - { - base: { - privileges: ['all', 'read'], - canUnassign: false, - }, - ...expectedFeaturePrivileges, - }, - ]); - }); - - it(`allows feature privileges to be set to "all" or "read" when space base is "read"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: {}, - }, - { - spaces: ['foo'], - base: ['read'], - feature: {}, - }, - ], - }); - - const privilegeCalculator = buildEffectivePrivileges(role); - - const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - { - base: { - privileges: ['all', 'read'], - canUnassign: true, - }, - feature: { - feature1: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: true, // feature 3 has no "read" privilege governed by space "all" - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }, - ]); - }); - - it(`allows space base privilege to be set to "all" or "read" when space base is already "all"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['foo'], - base: ['all'], - feature: {}, - }, - ], - }); - - const privilegeCalculator = buildEffectivePrivileges(role); - - const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - feature: { - feature1: { - privileges: ['all'], - canUnassign: false, - }, - feature2: { - privileges: ['all'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: false, - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }, - ]); - }); - - it(`restricts space feature privileges when global feature privileges are set`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['all'], - feature2: ['read'], - feature4: ['all'], - }, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - - const privilegeCalculator = buildEffectivePrivileges(role); - - const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - { - base: { - privileges: ['all', 'read'], - canUnassign: true, - }, - feature: { - feature1: { - privileges: ['all'], - canUnassign: false, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: true, // feature 3 has no "read" privilege governed by space "all" - }, - feature4: { - privileges: ['all'], - canUnassign: false, - }, - }, - }, - ]); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.ts deleted file mode 100644 index c3bf12b6aef5f..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.ts +++ /dev/null @@ -1,113 +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 { - FeaturesPrivileges, - KibanaPrivileges, - Role, - RoleKibanaPrivilege, -} from '../../../../../../../common/model'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { KibanaAllowedPrivilegesCalculator } from './kibana_allowed_privileges_calculator'; -import { KibanaBasePrivilegeCalculator } from './kibana_base_privilege_calculator'; -import { KibanaFeaturePrivilegeCalculator } from './kibana_feature_privilege_calculator'; -import { AllowedPrivilege, CalculatedPrivilege } from './kibana_privilege_calculator_types'; - -export class KibanaPrivilegeCalculator { - private allowedPrivilegesCalculator: KibanaAllowedPrivilegesCalculator; - - private effectiveBasePrivilegesCalculator: KibanaBasePrivilegeCalculator; - - private effectiveFeaturePrivilegesCalculator: KibanaFeaturePrivilegeCalculator; - - constructor( - private readonly kibanaPrivileges: KibanaPrivileges, - private readonly role: Role, - public readonly rankedFeaturePrivileges: FeaturesPrivileges - ) { - const globalPrivilege = this.locateGlobalPrivilege(role); - - const assignedGlobalBaseActions: string[] = globalPrivilege.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilege.base[0]) - : []; - - this.allowedPrivilegesCalculator = new KibanaAllowedPrivilegesCalculator( - kibanaPrivileges, - role - ); - - this.effectiveBasePrivilegesCalculator = new KibanaBasePrivilegeCalculator( - kibanaPrivileges, - globalPrivilege, - assignedGlobalBaseActions - ); - - this.effectiveFeaturePrivilegesCalculator = new KibanaFeaturePrivilegeCalculator( - kibanaPrivileges, - globalPrivilege, - assignedGlobalBaseActions, - rankedFeaturePrivileges - ); - } - - public calculateEffectivePrivileges(ignoreAssigned: boolean = false): CalculatedPrivilege[] { - const { kibana = [] } = this.role; - return kibana.map(privilegeSpec => - this.calculateEffectivePrivilege(privilegeSpec, ignoreAssigned) - ); - } - - public calculateAllowedPrivileges(): AllowedPrivilege[] { - const effectivePrivs = this.calculateEffectivePrivileges(true); - return this.allowedPrivilegesCalculator.calculateAllowedPrivileges(effectivePrivs); - } - - private calculateEffectivePrivilege( - privilegeSpec: RoleKibanaPrivilege, - ignoreAssigned: boolean - ): CalculatedPrivilege { - const result: CalculatedPrivilege = { - base: this.effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - privilegeSpec, - ignoreAssigned - ), - feature: {}, - reserved: privilegeSpec._reserved, - }; - - // If calculations wish to ignoreAssigned, then we still need to know what the real effective base privilege is - // without ignoring assigned, in order to calculate the correct feature privileges. - const effectiveBase = ignoreAssigned - ? this.effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(privilegeSpec, false) - : result.base; - - const allFeaturePrivileges = this.kibanaPrivileges.getFeaturePrivileges().getAllPrivileges(); - result.feature = Object.keys(allFeaturePrivileges).reduce((acc, featureId) => { - return { - ...acc, - [featureId]: this.effectiveFeaturePrivilegesCalculator.getMostPermissiveFeaturePrivilege( - privilegeSpec, - effectiveBase, - featureId, - ignoreAssigned - ), - }; - }, {}); - - return result; - } - - private locateGlobalPrivilege(role: Role) { - const spacePrivileges = role.kibana; - return ( - spacePrivileges.find(privileges => isGlobalPrivilegeDefinition(privileges)) || { - spaces: [] as string[], - base: [] as string[], - feature: {}, - } - ); - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator_types.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator_types.ts deleted file mode 100644 index aeaf12d02210a..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator_types.ts +++ /dev/null @@ -1,63 +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. - */ - -/** - * Describes the source of a privilege. - */ -export enum PRIVILEGE_SOURCE { - /** Privilege is assigned directly to the entity */ - SPACE_FEATURE = 10, - - /** Privilege is derived from space base privilege */ - SPACE_BASE = 20, - - /** Privilege is derived from global feature privilege */ - GLOBAL_FEATURE = 30, - - /** Privilege is derived from global base privilege */ - GLOBAL_BASE = 40, -} - -export interface PrivilegeExplanation { - actualPrivilege: string; - actualPrivilegeSource: PRIVILEGE_SOURCE; - isDirectlyAssigned: boolean; - supersededPrivilege?: string; - supersededPrivilegeSource?: PRIVILEGE_SOURCE; - directlyAssignedFeaturePrivilegeMorePermissiveThanBase?: boolean; -} - -export interface CalculatedPrivilege { - base: PrivilegeExplanation; - feature: { - [featureId: string]: PrivilegeExplanation | undefined; - }; - reserved: undefined | string[]; -} - -export interface PrivilegeScenario { - actualPrivilegeSource: PRIVILEGE_SOURCE; - isDirectlyAssigned: boolean; - supersededPrivilege?: string; - supersededPrivilegeSource?: PRIVILEGE_SOURCE; - actions: string[]; - directlyAssignedFeaturePrivilegeMorePermissiveThanBase?: boolean; -} - -export interface AllowedPrivilege { - base: { - privileges: string[]; - canUnassign: boolean; - }; - feature: { - [featureId: string]: - | { - privileges: string[]; - canUnassign: boolean; - } - | undefined; - }; -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts deleted file mode 100644 index febdb64b93d61..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts +++ /dev/null @@ -1,81 +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 { - FeaturesPrivileges, - KibanaPrivileges, - Role, - copyRole, -} from '../../../../../../../common/model'; -import { compareActions } from '../../../../../../../common/privilege_calculator_utils'; -import { KibanaPrivilegeCalculator } from './kibana_privilege_calculator'; - -export class KibanaPrivilegeCalculatorFactory { - /** All feature privileges, sorted from most permissive => least permissive. */ - public readonly rankedFeaturePrivileges: FeaturesPrivileges; - - constructor(private readonly kibanaPrivileges: KibanaPrivileges) { - this.rankedFeaturePrivileges = {}; - const featurePrivilegeSet = kibanaPrivileges.getFeaturePrivileges().getAllPrivileges(); - - Object.entries(featurePrivilegeSet).forEach(([featureId, privileges]) => { - this.rankedFeaturePrivileges[featureId] = privileges.sort((privilege1, privilege2) => { - const privilege1Actions = kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, privilege1); - const privilege2Actions = kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, privilege2); - return compareActions(privilege1Actions, privilege2Actions); - }); - }); - } - - /** - * Creates an KibanaPrivilegeCalculator instance for the specified role. - * @param role - */ - public getInstance(role: Role) { - const roleCopy = copyRole(role); - - this.sortPrivileges(roleCopy); - return new KibanaPrivilegeCalculator( - this.kibanaPrivileges, - roleCopy, - this.rankedFeaturePrivileges - ); - } - - private sortPrivileges(role: Role) { - role.kibana.forEach(privilege => { - privilege.base.sort((privilege1, privilege2) => { - const privilege1Actions = this.kibanaPrivileges - .getSpacesPrivileges() - .getActions(privilege1); - - const privilege2Actions = this.kibanaPrivileges - .getSpacesPrivileges() - .getActions(privilege2); - - return compareActions(privilege1Actions, privilege2Actions); - }); - - Object.entries(privilege.feature).forEach(([featureId, featurePrivs]) => { - featurePrivs.sort((privilege1, privilege2) => { - const privilege1Actions = this.kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, privilege1); - - const privilege2Actions = this.kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, privilege2); - - return compareActions(privilege1Actions, privilege2Actions); - }); - }); - }); - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx index 6487179b1d6e5..8fea0e02f3c8d 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx @@ -6,12 +6,13 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { KibanaPrivileges, Role } from '../../../../../../common/model'; +import { Role } from '../../../../../../common/model'; import { RoleValidator } from '../../validate_role'; import { KibanaPrivilegesRegion } from './kibana_privileges_region'; import { SimplePrivilegeSection } from './simple_privilege_section'; -import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; import { TransformErrorSection } from './transform_error_section'; +import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; +import { KibanaPrivileges } from '../../../model'; const buildProps = (customProps = {}) => { return { @@ -39,12 +40,15 @@ const buildProps = (customProps = {}) => { }, ], features: [], - kibanaPrivileges: new KibanaPrivileges({ - global: {}, - space: {}, - features: {}, - reserved: {}, - }), + kibanaPrivileges: new KibanaPrivileges( + { + global: {}, + space: {}, + features: {}, + reserved: {}, + }, + [] + ), intl: null as any, uiCapabilities: { navLinks: {}, @@ -57,6 +61,7 @@ const buildProps = (customProps = {}) => { editable: true, onChange: jest.fn(), validator: new RoleValidator(), + canCustomizeSubFeaturePrivileges: true, ...customProps, }; }; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx index a4e287632c764..284bcb29f9b6e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx @@ -7,21 +7,20 @@ import React, { Component } from 'react'; import { Capabilities } from 'src/core/public'; import { Space } from '../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../features/public'; -import { KibanaPrivileges, Role } from '../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from './kibana_privilege_calculator'; +import { Role } from '../../../../../../common/model'; import { RoleValidator } from '../../validate_role'; import { CollapsiblePanel } from '../../collapsible_panel'; import { SimplePrivilegeSection } from './simple_privilege_section'; import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; import { TransformErrorSection } from './transform_error_section'; +import { KibanaPrivileges } from '../../../model'; interface Props { role: Role; spacesEnabled: boolean; + canCustomizeSubFeaturePrivileges: boolean; spaces?: Space[]; uiCapabilities: Capabilities; - features: Feature[]; editable: boolean; kibanaPrivileges: KibanaPrivileges; onChange: (role: Role) => void; @@ -42,31 +41,28 @@ export class KibanaPrivilegesRegion extends Component { kibanaPrivileges, role, spacesEnabled, + canCustomizeSubFeaturePrivileges, spaces = [], uiCapabilities, onChange, editable, validator, - features, } = this.props; if (role._transform_error && role._transform_error.includes('kibana')) { return ; } - const privilegeCalculatorFactory = new KibanaPrivilegeCalculatorFactory(kibanaPrivileges); - if (spacesEnabled) { return ( ); @@ -74,11 +70,10 @@ export class KibanaPrivilegesRegion extends Component { return ( ); } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/index.ts similarity index 62% rename from x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/index.ts rename to x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/index.ts index 056a4d3022fc5..121d615c1fc35 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/index.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory'; -export * from './kibana_privilege_calculator_types'; +export { PrivilegeFormCalculator } from './privilege_form_calculator'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts new file mode 100644 index 0000000000000..edf2af918fd04 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts @@ -0,0 +1,833 @@ +/* + * 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 { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { Role } from '../../../../../../../common/model'; +import { PrivilegeFormCalculator } from './privilege_form_calculator'; + +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'unit test role', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana, + }; +}; + +describe('PrivilegeFormCalculator', () => { + describe('#getBasePrivilege', () => { + it(`returns undefined when no base privilege is assigned`, () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getBasePrivilege(0)).toBeUndefined(); + }); + + it(`ignores unknown base privileges`, () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['unknown'], + feature: {}, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getBasePrivilege(0)).toBeUndefined(); + }); + + it(`returns the assigned base privilege`, () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getBasePrivilege(0)).toMatchObject({ + id: 'read', + }); + }); + + it(`returns the most permissive base privilege when multiple are assigned`, () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read', 'all'], + feature: {}, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getBasePrivilege(0)).toMatchObject({ + id: 'all', + }); + }); + }); + + describe('#getDisplayedPrimaryFeaturePrivilegeId', () => { + it('returns undefined when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0) + ).toBeUndefined(); + }); + + it('returns the effective privilege id when a base privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)).toEqual( + 'all' + ); + }); + + it('returns the most permissive assigned primary feature privilege id', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read', 'all', 'minimal_read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)).toEqual( + 'all' + ); + }); + + it('returns the primary version of the minimal privilege id when assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)).toEqual( + 'read' + ); + }); + }); + + describe('#hasCustomizedSubFeaturePrivileges', () => { + it('returns false when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns false when there are no sub-feature privileges assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns false when the assigned sub-features are also granted by other assigned privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'cool_all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns true when the assigned sub-features are not also granted by other assigned privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read', 'cool_all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + + it('returns true when a minimal primary feature privilege is assigned, whose corresponding primary grants sub-feature privileges which are not assigned ', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + + it('returns false when a minimal primary feature privilege is assigned, whose corresponding primary grants sub-feature privileges which are all assigned ', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_read', 'cool_toggle_2'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns true when a minimal primary feature privilege is assigned, whose corresponding primary does not grant all assigned sub-feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [ + 'minimal_read', + 'cool_read', + 'cool_toggle_2', + 'cool_excluded_toggle', + ], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + + it('returns false when a base privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + }); + + describe('#getEffectivePrimaryFeaturePrivilege', () => { + it('returns undefined when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0) + ).toBeUndefined(); + }); + + it('returns the most permissive feature privilege granted by the assigned base privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({ + id: 'read', + }); + }); + + it('returns the most permissive feature privilege granted by the assigned feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: { + with_sub_features: ['read', 'all', 'minimal_all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({ + id: 'all', + }); + }); + + it('prefers `read` primary over `mininal_all`', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_all', 'read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({ + id: 'read', + }); + }); + + it('returns the minimal primary feature privilege when assigned and not superseded', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({ + id: 'minimal_all', + }); + }); + + it('ignores unknown privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['unknown'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0) + ).toBeUndefined(); + }); + }); + + describe('#isIndependentSubFeaturePrivilegeGranted', () => { + it('returns false when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted('with_sub_features', 'cool_toggle_1', 0) + ).toEqual(false); + }); + + it('returns false when an excluded sub-feature privilege is not directly assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted( + 'with_sub_features', + 'cool_excluded_toggle', + 0 + ) + ).toEqual(false); + }); + + it('returns true when an excluded sub-feature privilege is directly assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['all', 'cool_excluded_toggle'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted( + 'with_sub_features', + 'cool_excluded_toggle', + 0 + ) + ).toEqual(true); + }); + + it('returns true when a sub-feature privilege is directly assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['all', 'cool_toggle_1'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted('with_sub_features', 'cool_toggle_1', 0) + ).toEqual(true); + }); + + it('returns true when a sub-feature privilege is inherited', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted('with_sub_features', 'cool_toggle_1', 0) + ).toEqual(true); + }); + }); + + describe('#getSelectedMutuallyExclusiveSubFeaturePrivilege', () => { + it('returns undefined when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + const coolSubFeature = feature.getSubFeatures().find(sf => sf.name === 'Cool Sub Feature')!; + const subFeatureGroup = coolSubFeature + .getPrivilegeGroups() + .find(pg => pg.groupType === 'mutually_exclusive')!; + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getSelectedMutuallyExclusiveSubFeaturePrivilege( + 'with_sub_features', + subFeatureGroup, + 0 + ) + ).toBeUndefined(); + }); + + it('returns the inherited privilege when not directly assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + const coolSubFeature = feature.getSubFeatures().find(sf => sf.name === 'Cool Sub Feature')!; + const subFeatureGroup = coolSubFeature + .getPrivilegeGroups() + .find(pg => pg.groupType === 'mutually_exclusive')!; + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getSelectedMutuallyExclusiveSubFeaturePrivilege( + 'with_sub_features', + subFeatureGroup, + 0 + ) + ).toMatchObject({ + id: 'cool_all', + }); + }); + + it('returns the the most permissive effective privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'cool_read', 'cool_all'], + }, + spaces: ['foo'], + }, + ]); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + const coolSubFeature = feature.getSubFeatures().find(sf => sf.name === 'Cool Sub Feature')!; + const subFeatureGroup = coolSubFeature + .getPrivilegeGroups() + .find(pg => pg.groupType === 'mutually_exclusive')!; + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getSelectedMutuallyExclusiveSubFeaturePrivilege( + 'with_sub_features', + subFeatureGroup, + 0 + ) + ).toMatchObject({ + id: 'cool_all', + }); + }); + }); + + describe('#canCustomizeSubFeaturePrivileges', () => { + it('returns false if no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns false if a base privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns true if a minimal privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['foo'], + }, + ]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + + it('returns true if a primary feature privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + ]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + }); + + describe('#updateSelectedFeaturePrivilegesForCustomization', () => { + it('returns the privileges unmodified if no primary feature privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['some-privilege'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect( + calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, true) + ).toEqual(['some-privilege']); + + expect( + calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, false) + ).toEqual(['some-privilege']); + }); + + it('switches to the minimal privilege when customizing, but explicitly grants the sub-feature privileges which were originally inherited', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect( + calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, true) + ).toEqual(['minimal_read', 'cool_read', 'cool_toggle_2']); + }); + + it('switches to the non-minimal privilege when customizing, removing all other privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_read', 'cool_toggle_2'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect( + calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, false) + ).toEqual(['read']); + }); + }); + + describe('#hasSupersededInheritedPrivileges', () => { + // More exhaustive testing is done at the UI layer: `privilege_space_table.test.tsx` + it('returns false for the global privilege definition', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + { + base: ['all'], + feature: { + with_sub_features: ['read'], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(1)).toEqual(false); + }); + + it('returns false when the global privilege is not more permissive', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(false); + }); + + it('returns true when the global feature privilege is more permissive', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(true); + }); + + it('returns true when the global base privilege is more permissive', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['foo'], + }, + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(true); + }); + + it('returns false when only the global base privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: {}, + spaces: ['foo'], + }, + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts new file mode 100644 index 0000000000000..8cff37f4bd4b0 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts @@ -0,0 +1,303 @@ +/* + * 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 { Role } from '../../../../../../../common/model'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { KibanaPrivileges, SubFeaturePrivilegeGroup } from '../../../../model'; + +/** + * Calculator responsible for determining the displayed and effective privilege values for the following interfaces: + * - and children + * - and children + */ +export class PrivilegeFormCalculator { + constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) {} + + /** + * Returns the assigned base privilege. + * If more than one base privilege is assigned, the most permissive privilege will be returned. + * If no base privileges are assigned, then this will return `undefined`. + * + * @param privilegeIndex the index of the kibana privileges role component + */ + public getBasePrivilege(privilegeIndex: number) { + const entry = this.role.kibana[privilegeIndex]; + + const basePrivileges = this.kibanaPrivileges.getBasePrivileges(entry); + return basePrivileges.find(bp => entry.base.includes(bp.id)); + } + + /** + * Returns the ID of the *displayed* Primary Feature Privilege for the indicated feature and privilege index. + * If the effective primary feature privilege is a "minimal" version, then this returns the corresponding non-minimal version. + * + * @example + * The following kibana privilege entry will return `read`: + * ```ts + * const entry = { + * base: [], + * feature: { + * some_feature: ['minimal_read'], + * } + * } + * ``` + * + * @param featureId the feature id to get the Primary Feature KibanaPrivilege for. + * @param privilegeIndex the index of the kibana privileges role component + */ + public getDisplayedPrimaryFeaturePrivilegeId(featureId: string, privilegeIndex: number) { + return this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex)?.id; + } + + /** + * Determines if the indicated feature has sub-feature privilege assignments which differ from the "displayed" primary feature privilege. + * + * @param featureId the feature id + * @param privilegeIndex the index of the kibana privileges role component + */ + public hasCustomizedSubFeaturePrivileges(featureId: string, privilegeIndex: number) { + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + const displayedPrimary = this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex); + + const formPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ + this.role.kibana[privilegeIndex], + ]); + + return feature.getSubFeaturePrivileges().some(sfp => { + const isGranted = formPrivileges.grantsPrivilege(sfp); + const isGrantedByDisplayedPrimary = displayedPrimary?.grantsPrivilege(sfp) ?? isGranted; + + return isGranted !== isGrantedByDisplayedPrimary; + }); + } + + /** + * Returns the most permissive effective Primary Feature KibanaPrivilege, including the minimal versions. + * + * @param featureId the feature id + * @param privilegeIndex the index of the kibana privileges role component + */ + public getEffectivePrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) { + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + const basePrivilege = this.getBasePrivilege(privilegeIndex); + + const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); + + return feature + .getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true }) + .find(fp => { + return selectedFeaturePrivileges.includes(fp.id) || basePrivilege?.grantsPrivilege(fp); + }); + } + + /** + * Determines if the indicated sub-feature privilege is granted. + * + * @param featureId the feature id + * @param privilegeId the sub feature privilege id + * @param privilegeIndex the index of the kibana privileges role component + */ + public isIndependentSubFeaturePrivilegeGranted( + featureId: string, + privilegeId: string, + privilegeIndex: number + ) { + const kibanaPrivilege = this.role.kibana[privilegeIndex]; + + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + const subFeaturePrivilege = feature + .getSubFeaturePrivileges() + .find(ap => ap.id === privilegeId)!; + + const assignedPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ + kibanaPrivilege, + ]); + + return assignedPrivileges.grantsPrivilege(subFeaturePrivilege); + } + + /** + * Returns the most permissive effective privilege within the indicated mutually-exclusive sub feature privilege group. + * + * @param featureId the feature id + * @param subFeatureGroup the mutually-exclusive sub feature group + * @param privilegeIndex the index of the kibana privileges role component + */ + public getSelectedMutuallyExclusiveSubFeaturePrivilege( + featureId: string, + subFeatureGroup: SubFeaturePrivilegeGroup, + privilegeIndex: number + ) { + const kibanaPrivilege = this.role.kibana[privilegeIndex]; + const assignedPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ + kibanaPrivilege, + ]); + + return subFeatureGroup.privileges.find(p => { + return assignedPrivileges.grantsPrivilege(p); + }); + } + + /** + * Determines if the indicated feature is capable of having its sub-feature privileges customized. + * + * @param featureId the feature id + * @param privilegeIndex the index of the kibana privileges role component + */ + public canCustomizeSubFeaturePrivileges(featureId: string, privilegeIndex: number) { + const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + return feature + .getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true }) + .some(apfp => selectedFeaturePrivileges.includes(apfp.id)); + } + + /** + * Returns an updated set of feature privileges based on the toggling of the "Customize sub-feature privileges" control. + * + * @param featureId the feature id + * @param privilegeIndex the index of the kibana privileges role component + * @param willBeCustomizing flag indicating if this feature is about to have its sub-feature privileges customized or not + */ + public updateSelectedFeaturePrivilegesForCustomization( + featureId: string, + privilegeIndex: number, + willBeCustomizing: boolean + ) { + const primary = this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex); + const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); + + if (!primary) { + return selectedFeaturePrivileges; + } + + const nextPrivileges = []; + + if (willBeCustomizing) { + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + const startingPrivileges = feature + .getSubFeaturePrivileges() + .filter(ap => primary.grantsPrivilege(ap)) + .map(p => p.id); + + nextPrivileges.push(primary.getMinimalPrivilegeId(), ...startingPrivileges); + } else { + nextPrivileges.push(primary.id); + } + + return nextPrivileges; + } + + /** + * Determines if the indicated privilege entry is less permissive than the configured "global" entry for the role. + * @param privilegeIndex the index of the kibana privileges role component + */ + public hasSupersededInheritedPrivileges(privilegeIndex: number) { + const global = this.locateGlobalPrivilege(this.role); + + const entry = this.role.kibana[privilegeIndex]; + + if (isGlobalPrivilegeDefinition(entry) || !global) { + return false; + } + + const globalPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ + global, + ]); + + const formPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([entry]); + + const hasAssignedBasePrivileges = this.kibanaPrivileges + .getBasePrivileges(entry) + .some(base => entry.base.includes(base.id)); + + const featuresWithDirectlyAssignedPrivileges = this.kibanaPrivileges + .getSecuredFeatures() + .filter(feature => + feature + .getAllPrivileges() + .some(privilege => entry.feature[feature.id]?.includes(privilege.id)) + ); + + const hasSupersededBasePrivileges = + hasAssignedBasePrivileges && + this.kibanaPrivileges + .getBasePrivileges(entry) + .some( + privilege => + globalPrivileges.grantsPrivilege(privilege) && + !formPrivileges.grantsPrivilege(privilege) + ); + + const hasSupersededFeaturePrivileges = featuresWithDirectlyAssignedPrivileges.some(feature => + feature + .getAllPrivileges() + .some(fp => globalPrivileges.grantsPrivilege(fp) && !formPrivileges.grantsPrivilege(fp)) + ); + + return hasSupersededBasePrivileges || hasSupersededFeaturePrivileges; + } + + /** + * Returns the *displayed* Primary Feature Privilege for the indicated feature and privilege index. + * If the effective primary feature privilege is a "minimal" version, then this returns the corresponding non-minimal version. + * + * @example + * The following kibana privilege entry will return `read`: + * ```ts + * const entry = { + * base: [], + * feature: { + * some_feature: ['minimal_read'], + * } + * } + * ``` + * + * @param featureId the feature id to get the Primary Feature KibanaPrivilege for. + * @param privilegeIndex the index of the kibana privileges role component + */ + private getDisplayedPrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) { + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + const basePrivilege = this.getBasePrivilege(privilegeIndex); + + const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); + + return feature.getPrimaryFeaturePrivileges().find(fp => { + const correspondingMinimalPrivilegeId = fp.getMinimalPrivilegeId(); + + const correspendingMinimalPrivilege = feature + .getMinimalFeaturePrivileges() + .find(mp => mp.id === correspondingMinimalPrivilegeId)!; + + // There are two cases where the minimal privileges aren't available: + // 1. The feature has no registered sub-features + // 2. Sub-feature privileges cannot be customized. When this is the case, the minimal privileges aren't registered with ES, + // so they end up represented in the UI as an empty privilege. Empty privileges cannot be granted other privileges, so if we + // encounter a minimal privilege that isn't granted by it's correspending primary, then we know we've encountered this scenario. + const hasMinimalPrivileges = + feature.subFeatures.length > 0 && fp.grantsPrivilege(correspendingMinimalPrivilege); + return ( + selectedFeaturePrivileges.includes(fp.id) || + (hasMinimalPrivileges && + selectedFeaturePrivileges.includes(correspondingMinimalPrivilegeId)) || + basePrivilege?.grantsPrivilege(fp) + ); + }); + } + + private getSelectedFeaturePrivileges(featureId: string, privilegeIndex: number) { + return this.role.kibana[privilegeIndex].feature[featureId] ?? []; + } + + private locateGlobalPrivilege(role: Role) { + return role.kibana.find(entry => isGlobalPrivilegeDefinition(entry)); + } +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/__fixtures__/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/__fixtures__/index.ts new file mode 100644 index 0000000000000..63b38b6967575 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/__fixtures__/index.ts @@ -0,0 +1,129 @@ +/* + * 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 { ReactWrapper } from 'enzyme'; + +import { EuiTableRow } from '@elastic/eui'; + +import { findTestSubject } from 'test_utils/find_test_subject'; +import { Role, RoleKibanaPrivilege } from '../../../../../../../../common/model'; +import { PrivilegeSummaryExpandedRow } from '../privilege_summary_expanded_row'; +import { FeatureTableCell } from '../../feature_table_cell'; + +interface DisplayedFeaturePrivileges { + [featureId: string]: { + [spaceGroup: string]: { + primaryFeaturePrivilege: string; + subFeaturesPrivileges: { + [subFeatureName: string]: string[]; + }; + hasCustomizedSubFeaturePrivileges: boolean; + }; + }; +} + +const getSpaceKey = (entry: RoleKibanaPrivilege) => entry.spaces.join(', '); + +export function getDisplayedFeaturePrivileges( + wrapper: ReactWrapper, + role: Role +): DisplayedFeaturePrivileges { + const allExpanderButtons = findTestSubject(wrapper, 'expandPrivilegeSummaryRow'); + allExpanderButtons.forEach(button => button.simulate('click')); + + // each expanded row renders its own `EuiTableRow`, so there are 2 rows + // for each feature: one for the primary feature privilege, and one for the sub privilege form + const rows = wrapper.find(EuiTableRow); + + return rows.reduce((acc, row) => { + const expandedRow = row.find(PrivilegeSummaryExpandedRow); + if (expandedRow.length > 0) { + return { + ...acc, + ...getDisplayedSubFeaturePrivileges(acc, expandedRow, role), + }; + } else { + const feature = row.find(FeatureTableCell).props().feature; + + const primaryFeaturePrivileges = findTestSubject(row, 'privilegeColumn'); + + expect(primaryFeaturePrivileges).toHaveLength(role.kibana.length); + + acc[feature.id] = acc[feature.id] ?? {}; + + primaryFeaturePrivileges.forEach((primary, index) => { + const key = getSpaceKey(role.kibana[index]); + + acc[feature.id][key] = { + ...acc[feature.id][key], + primaryFeaturePrivilege: primary.text().trim(), + hasCustomizedSubFeaturePrivileges: + findTestSubject(primary, 'additionalPrivilegesGranted').length > 0, + }; + }); + + return acc; + } + }, {} as DisplayedFeaturePrivileges); +} + +function getDisplayedSubFeaturePrivileges( + displayedFeatures: DisplayedFeaturePrivileges, + expandedRow: ReactWrapper, + role: Role +) { + const { feature } = expandedRow.props(); + + const subFeatureEntries = findTestSubject(expandedRow as ReactWrapper, 'subFeatureEntry'); + + displayedFeatures[feature.id] = displayedFeatures[feature.id] ?? {}; + + subFeatureEntries.forEach(subFeatureEntry => { + const subFeatureName = findTestSubject(subFeatureEntry, 'subFeatureName').text(); + + const entryElements = findTestSubject(subFeatureEntry as ReactWrapper, 'entry', '|='); + + expect(entryElements).toHaveLength(role.kibana.length); + + role.kibana.forEach((entry, index) => { + const key = getSpaceKey(entry); + const element = findTestSubject(expandedRow as ReactWrapper, `entry-${index}`); + + const independentPrivileges = element + .find('EuiFlexGroup[data-test-subj="independentPrivilege"]') + .reduce((acc2, flexGroup) => { + const privilegeName = findTestSubject(flexGroup, 'privilegeName').text(); + const isGranted = flexGroup.exists('EuiIconTip[type="check"]'); + if (isGranted) { + return [...acc2, privilegeName]; + } + return acc2; + }, [] as string[]); + + const mutuallyExclusivePrivileges = element + .find('EuiFlexGroup[data-test-subj="mutexPrivilege"]') + .reduce((acc2, flexGroup) => { + const privilegeName = findTestSubject(flexGroup, 'privilegeName').text(); + const isGranted = flexGroup.exists('EuiIconTip[type="check"]'); + + if (isGranted) { + return [...acc2, privilegeName]; + } + return acc2; + }, [] as string[]); + + displayedFeatures[feature.id][key] = { + ...displayedFeatures[feature.id][key], + subFeaturesPrivileges: { + ...displayedFeatures[feature.id][key].subFeaturesPrivileges, + [subFeatureName]: [...independentPrivileges, ...mutuallyExclusivePrivileges], + }, + }; + }); + }); + + return displayedFeatures; +} diff --git a/x-pack/plugins/security/common/model/kibana_privileges/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/index.ts similarity index 81% rename from x-pack/plugins/security/common/model/kibana_privileges/index.ts rename to x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/index.ts index ab9baa1356c4b..5f7dc0d99654e 100644 --- a/x-pack/plugins/security/common/model/kibana_privileges/index.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { KibanaPrivileges } from './kibana_privileges'; +export { PrivilegeSummary } from './privilege_summary'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx new file mode 100644 index 0000000000000..85144d37ce754 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { PrivilegeSummary } from '.'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { PrivilegeSummaryTable } from './privilege_summary_table'; + +const createRole = (roleKibanaPrivileges: RoleKibanaPrivilege[]) => ({ + name: 'some-role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: roleKibanaPrivileges, +}); + +const spaces = [ + { + id: 'default', + name: 'Default Space', + disabledFeatures: [], + }, +]; + +describe('PrivilegeSummary', () => { + it('initially renders a button', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['default'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + expect(findTestSubject(wrapper, 'viewPrivilegeSummaryButton')).toHaveLength(1); + expect(wrapper.find(PrivilegeSummaryTable)).toHaveLength(0); + }); + + it('clicking the button renders the privilege summary table', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['default'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'viewPrivilegeSummaryButton').simulate('click'); + expect(wrapper.find(PrivilegeSummaryTable)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx new file mode 100644 index 0000000000000..e0889d91d759a --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx @@ -0,0 +1,73 @@ +/* + * 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, Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiModal, + EuiButtonEmpty, + EuiOverlayMask, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiButton, +} from '@elastic/eui'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { Role } from '../../../../../../../common/model'; +import { PrivilegeSummaryTable } from './privilege_summary_table'; +import { KibanaPrivileges } from '../../../../model'; + +interface Props { + role: Role; + spaces: Space[]; + kibanaPrivileges: KibanaPrivileges; + canCustomizeSubFeaturePrivileges: boolean; +} +export const PrivilegeSummary = (props: Props) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + setIsOpen(true)} data-test-subj="viewPrivilegeSummaryButton"> + + + {isOpen && ( + + setIsOpen(false)} maxWidth={false}> + + + + + + + + + + setIsOpen(false)}> + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts new file mode 100644 index 0000000000000..6163a6ec7ba23 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts @@ -0,0 +1,338 @@ +/* + * 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 { Role } from '../../../../../../../common/model'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { PrivilegeSummaryCalculator } from './privilege_summary_calculator'; + +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'unit test role', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana, + }; +}; +describe('PrivilegeSummaryCalculator', () => { + describe('#getEffectiveFeaturePrivileges', () => { + it('returns an empty privilege set when nothing is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + }); + }); + + it('calculates effective privileges when inherited from the global privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: {}, + spaces: ['foo'], + }, + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: ['cool_all', 'cool_read', 'cool_toggle_1', 'cool_toggle_2'], + }, + }); + }); + + it('calculates effective privileges when there are non-superseded sub-feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['cool_excluded_toggle'], + }, + spaces: ['foo'], + }, + { + base: ['all'], + feature: { + with_sub_features: [], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: true, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [ + 'cool_all', + 'cool_read', + 'cool_toggle_1', + 'cool_toggle_2', + 'cool_excluded_toggle', + ], + }, + }); + }); + + it('calculates privileges for all features for a space entry', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: { + excluded_from_base: ['all'], + no_sub_features: ['read'], + with_excluded_sub_features: ['all'], + with_sub_features: ['minimal_read', 'cool_excluded_toggle'], + }, + spaces: ['foo'], + }, + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: ['cool_all', 'cool_read', 'cool_toggle_1', 'cool_toggle_2'], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: true, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [ + 'cool_all', + 'cool_read', + 'cool_toggle_1', + 'cool_toggle_2', + 'cool_excluded_toggle', + ], + }, + }); + }); + + it('calculates privileges for all features for a global entry', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: ['cool_all', 'cool_read', 'cool_toggle_1', 'cool_toggle_2'], + }, + }); + }); + + it('calculates privileges for a single feature at a space entry', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_excluded_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + }); + }); + + it('calculates privileges for a single feature at the global entry', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_excluded_sub_features: ['all'], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts new file mode 100644 index 0000000000000..27ed8c443045a --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { KibanaPrivileges, PrimaryFeaturePrivilege, SecuredFeature } from '../../../../model'; +import { PrivilegeCollection } from '../../../../model/privilege_collection'; + +export interface EffectiveFeaturePrivileges { + [featureId: string]: { + primary?: PrimaryFeaturePrivilege; + subFeature: string[]; + hasCustomizedSubFeaturePrivileges: boolean; + }; +} +export class PrivilegeSummaryCalculator { + constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) {} + + public getEffectiveFeaturePrivileges(entry: RoleKibanaPrivilege): EffectiveFeaturePrivileges { + const assignedPrivileges = this.collectAssignedPrivileges(entry); + + const features = this.kibanaPrivileges.getSecuredFeatures(); + + return features.reduce((acc, feature) => { + const displayedPrimaryFeaturePrivilege = this.getDisplayedPrimaryFeaturePrivilege( + assignedPrivileges, + feature + ); + + const effectiveSubPrivileges = feature + .getSubFeaturePrivileges() + .filter(ap => assignedPrivileges.grantsPrivilege(ap)); + + const hasCustomizedSubFeaturePrivileges = this.hasCustomizedSubFeaturePrivileges( + feature, + displayedPrimaryFeaturePrivilege, + entry + ); + + return { + ...acc, + [feature.id]: { + primary: displayedPrimaryFeaturePrivilege, + hasCustomizedSubFeaturePrivileges, + subFeature: effectiveSubPrivileges.map(p => p.id), + }, + }; + }, {} as EffectiveFeaturePrivileges); + } + + private hasCustomizedSubFeaturePrivileges( + feature: SecuredFeature, + displayedPrimaryFeaturePrivilege: PrimaryFeaturePrivilege | undefined, + entry: RoleKibanaPrivilege + ) { + const formPrivileges = this.collectAssignedPrivileges(entry); + + return feature.getSubFeaturePrivileges().some(sfp => { + const isGranted = formPrivileges.grantsPrivilege(sfp); + const isGrantedByDisplayedPrimary = + displayedPrimaryFeaturePrivilege?.grantsPrivilege(sfp) ?? isGranted; + + // if displayed primary is derived from base, then excluded sub-feature-privs should not count. + return isGranted !== isGrantedByDisplayedPrimary; + }); + } + + private getDisplayedPrimaryFeaturePrivilege( + assignedPrivileges: PrivilegeCollection, + feature: SecuredFeature + ) { + const primaryFeaturePrivileges = feature.getPrimaryFeaturePrivileges(); + const minimalPrimaryFeaturePrivileges = feature.getMinimalFeaturePrivileges(); + + const hasMinimalPrivileges = feature.subFeatures.length > 0; + + const effectivePrivilege = primaryFeaturePrivileges.find(pfp => { + const isPrimaryGranted = assignedPrivileges.grantsPrivilege(pfp); + if (!isPrimaryGranted && hasMinimalPrivileges) { + const correspondingMinimal = minimalPrimaryFeaturePrivileges.find( + mpfp => mpfp.id === pfp.getMinimalPrivilegeId() + )!; + + return assignedPrivileges.grantsPrivilege(correspondingMinimal); + } + return isPrimaryGranted; + }); + + return effectivePrivilege; + } + + private collectAssignedPrivileges(entry: RoleKibanaPrivilege) { + if (isGlobalPrivilegeDefinition(entry)) { + return this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([entry]); + } + + const globalPrivilege = this.locateGlobalPrivilege(this.role); + return this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges( + globalPrivilege ? [globalPrivilege, entry] : [entry] + ); + } + + private locateGlobalPrivilege(role: Role) { + return role.kibana.find(entry => isGlobalPrivilegeDefinition(entry)); + } +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx new file mode 100644 index 0000000000000..3283f7a58a27c --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx @@ -0,0 +1,131 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIconTip } from '@elastic/eui'; +import { SecuredFeature, SubFeaturePrivilegeGroup, SubFeaturePrivilege } from '../../../../model'; +import { EffectiveFeaturePrivileges } from './privilege_summary_calculator'; + +interface Props { + feature: SecuredFeature; + effectiveFeaturePrivileges: Array; +} + +export const PrivilegeSummaryExpandedRow = (props: Props) => { + return ( + + {props.feature.getSubFeatures().map(subFeature => { + return ( + + + + + {subFeature.name} + + + {props.effectiveFeaturePrivileges.map((privs, index) => { + return ( + + {subFeature.getPrivilegeGroups().map(renderPrivilegeGroup(privs.subFeature))} + + ); + })} + + + ); + })} + + ); + + function renderPrivilegeGroup(effectiveSubFeaturePrivileges: string[]) { + return (privilegeGroup: SubFeaturePrivilegeGroup, index: number) => { + switch (privilegeGroup.groupType) { + case 'independent': + return renderIndependentPrivilegeGroup( + effectiveSubFeaturePrivileges, + privilegeGroup, + index + ); + case 'mutually_exclusive': + return renderMutuallyExclusivePrivilegeGroup( + effectiveSubFeaturePrivileges, + privilegeGroup, + index + ); + default: + throw new Error(`Unsupported privilege group type: ${privilegeGroup.groupType}`); + } + }; + } + + function renderIndependentPrivilegeGroup( + effectiveSubFeaturePrivileges: string[], + privilegeGroup: SubFeaturePrivilegeGroup, + index: number + ) { + return ( +
+ {privilegeGroup.privileges.map((privilege: SubFeaturePrivilege) => { + const isGranted = effectiveSubFeaturePrivileges.includes(privilege.id); + return ( + + + + + + + {privilege.name} + + + + ); + })} +
+ ); + } + + function renderMutuallyExclusivePrivilegeGroup( + effectiveSubFeaturePrivileges: string[], + privilegeGroup: SubFeaturePrivilegeGroup, + index: number + ) { + const firstSelectedPrivilege = privilegeGroup.privileges.find(p => + effectiveSubFeaturePrivileges.includes(p.id) + )?.name; + + return ( + + + + + + + {firstSelectedPrivilege ?? 'None'} + + + + ); + } +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx new file mode 100644 index 0000000000000..0498f099b536b --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx @@ -0,0 +1,922 @@ +/* + * 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 { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { PrivilegeSummaryTable } from './privilege_summary_table'; +import { RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { getDisplayedFeaturePrivileges } from './__fixtures__'; + +const createRole = (roleKibanaPrivileges: RoleKibanaPrivilege[]) => ({ + name: 'some-role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: roleKibanaPrivileges, +}); + +const spaces = [ + { + id: 'default', + name: 'Default Space', + disabledFeatures: [], + }, + { + id: 'space-1', + name: 'First Space', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Second Space', + disabledFeatures: [], + }, +]; + +const maybeExpectSubFeaturePrivileges = (expect: boolean, subFeaturesPrivileges: unknown) => { + return expect ? { subFeaturesPrivileges } : {}; +}; + +const expectNoPrivileges = (displayedPrivileges: any, expectSubFeatures: boolean) => { + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(expectSubFeatures, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(expectSubFeatures, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(expectSubFeatures, { + 'Cool Sub Feature': [], + }), + }, + }, + }); +}; + +describe('PrivilegeSummaryTable', () => { + [true, false].forEach(allowSubFeaturePrivileges => { + describe(`when sub feature privileges are ${ + allowSubFeaturePrivileges ? 'allowed' : 'disallowed' + }`, () => { + it('ignores unknown base privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['idk_what_this_means'], + feature: {}, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expectNoPrivileges(displayedPrivileges, allowSubFeaturePrivileges); + }); + + it('ignores unknown feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['this_doesnt_exist_either'], + }, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expectNoPrivileges(displayedPrivileges, allowSubFeaturePrivileges); + }); + + it('ignores unknown features', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + unknown_feature: ['this_doesnt_exist_either'], + }, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expectNoPrivileges(displayedPrivileges, allowSubFeaturePrivileges); + }); + + it('renders effective privileges for the global base privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for a global feature privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for the space base privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for a space feature privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + }, + with_excluded_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'Read' : 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + }); + }); + + it('renders effective privileges for global base + space base privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: ['all'], + feature: {}, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'Read'], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for global base + space feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all'], + }, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'Read'], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for global feature + space base privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all'], + }, + spaces: ['*'], + }, + { + base: ['read'], + feature: {}, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'Read' : 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['All'], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for global feature + space feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all'], + }, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'Read' : 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['All'], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for a complex setup', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: ['read', 'all'], + feature: {}, + spaces: ['default'], + }, + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + with_excluded_sub_features: ['all', 'cool_toggle_1'], + no_sub_features: ['all'], + excluded_from_base: ['minimal_all', 'cool_toggle_1'], + }, + spaces: ['space-1', 'space-2'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + default: { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'space-1, space-2': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'All' : 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2'], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + default: { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + 'space-1, space-2': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + default: { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'space-1, space-2': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': ['Cool toggle 1'], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'Read'], + }), + }, + default: { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + 'space-1, space-2': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'Read'], + }), + }, + }, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx new file mode 100644 index 0000000000000..e04ca36b6d193 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx @@ -0,0 +1,174 @@ +/* + * 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 } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiInMemoryTable, + EuiBasicTableColumn, + EuiButtonIcon, + EuiIcon, + EuiIconTip, +} from '@elastic/eui'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { FeatureTableCell } from '../feature_table_cell'; +import { SpaceColumnHeader } from './space_column_header'; +import { PrivilegeSummaryExpandedRow } from './privilege_summary_expanded_row'; +import { SecuredFeature, KibanaPrivileges } from '../../../../model'; +import { + PrivilegeSummaryCalculator, + EffectiveFeaturePrivileges, +} from './privilege_summary_calculator'; + +interface Props { + role: Role; + spaces: Space[]; + kibanaPrivileges: KibanaPrivileges; + canCustomizeSubFeaturePrivileges: boolean; +} + +function getColumnKey(entry: RoleKibanaPrivilege) { + return `privilege_entry_${entry.spaces.join('|')}`; +} + +export const PrivilegeSummaryTable = (props: Props) => { + const [expandedFeatures, setExpandedFeatures] = useState([]); + + const calculator = new PrivilegeSummaryCalculator(props.kibanaPrivileges, props.role); + + const toggleExpandedFeature = (featureId: string) => { + if (expandedFeatures.includes(featureId)) { + setExpandedFeatures(expandedFeatures.filter(ef => ef !== featureId)); + } else { + setExpandedFeatures([...expandedFeatures, featureId]); + } + }; + + const featureColumn: EuiBasicTableColumn = { + name: 'Feature', + field: 'feature', + render: (feature: any) => { + return ; + }, + }; + const rowExpanderColumn: EuiBasicTableColumn = { + align: 'right', + width: '40px', + isExpander: true, + field: 'featureId', + name: '', + render: (featureId: string, record: any) => { + const feature = record.feature as SecuredFeature; + const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0; + if (!hasSubFeaturePrivileges) { + return null; + } + return ( + toggleExpandedFeature(featureId)} + data-test-subj={`expandPrivilegeSummaryRow`} + aria-label={expandedFeatures.includes(featureId) ? 'Collapse' : 'Expand'} + iconType={expandedFeatures.includes(featureId) ? 'arrowUp' : 'arrowDown'} + /> + ); + }, + }; + + const rawKibanaPrivileges = [...props.role.kibana].sort((entry1, entry2) => { + if (isGlobalPrivilegeDefinition(entry1)) { + return -1; + } + if (isGlobalPrivilegeDefinition(entry2)) { + return 1; + } + return 0; + }); + const privilegeColumns = rawKibanaPrivileges.map(entry => { + const key = getColumnKey(entry); + return { + name: , + field: key, + render: (kibanaPrivilege: EffectiveFeaturePrivileges, record: { featureId: string }) => { + const { primary, hasCustomizedSubFeaturePrivileges } = kibanaPrivilege[record.featureId]; + let iconTip = null; + if (hasCustomizedSubFeaturePrivileges) { + iconTip = ( + + + + } + /> + ); + } else { + iconTip = ; + } + return ( + + {primary?.name ?? 'None'} {iconTip} + + ); + }, + }; + }); + + const columns: Array> = []; + if (props.canCustomizeSubFeaturePrivileges) { + columns.push(rowExpanderColumn); + } + columns.push(featureColumn, ...privilegeColumns); + + const privileges = rawKibanaPrivileges.reduce((acc, entry) => { + return { + ...acc, + [getColumnKey(entry)]: calculator.getEffectiveFeaturePrivileges(entry), + }; + }, {} as Record); + + const items = props.kibanaPrivileges.getSecuredFeatures().map(feature => { + return { + feature, + featureId: feature.id, + ...privileges, + }; + }); + + return ( + { + return { + 'data-test-subj': `summaryTableRow-${record.featureId}`, + }; + }} + itemIdToExpandedRowMap={expandedFeatures.reduce((acc, featureId) => { + return { + ...acc, + [featureId]: ( + p[featureId])} + /> + ), + }; + }, {})} + /> + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.test.tsx new file mode 100644 index 0000000000000..b691056528498 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.test.tsx @@ -0,0 +1,123 @@ +/* + * 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 { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { SpaceColumnHeader } from './space_column_header'; +import { SpacesPopoverList } from '../../../spaces_popover_list'; +import { SpaceAvatar } from '../../../../../../../../spaces/public'; + +const spaces = [ + { + id: '*', + name: 'Global', + disabledFeatures: [], + }, + { + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Space 2', + disabledFeatures: [], + }, + { + id: 'space-3', + name: 'Space 3', + disabledFeatures: [], + }, + { + id: 'space-4', + name: 'Space 4', + disabledFeatures: [], + }, + { + id: 'space-5', + name: 'Space 5', + disabledFeatures: [], + }, +]; + +describe('SpaceColumnHeader', () => { + it('renders the Global privilege definition with a special label and popover control', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(SpacesPopoverList)).toHaveLength(1); + // Snapshot includes space avatar (The first "G"), followed by the "Global" label, + // followed by the (all spaces) text as part of the SpacesPopoverList + expect(wrapper.text()).toMatchInlineSnapshot(`"G Global(all spaces)"`); + }); + + it('renders a placeholder space when the requested space no longer exists', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(SpacesPopoverList)).toHaveLength(0); + + const avatars = wrapper.find(SpaceAvatar); + expect(avatars).toHaveLength(3); + + expect(wrapper.text()).toMatchInlineSnapshot(`"S1 m S3 "`); + }); + + it('renders a space privilege definition with an avatar for each space in the group', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(SpacesPopoverList)).toHaveLength(0); + + const avatars = wrapper.find(SpaceAvatar); + expect(avatars).toHaveLength(4); + + expect(wrapper.text()).toMatchInlineSnapshot(`"S1 S2 S3 S4 "`); + }); + + it('renders a space privilege definition with an avatar for the first 4 spaces in the group, with the popover control showing the rest', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(SpacesPopoverList)).toHaveLength(1); + + const avatars = wrapper.find(SpaceAvatar); + expect(avatars).toHaveLength(4); + + expect(wrapper.text()).toMatchInlineSnapshot(`"S1 S2 S3 S4 +1 more"`); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx new file mode 100644 index 0000000000000..8ed9bb449b595 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { Space, SpaceAvatar } from '../../../../../../../../spaces/public'; +import { RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { SpacesPopoverList } from '../../../spaces_popover_list'; + +interface Props { + spaces: Space[]; + entry: RoleKibanaPrivilege; +} + +const SPACES_DISPLAY_COUNT = 4; + +export const SpaceColumnHeader = (props: Props) => { + const isGlobal = isGlobalPrivilegeDefinition(props.entry); + const entrySpaces = props.entry.spaces.map(spaceId => { + return ( + props.spaces.find(s => s.id === spaceId) ?? { + id: spaceId, + name: spaceId, + disabledFeatures: [], + } + ); + }); + return ( +
+ {entrySpaces.slice(0, SPACES_DISPLAY_COUNT).map(space => { + return ( + + {' '} + {isGlobal && ( + + +
+ s.id !== '*')} + buttonText={i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink', + { + defaultMessage: '(all spaces)', + } + )} + /> +
+ )} +
+ ); + })} + {entrySpaces.length > SPACES_DISPLAY_COUNT && ( + +
+ +
+ )} +
+ ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/__snapshots__/simple_privilege_section.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/__snapshots__/simple_privilege_section.test.tsx.snap index 4d8f590f286ae..7873e47d2e0ff 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/__snapshots__/simple_privilege_section.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/__snapshots__/simple_privilege_section.test.tsx.snap @@ -2,153 +2,159 @@ exports[` renders without crashing 1`] = ` - - -

- } - title={ -

- -

- } + - - + +

+ +

+
+ + + - + hasChildLabel={true} + hasEmptyLabelSpace={false} + label={ + + } + labelType="label" + > + + + + +

+ +

+ , + "inputDisplay": -
-

- -

- , - "inputDisplay": - - , - "value": "none", - }, - Object { - "dropdownDisplay": - + , + "value": "none", + }, + Object { + "dropdownDisplay": + + + +

+ +

+
, + "inputDisplay": -
-

- -

- , - "inputDisplay": - - , - "value": "custom", - }, - Object { - "dropdownDisplay": - + , + "value": "custom", + }, + Object { + "dropdownDisplay": + + + +

+ +

+
, + "inputDisplay": -
-

- -

- , - "inputDisplay": - - , - "value": "read", - }, - Object { - "dropdownDisplay": - + , + "value": "read", + }, + Object { + "dropdownDisplay": + + + +

+ +

+
, + "inputDisplay": -
-

- -

- , - "inputDisplay": - - , - "value": "all", - }, - ] - } - valueOfSelected="none" - /> -
-
+ , + "value": "all", + }, + ] + } + valueOfSelected="none" + /> + + +
`; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx index db1e3cfd61621..7ecf32ee45b85 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx @@ -7,24 +7,53 @@ import { EuiButtonGroup, EuiButtonGroupProps, EuiComboBox, EuiSuperSelect } from '@elastic/eui'; import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; +import { Role } from '../../../../../../../common/model'; import { SimplePrivilegeSection } from './simple_privilege_section'; import { UnsupportedSpacePrivilegesWarning } from './unsupported_space_privileges_warning'; +import { KibanaPrivileges, SecuredFeature } from '../../../../model'; const buildProps = (customProps: any = {}) => { - const kibanaPrivileges = new KibanaPrivileges({ - features: { - feature1: { - all: ['*'], - read: ['read'], + const features = [ + new SecuredFeature({ + id: 'feature1', + name: 'Feature 1', + app: ['app'], + icon: 'spacesApp', + privileges: { + all: { + app: ['app'], + savedObject: { + all: ['foo'], + read: [], + }, + ui: ['app-ui'], + }, + read: { + app: ['app'], + savedObject: { + all: [], + read: [], + }, + ui: ['app-ui'], + }, }, + }), + ] as SecuredFeature[]; + + const kibanaPrivileges = new KibanaPrivileges( + { + features: { + feature1: { + all: ['*'], + read: ['read'], + }, + }, + global: {}, + space: {}, + reserved: {}, }, - global: {}, - space: {}, - reserved: {}, - }); + features + ); const role = { name: '', @@ -40,34 +69,9 @@ const buildProps = (customProps: any = {}) => { return { editable: true, kibanaPrivileges, - privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory(kibanaPrivileges), - features: [ - { - id: 'feature1', - name: 'Feature 1', - app: ['app'], - icon: 'spacesApp', - privileges: { - all: { - app: ['app'], - savedObject: { - all: ['foo'], - read: [], - }, - ui: ['app-ui'], - }, - read: { - app: ['app'], - savedObject: { - all: [], - read: [], - }, - ui: ['app-ui'], - }, - }, - }, - ] as Feature[], + features, onChange: jest.fn(), + canCustomizeSubFeaturePrivileges: true, ...customProps, role, }; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx index 2221fc6bab279..d68d43e8089c7 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx @@ -6,34 +6,28 @@ import { EuiComboBox, - EuiDescribedFormGroup, EuiFormRow, EuiSuperSelect, EuiText, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment } from 'react'; - -import { Feature } from '../../../../../../../../features/public'; -import { - KibanaPrivileges, - Role, - RoleKibanaPrivilege, - copyRole, -} from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; +import { Role, RoleKibanaPrivilege, copyRole } from '../../../../../../../common/model'; import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; import { CUSTOM_PRIVILEGE_VALUE, NO_PRIVILEGE_VALUE } from '../constants'; import { FeatureTable } from '../feature_table'; import { UnsupportedSpacePrivilegesWarning } from './unsupported_space_privileges_warning'; +import { KibanaPrivileges } from '../../../../model'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; interface Props { role: Role; kibanaPrivileges: KibanaPrivileges; - privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory; - features: Feature[]; onChange: (role: Role) => void; editable: boolean; + canCustomizeSubFeaturePrivileges: boolean; } interface State { @@ -58,20 +52,14 @@ export class SimplePrivilegeSection extends Component { public render() { const kibanaPrivilege = this.getDisplayedBasePrivilege(); - const privilegeCalculator = this.props.privilegeCalculatorFactory.getInstance(this.props.role); - - const calculatedPrivileges = privilegeCalculator.calculateEffectivePrivileges()[ - this.state.globalPrivsIndex - ]; - - const allowedPrivileges = privilegeCalculator.calculateAllowedPrivileges()[ - this.state.globalPrivsIndex - ]; + const reservedPrivileges = this.props.role.kibana[this.state.globalPrivsIndex]?._reserved ?? []; - const hasReservedPrivileges = - calculatedPrivileges && - calculatedPrivileges.reserved != null && - calculatedPrivileges.reserved.length > 0; + const title = ( + + ); const description = (

@@ -84,162 +72,159 @@ export class SimplePrivilegeSection extends Component { return ( - - - - } - description={description} - > - - {hasReservedPrivileges ? ( - ({ - label: privilege, - }))} - isDisabled - /> - ) : ( - - - - ), - dropdownDisplay: ( - - + + + + {description} + + + + + {reservedPrivileges.length > 0 ? ( + ({ label: rp }))} + isDisabled + /> + ) : ( + - -

- -

- - ), - }, - { - value: CUSTOM_PRIVILEGE_VALUE, - inputDisplay: ( - - - - ), - dropdownDisplay: ( - - + + ), + dropdownDisplay: ( + + + + +

+ +

+
+ ), + }, + { + value: CUSTOM_PRIVILEGE_VALUE, + inputDisplay: ( + -
-

+ + ), + dropdownDisplay: ( + + + + +

+ +

+ + ), + }, + { + value: 'read', + inputDisplay: ( + -

-
- ), - }, - { - value: 'read', - inputDisplay: ( - - - - ), - dropdownDisplay: ( - - - - -

- -

-
- ), - }, - { - value: 'all', - inputDisplay: ( - - - - ), - dropdownDisplay: ( - - + + ), + dropdownDisplay: ( + + + + +

+ +

+
+ ), + }, + { + value: 'all', + inputDisplay: ( + -
-

- -

- - ), - }, - ]} - hasDividers - valueOfSelected={kibanaPrivilege} - /> - )} - - {this.state.isCustomizingGlobalPrivilege && ( - - isGlobalPrivilegeDefinition(k))} - /> + + ), + dropdownDisplay: ( + + + + +

+ +

+
+ ), + }, + ]} + hasDividers + valueOfSelected={kibanaPrivilege} + /> + )}
- )} - {this.maybeRenderSpacePrivilegeWarning()} - + {this.state.isCustomizingGlobalPrivilege && ( + + + isGlobalPrivilegeDefinition(k) + )} + canCustomizeSubFeaturePrivileges={this.props.canCustomizeSubFeaturePrivileges} + /> + + )} + {this.maybeRenderSpacePrivilegeWarning()} + + ); } @@ -295,7 +280,7 @@ export class SimplePrivilegeSection extends Component { const form = this.locateGlobalPrivilege(role) || this.createGlobalPrivilegeEntry(role); if (privileges.length > 0) { - this.props.features.forEach(feature => { + this.props.kibanaPrivileges.getSecuredFeatures().forEach(feature => { form.feature[feature.id] = [...privileges]; }); } else { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/raw_kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/raw_kibana_privileges.ts deleted file mode 100644 index 428836c9f181b..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/raw_kibana_privileges.ts +++ /dev/null @@ -1,38 +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 { RawKibanaPrivileges } from '../../../../../../../../common/model'; - -export const rawKibanaPrivileges: RawKibanaPrivileges = { - global: { - all: [ - 'normal-feature-all', - 'normal-feature-read', - 'just-global-all', - 'all-privilege-excluded-from-base-read', - ], - read: ['normal-feature-read', 'all-privilege-excluded-from-base-read'], - }, - space: { - all: ['normal-feature-all', 'normal-feature-read', 'all-privilege-excluded-from-base-read'], - read: ['normal-feature-read', 'all-privilege-excluded-from-base-read'], - }, - reserved: {}, - features: { - normal: { - all: ['normal-feature-all', 'normal-feature-read'], - read: ['normal-feature-read'], - }, - bothPrivilegesExcludedFromBase: { - all: ['both-privileges-excluded-from-base-all', 'both-privileges-excluded-from-base-read'], - read: ['both-privileges-excluded-from-base-read'], - }, - allPrivilegeExcludedFromBase: { - all: ['all-privilege-excluded-from-base-all', 'all-privilege-excluded-from-base-read'], - read: ['all-privilege-excluded-from-base-read'], - }, - }, -}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_display.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_display.test.tsx.snap deleted file mode 100644 index a3fbdebee7eba..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_display.test.tsx.snap +++ /dev/null @@ -1,118 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PrivilegeDisplay renders a superceded privilege 1`] = ` - -`; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_space_form.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_space_form.test.tsx.snap deleted file mode 100644 index 8d10e27df9694..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_space_form.test.tsx.snap +++ /dev/null @@ -1,497 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders without crashing 1`] = ` - - - - -

- -

-
-
- - - - - - - - - - -

- -

- , - "inputDisplay": - - , - "value": "basePrivilege_custom", - }, - Object { - "disabled": false, - "dropdownDisplay": - - - -

- -

-
, - "inputDisplay": - - , - "value": "basePrivilege_read", - }, - Object { - "dropdownDisplay": - - - -

- -

-
, - "inputDisplay": - - , - "value": "basePrivilege_all", - }, - ] - } - valueOfSelected="basePrivilege_custom" - /> -
- - -

- Customize by feature -

-
- - -

- Increase privilege levels on a per feature basis. Some features might be hidden by the space or affected by a global space privilege. -

-
- - -
-
- - - - - - - - - - - - - - -
-
-`; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.test.tsx index c6268e19abfd1..155ccf98b9762 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.test.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiIconTip, EuiText, EuiToolTip } from '@elastic/eui'; +import { EuiText } from '@elastic/eui'; import React from 'react'; -import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { PRIVILEGE_SOURCE } from '../kibana_privilege_calculator'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { PrivilegeDisplay } from './privilege_display'; describe('PrivilegeDisplay', () => { @@ -23,41 +22,4 @@ describe('PrivilegeDisplay', () => { color: 'danger', }); }); - - it('renders a privilege with tooltip, if provided', () => { - const wrapper = mountWithIntl( - ahh} /> - ); - expect(wrapper.text().trim()).toEqual('All'); - expect(wrapper.find(EuiToolTip).props()).toMatchObject({ - content: ahh, - }); - }); - - it('renders a privilege with icon tooltip, if provided', () => { - const wrapper = mountWithIntl( - ahh} iconType={'asterisk'} /> - ); - expect(wrapper.text().trim()).toEqual('All'); - expect(wrapper.find(EuiIconTip).props()).toMatchObject({ - type: 'asterisk', - content: ahh, - }); - }); - - it('renders a superceded privilege', () => { - const wrapper = shallowWithIntl( - - ); - expect(wrapper).toMatchSnapshot(); - }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.tsx index 55ac99da4c8c1..93f1d9bba460d 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.tsx @@ -3,95 +3,28 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiIcon, EuiIconTip, EuiText, IconType, PropsOf, EuiToolTip } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiIcon, EuiText, PropsOf } from '@elastic/eui'; import _ from 'lodash'; import React, { ReactNode, FC } from 'react'; -import { PRIVILEGE_SOURCE, PrivilegeExplanation } from '../kibana_privilege_calculator'; import { NO_PRIVILEGE_VALUE } from '../constants'; interface Props extends PropsOf { privilege: string | string[] | undefined; - explanation?: PrivilegeExplanation; - iconType?: IconType; - iconTooltipContent?: ReactNode; - tooltipContent?: ReactNode; + 'data-test-subj'?: string; } export const PrivilegeDisplay: FC = (props: Props) => { - const { explanation } = props; - - if (!explanation) { - return ; - } - - if (explanation.supersededPrivilege) { - return ; - } - - if (!explanation.isDirectlyAssigned) { - return ; - } - return ; }; const SimplePrivilegeDisplay: FC = (props: Props) => { - const { privilege, iconType, iconTooltipContent, explanation, tooltipContent, ...rest } = props; - - const text = ( - - {getDisplayValue(privilege)} {getIconTip(iconType, iconTooltipContent)} - - ); + const { privilege, ...rest } = props; - if (tooltipContent) { - return {text}; - } + const text = {getDisplayValue(privilege)}; return text; }; -export const SupersededPrivilegeDisplay: FC = (props: Props) => { - const { supersededPrivilege, actualPrivilegeSource } = - props.explanation || ({} as PrivilegeExplanation); - - return ( - - } - /> - ); -}; - -export const EffectivePrivilegeDisplay: FC = (props: Props) => { - const { explanation, ...rest } = props; - - const source = getReadablePrivilegeSource(explanation!.actualPrivilegeSource); - - const iconTooltipContent = ( - - ); - - return ( - - ); -}; - PrivilegeDisplay.defaultProps = { privilege: [], }; @@ -113,24 +46,6 @@ function getDisplayValue(privilege: string | string[] | undefined) { return displayValue; } -function getIconTip(iconType?: IconType, tooltipContent?: ReactNode) { - if (!iconType || !tooltipContent) { - return null; - } - - return ( - - ); -} - function coerceToArray(privilege: string | string[] | undefined): string[] { if (privilege === undefined) { return []; @@ -140,43 +55,3 @@ function coerceToArray(privilege: string | string[] | undefined): string[] { } return [privilege]; } - -function getReadablePrivilegeSource(privilegeSource: PRIVILEGE_SOURCE) { - switch (privilegeSource) { - case PRIVILEGE_SOURCE.GLOBAL_BASE: - return ( - - ); - case PRIVILEGE_SOURCE.GLOBAL_FEATURE: - return ( - - ); - case PRIVILEGE_SOURCE.SPACE_BASE: - return ( - - ); - case PRIVILEGE_SOURCE.SPACE_FEATURE: - return ( - - ); - default: - return ( - - ); - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.test.tsx deleted file mode 100644 index a01c026c1a5df..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.test.tsx +++ /dev/null @@ -1,128 +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 { EuiButtonEmpty, EuiInMemoryTable } from '@elastic/eui'; -import React from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { Space } from '../../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; -import { PrivilegeMatrix } from './privilege_matrix'; - -describe('PrivilegeMatrix', () => { - it('can render a complex matrix', () => { - const spaces: Space[] = ['*', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'].map(a => ({ - id: a, - name: `${a} space`, - disabledFeatures: [], - })); - - const features: Feature[] = [ - { - id: 'feature1', - name: 'feature 1', - icon: 'apmApp', - app: [], - privileges: {}, - }, - { - id: 'feature2', - name: 'feature 2', - icon: 'apmApp', - app: [], - privileges: {}, - }, - { - id: 'feature3', - name: 'feature 3', - icon: 'apmApp', - app: [], - privileges: {}, - }, - ]; - - const role: Role = { - name: 'role', - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], - base: [], - feature: { - feature2: ['read'], - feature3: ['all'], - }, - }, - { - spaces: ['k'], - base: ['all'], - feature: { - feature2: ['read'], - feature3: ['read'], - }, - }, - ], - }; - - const calculator = new KibanaPrivilegeCalculatorFactory( - new KibanaPrivileges({ - global: { - all: [], - read: [], - }, - features: { - feature1: { - all: [], - read: [], - }, - feature2: { - all: [], - read: [], - }, - feature3: { - all: [], - read: [], - }, - }, - space: { - all: [], - read: [], - }, - reserved: {}, - }) - ).getInstance(role); - - const wrapper = mountWithIntl( - - ); - - wrapper.find(EuiButtonEmpty).simulate('click'); - wrapper.update(); - - const { columns, items } = wrapper.find(EuiInMemoryTable).props() as any; - - expect(columns).toHaveLength(4); // all spaces groups plus the "feature" column - expect(items).toHaveLength(features.length + 1); // all features plus the "base" row - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.tsx deleted file mode 100644 index f0f425273e25d..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.tsx +++ /dev/null @@ -1,342 +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 { - EuiButton, - EuiButtonEmpty, - EuiIcon, - EuiIconTip, - EuiInMemoryTable, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiOverlayMask, - IconType, -} from '@elastic/eui'; -import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; -import React, { Component, Fragment } from 'react'; -import { Space, SpaceAvatar } from '../../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../../features/public'; -import { FeaturesPrivileges, Role } from '../../../../../../../common/model'; -import { CalculatedPrivilege } from '../kibana_privilege_calculator'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { SpacesPopoverList } from '../../../spaces_popover_list'; -import { PrivilegeDisplay } from './privilege_display'; - -const SPACES_DISPLAY_COUNT = 4; - -interface Props { - role: Role; - spaces: Space[]; - features: Feature[]; - calculatedPrivileges: CalculatedPrivilege[]; - intl: InjectedIntl; -} - -interface State { - showModal: boolean; -} - -interface TableRow { - feature: Feature & { isBase: boolean }; - tooltip?: string; - role: Role; -} - -interface SpacesColumn { - isGlobal: boolean; - spacesIndex: number; - spaces: Space[]; - privileges: { - base: string[]; - feature: FeaturesPrivileges; - }; -} - -export class PrivilegeMatrix extends Component { - public state = { - showModal: false, - }; - public render() { - let modal = null; - if (this.state.showModal) { - modal = ( - - - - - - - - {this.renderTable()} - - - - - - - - ); - } - - return ( - - - - - {modal} - - ); - } - - private renderTable = () => { - const { role, features, intl } = this.props; - - const spacePrivileges = role.kibana; - - const globalPrivilege = this.locateGlobalPrivilege(); - - const spacesColumns: SpacesColumn[] = []; - - spacePrivileges.forEach((spacePrivs, spacesIndex) => { - spacesColumns.push({ - isGlobal: isGlobalPrivilegeDefinition(spacePrivs), - spacesIndex, - spaces: spacePrivs.spaces - .map(spaceId => this.props.spaces.find(space => space.id === spaceId)) - .filter(Boolean) as Space[], - privileges: { - base: spacePrivs.base, - feature: spacePrivs.feature, - }, - }); - }); - - const rows: TableRow[] = [ - { - feature: { - id: '*base*', - isBase: true, - name: intl.formatMessage({ - id: 'xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeText', - defaultMessage: 'Base privilege', - }), - app: [], - privileges: {}, - }, - role, - }, - ...features.map(feature => ({ - feature: { - ...feature, - isBase: false, - }, - role, - })), - ]; - - const columns = [ - { - field: 'feature', - name: intl.formatMessage({ - id: 'xpack.security.management.editRole.spacePrivilegeMatrix.featureColumnTitle', - defaultMessage: 'Feature', - }), - width: '230px', - render: (feature: Feature & { isBase: boolean }) => { - return feature.isBase ? ( - - {feature.name} - - - ) : ( - - {feature.icon && ( - - )} - {feature.name} - - ); - }, - }, - ...spacesColumns.map(item => { - let columnWidth; - if (item.isGlobal) { - columnWidth = '100px'; - } else if (item.spaces.length - SPACES_DISPLAY_COUNT) { - columnWidth = '90px'; - } else { - columnWidth = '80px'; - } - - return { - // TODO: this is a hacky way to determine if we are looking at the global feature - // used for cellProps below... - field: item.isGlobal ? 'global' : 'feature', - width: columnWidth, - name: ( -
- {item.spaces.slice(0, SPACES_DISPLAY_COUNT).map((space: Space) => ( - - {' '} - {item.isGlobal && ( - - -
- s.id !== '*')} - intl={this.props.intl} - buttonText={this.props.intl.formatMessage({ - id: - 'xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink', - defaultMessage: '(all spaces)', - })} - /> -
- )} -
- ))} - {item.spaces.length > SPACES_DISPLAY_COUNT && ( - -
- -
- )} -
- ), - render: (feature: Feature & { isBase: boolean }, record: TableRow) => { - return this.renderPrivilegeDisplay(item, record, globalPrivilege.base); - }, - }; - }), - ]; - - return ( - { - return { - className: item.feature.isBase ? 'secPrivilegeMatrix__row--isBasePrivilege' : '', - }; - }} - cellProps={(item: TableRow, column: Record) => { - return { - className: - column.field === 'global' ? 'secPrivilegeMatrix__cell--isGlobalPrivilege' : '', - }; - }} - /> - ); - }; - - private renderPrivilegeDisplay = ( - column: SpacesColumn, - { feature }: TableRow, - globalBasePrivilege: string[] - ) => { - if (column.isGlobal) { - if (feature.isBase) { - return ; - } - - const featureCalculatedPrivilege = this.props.calculatedPrivileges[column.spacesIndex] - .feature[feature.id]; - - return ( - - ); - } else { - // not global - - const calculatedPrivilege = this.props.calculatedPrivileges[column.spacesIndex]; - - if (feature.isBase) { - // Space base privilege - const actualBasePrivileges = calculatedPrivilege.base.actualPrivilege; - - return ( - - ); - } - - const featurePrivilegeExplanation = calculatedPrivilege.feature[feature.id]; - - return ( - - ); - } - }; - - private locateGlobalPrivilege = () => { - return ( - this.props.role.kibana.find(spacePriv => isGlobalPrivilegeDefinition(spacePriv)) || { - spaces: ['*'], - base: [], - feature: [], - } - ); - }; - - private hideModal = () => { - this.setState({ - showModal: false, - }); - }; - - private showModal = () => { - this.setState({ - showModal: true, - }); - }; -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx index 675f02a81f9e1..968730181fe10 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx @@ -4,123 +4,379 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { merge } from 'lodash'; -// @ts-ignore -import { findTestSubject } from '@elastic/eui/lib/test'; -import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; -import { KibanaPrivileges } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; +import { Role } from '../../../../../../../common/model'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { PrivilegeSpaceForm } from './privilege_space_form'; -import { rawKibanaPrivileges } from './__fixtures__'; +import React from 'react'; +import { Space } from '../../../../../../../../spaces/public'; +import { EuiSuperSelect } from '@elastic/eui'; +import { FeatureTable } from '../feature_table'; +import { getDisplayedFeaturePrivileges } from '../feature_table/__fixtures__'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { SpaceSelector } from './space_selector'; -type RecursivePartial = { - [P in keyof T]?: RecursivePartial; +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'my_role', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, + }; }; -const buildProps = ( - overrides?: RecursivePartial -): PrivilegeSpaceForm['props'] => { - const kibanaPrivileges = new KibanaPrivileges(rawKibanaPrivileges); - const defaultProps: PrivilegeSpaceForm['props'] = { - spaces: [ +const displaySpaces: Space[] = [ + { + id: 'foo', + name: 'Foo Space', + disabledFeatures: [], + }, + { + id: 'default', + name: 'Default Space', + disabledFeatures: [], + }, + { + id: '*', + name: 'Global', + disabledFeatures: [], + }, +]; + +describe('PrivilegeSpaceForm', () => { + it('renders an empty form when the role contains no Kibana privileges', () => { + const role = createRole(); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiSuperSelect).props().valueOfSelected).toEqual(`basePrivilege_custom`); + expect(wrapper.find(FeatureTable).props().disabled).toEqual(true); + expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` + Object { + "excluded_from_base": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "no_sub_features": Object { + "primaryFeaturePrivilege": "none", + }, + "with_excluded_sub_features": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "with_sub_features": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + } + `); + + expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(0); + }); + + it('renders when a base privilege is selected', () => { + const role = createRole([ { - id: 'default', - name: 'Default Space', - description: '', - disabledFeatures: [], - _reserved: true, + base: ['all'], + feature: {}, + spaces: ['foo'], }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiSuperSelect).props().valueOfSelected).toEqual(`basePrivilege_all`); + expect(wrapper.find(FeatureTable).props().disabled).toEqual(true); + expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` + Object { + "excluded_from_base": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "no_sub_features": Object { + "primaryFeaturePrivilege": "all", + }, + "with_excluded_sub_features": Object { + "primaryFeaturePrivilege": "all", + "subFeaturePrivileges": Array [], + }, + "with_sub_features": Object { + "primaryFeaturePrivilege": "all", + "subFeaturePrivileges": Array [ + "with_sub_features_cool_toggle_1", + "with_sub_features_cool_toggle_2", + "cool_all", + ], + }, + } + `); + + expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(0); + }); + + it('renders when a feature privileges are selected', () => { + const role = createRole([ { - id: 'marketing', - name: 'Marketing', - description: '', - disabledFeatures: [], + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], }, - ], - kibanaPrivileges, - privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory(kibanaPrivileges), - features: [], - role: { - name: 'test role', - elasticsearch: { - cluster: ['all'], - indices: [] as any[], - run_as: [] as string[], + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiSuperSelect).props().valueOfSelected).toEqual(`basePrivilege_custom`); + expect(wrapper.find(FeatureTable).props().disabled).toEqual(false); + expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` + Object { + "excluded_from_base": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "no_sub_features": Object { + "primaryFeaturePrivilege": "none", + }, + "with_excluded_sub_features": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "with_sub_features": Object { + "primaryFeaturePrivilege": "read", + "subFeaturePrivileges": Array [ + "with_sub_features_cool_toggle_2", + "cool_read", + ], + }, + } + `); + + expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(0); + }); + + it('renders a warning when configuring a global privilege after space privileges are already defined', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], }, - kibana: [{ spaces: [], base: [], feature: {} }], - }, - onChange: jest.fn(), - onCancel: jest.fn(), - intl: {} as any, - editingIndex: 0, - }; - return merge(defaultProps, overrides || {}); -}; + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['*'], + }, + ]); + + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + wrapper + .find(SpaceSelector) + .props() + .onChange(['*']); + + wrapper.update(); -describe('', () => { - it('renders without crashing', () => { - expect(shallowWithIntl()).toMatchSnapshot(); + expect(findTestSubject(wrapper, 'globalPrivilegeWarning')).toHaveLength(1); }); - it(`defaults to "Custom" for new global entries`, () => { - const props = buildProps({ - role: { - kibana: [ - { - spaces: ['*'], - base: [], - feature: {}, - }, - ], + it('renders a warning when space privileges are less permissive than configured global privileges', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['*'], }, - editingIndex: 0, - }); - const component = mountWithIntl(); - const basePrivilegeComboBox = findTestSubject(component, `basePrivilegeComboBox`); - expect(basePrivilegeComboBox.text()).toBe('Custom'); + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiSuperSelect).props().valueOfSelected).toEqual(`basePrivilege_custom`); + expect(wrapper.find(FeatureTable).props().disabled).toEqual(false); + expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` + Object { + "excluded_from_base": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "no_sub_features": Object { + "primaryFeaturePrivilege": "none", + }, + "with_excluded_sub_features": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "with_sub_features": Object { + "primaryFeaturePrivilege": "read", + "subFeaturePrivileges": Array [ + "with_sub_features_cool_toggle_2", + "cool_read", + ], + }, + } + `); + + expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(1); + expect(findTestSubject(wrapper, 'globalPrivilegeWarning')).toHaveLength(0); }); - it(`defaults to "Custom" for new space entries`, () => { - const props = buildProps({ - role: { - kibana: [ - { - spaces: ['space:default'], - base: [], - feature: {}, - }, - ], + it('allows all feature privileges to be changed via "change all"', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'with_sub_features_cool_toggle_2', 'cool_read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], }, - editingIndex: 0, - }); - const component = mountWithIntl(); - const basePrivilegeComboBox = findTestSubject(component, `basePrivilegeComboBox`); - expect(basePrivilegeComboBox.text()).toBe('Custom'); + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['read'], + with_excluded_sub_features: ['read'], + no_sub_features: ['read'], + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); }); - describe('when an existing global all privilege', () => { - it(`defaults to "Custom" for new entries`, () => { - const props = buildProps({ - role: { - kibana: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['default'], - base: [], - feature: {}, - }, - ], + it('passes the `canCustomizeSubFeaturePrivileges` prop to the FeatureTable', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], }, - editingIndex: 1, - }); - const component = mountWithIntl(); - const basePrivilegeComboBox = findTestSubject(component, `basePrivilegeComboBox`); - expect(basePrivilegeComboBox.text()).toBe('Custom'); - }); + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const onChange = jest.fn(); + + const canCustomize = (Symbol('can customize') as unknown) as boolean; + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(FeatureTable).props().canCustomizeSubFeaturePrivileges).toBe(canCustomize); }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx index 6f841b5d14cb3..4e9e02bb531f1 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx @@ -21,46 +21,42 @@ import { EuiSuperSelect, EuiText, EuiTitle, + EuiErrorBoundary, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment } from 'react'; import { Space } from '../../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role, copyRole } from '../../../../../../../common/model'; -import { - AllowedPrivilege, - KibanaPrivilegeCalculatorFactory, - PrivilegeExplanation, -} from '../kibana_privilege_calculator'; -import { hasAssignedFeaturePrivileges } from '../../../privilege_utils'; -import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; -import { FeatureTable } from '../feature_table'; +import { Role, copyRole } from '../../../../../../../common/model'; import { SpaceSelector } from './space_selector'; +import { FeatureTable } from '../feature_table'; +import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { KibanaPrivileges } from '../../../../model'; interface Props { role: Role; - privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory; kibanaPrivileges: KibanaPrivileges; - features: Feature[]; spaces: Space[]; - editingIndex: number; + privilegeIndex: number; + canCustomizeSubFeaturePrivileges: boolean; onChange: (role: Role) => void; onCancel: () => void; - intl: InjectedIntl; } interface State { - editingIndex: number; + privilegeIndex: number; selectedSpaceIds: string[]; selectedBasePrivilege: string[]; role: Role; mode: 'create' | 'update'; isCustomizingFeaturePrivileges: boolean; + privilegeCalculator: PrivilegeFormCalculator; } export class PrivilegeSpaceForm extends Component { public static defaultProps = { - editingIndex: -1, + privilegeIndex: -1, }; constructor(props: Props) { @@ -68,10 +64,10 @@ export class PrivilegeSpaceForm extends Component { const role = copyRole(props.role); - let editingIndex = props.editingIndex; - if (editingIndex < 0) { + let privilegeIndex = props.privilegeIndex; + if (privilegeIndex < 0) { // create new form - editingIndex = + privilegeIndex = role.kibana.push({ spaces: [], base: [], @@ -81,11 +77,12 @@ export class PrivilegeSpaceForm extends Component { this.state = { role, - editingIndex, - selectedSpaceIds: [...role.kibana[editingIndex].spaces], - selectedBasePrivilege: [...(role.kibana[editingIndex].base || [])], - mode: props.editingIndex < 0 ? 'create' : 'update', + privilegeIndex, + selectedSpaceIds: [...role.kibana[privilegeIndex].spaces], + selectedBasePrivilege: [...(role.kibana[privilegeIndex].base || [])], + mode: props.privilegeIndex < 0 ? 'create' : 'update', isCustomizingFeaturePrivileges: false, + privilegeCalculator: new PrivilegeFormCalculator(props.kibanaPrivileges, role), }; } @@ -103,8 +100,33 @@ export class PrivilegeSpaceForm extends Component { - {this.getForm()} + + {this.getForm()} + + {this.state.privilegeCalculator.hasSupersededInheritedPrivileges( + this.state.privilegeIndex + ) && ( + + + } + > + + + + + )} { data-test-subj={'cancelSpacePrivilegeButton'} > @@ -128,18 +150,7 @@ export class PrivilegeSpaceForm extends Component { } private getForm = () => { - const { intl, spaces, privilegeCalculatorFactory } = this.props; - - const privilegeCalculator = privilegeCalculatorFactory.getInstance(this.state.role); - - const calculatedPrivileges = privilegeCalculator.calculateEffectivePrivileges()[ - this.state.editingIndex - ]; - const allowedPrivileges = privilegeCalculator.calculateAllowedPrivileges()[ - this.state.editingIndex - ]; - - const baseExplanation = calculatedPrivileges.base; + const { spaces } = this.props; const hasSelectedSpaces = this.state.selectedSpaceIds.length > 0; @@ -147,16 +158,17 @@ export class PrivilegeSpaceForm extends Component { @@ -164,10 +176,12 @@ export class PrivilegeSpaceForm extends Component { { options={[ { value: 'basePrivilege_custom', - disabled: !this.canCustomizeFeaturePrivileges(baseExplanation, allowedPrivileges), inputDisplay: ( { }, { value: 'basePrivilege_read', - disabled: !allowedPrivileges.base.privileges.includes('read'), inputDisplay: ( { }, ]} hasDividers - valueOfSelected={this.getDisplayedBasePrivilege(allowedPrivileges, baseExplanation)} + valueOfSelected={this.getDisplayedBasePrivilege()} disabled={!hasSelectedSpaces} /> @@ -280,14 +292,12 @@ export class PrivilegeSpaceForm extends Component { 0 || !hasSelectedSpaces} /> @@ -297,6 +307,7 @@ export class PrivilegeSpaceForm extends Component { { private getFeatureListLabel = (disabled: boolean) => { if (disabled) { - return this.props.intl.formatMessage({ - id: 'xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges', - defaultMessage: 'Summary of feature privileges', - }); + return i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges', + { + defaultMessage: 'Summary of feature privileges', + } + ); } else { - return this.props.intl.formatMessage({ - id: 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivileges', - defaultMessage: 'Customize by feature', - }); + return i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivileges', + { + defaultMessage: 'Customize by feature', + } + ); } }; private getFeatureListDescription = (disabled: boolean) => { if (disabled) { - return this.props.intl.formatMessage({ - id: - 'xpack.security.management.editRole.spacePrivilegeForm.featurePrivilegeSummaryDescription', - defaultMessage: - 'Some features might be hidden by the space or affected by a global space privilege.', - }); + return i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeForm.featurePrivilegeSummaryDescription', + { + defaultMessage: + 'Some features might be hidden by the space or affected by a global space privilege.', + } + ); } else { - return this.props.intl.formatMessage({ - id: - 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivilegeDescription', - defaultMessage: - 'Increase privilege levels on a per feature basis. Some features might be hidden by the space or affected by a global space privilege.', - }); + return i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivilegeDescription', + { + defaultMessage: + 'Increase privilege levels on a per feature basis. Some features might be hidden by the space or affected by a global space privilege.', + } + ); } }; @@ -410,10 +427,12 @@ export class PrivilegeSpaceForm extends Component { ); @@ -429,7 +448,7 @@ export class PrivilegeSpaceForm extends Component { private onSaveClick = () => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; + const form = role.kibana[this.state.privilegeIndex]; // remove any spaces that no longer exist if (!this.isDefiningGlobalPrivilege()) { @@ -444,18 +463,19 @@ export class PrivilegeSpaceForm extends Component { private onSelectedSpacesChange = (selectedSpaceIds: string[]) => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; + const form = role.kibana[this.state.privilegeIndex]; form.spaces = [...selectedSpaceIds]; this.setState({ selectedSpaceIds, role, + privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); }; private onSpaceBasePrivilegeChange = (basePrivilege: string) => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; + const form = role.kibana[this.state.privilegeIndex]; const privilegeName = basePrivilege.split('basePrivilege_')[1]; @@ -473,47 +493,25 @@ export class PrivilegeSpaceForm extends Component { selectedBasePrivilege: privilegeName === CUSTOM_PRIVILEGE_VALUE ? [] : [privilegeName], role, isCustomizingFeaturePrivileges, + privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); }; - private getDisplayedBasePrivilege = ( - allowedPrivileges: AllowedPrivilege, - explanation: PrivilegeExplanation - ) => { - let displayedBasePrivilege = explanation.actualPrivilege; - - if (this.canCustomizeFeaturePrivileges(explanation, allowedPrivileges)) { - const form = this.state.role.kibana[this.state.editingIndex]; - - if ( - hasAssignedFeaturePrivileges(form) || - form.base.length === 0 || - this.state.isCustomizingFeaturePrivileges - ) { - displayedBasePrivilege = CUSTOM_PRIVILEGE_VALUE; - } - } - - return displayedBasePrivilege ? `basePrivilege_${displayedBasePrivilege}` : undefined; - }; + private getDisplayedBasePrivilege = () => { + const basePrivilege = this.state.privilegeCalculator.getBasePrivilege( + this.state.privilegeIndex + ); - private canCustomizeFeaturePrivileges = ( - basePrivilegeExplanation: PrivilegeExplanation, - allowedPrivileges: AllowedPrivilege - ) => { - if (basePrivilegeExplanation.isDirectlyAssigned) { - return true; + if (basePrivilege) { + return `basePrivilege_${basePrivilege.id}`; } - const featureEntries = Object.values(allowedPrivileges.feature); - return featureEntries.some(entry => { - return entry != null && (entry.canUnassign || entry.privileges.length > 1); - }); + return `basePrivilege_${CUSTOM_PRIVILEGE_VALUE}`; }; private onFeaturePrivilegesChange = (featureId: string, privileges: string[]) => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; + const form = role.kibana[this.state.privilegeIndex]; if (privileges.length === 0) { delete form.feature[featureId]; @@ -523,32 +521,29 @@ export class PrivilegeSpaceForm extends Component { this.setState({ role, + privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); }; private onChangeAllFeaturePrivileges = (privileges: string[]) => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; - - const calculator = this.props.privilegeCalculatorFactory.getInstance(role); - const allowedPrivs = calculator.calculateAllowedPrivileges(); + const entry = role.kibana[this.state.privilegeIndex]; if (privileges.length === 0) { - form.feature = {}; + entry.feature = {}; } else { - this.props.features.forEach(feature => { - const allowedPrivilegesFeature = allowedPrivs[this.state.editingIndex].feature[feature.id]; - const canAssign = - allowedPrivilegesFeature && allowedPrivilegesFeature.privileges.includes(privileges[0]); - - if (canAssign) { - form.feature[feature.id] = [...privileges]; + this.props.kibanaPrivileges.getSecuredFeatures().forEach(feature => { + const nextFeaturePrivilege = feature + .getPrimaryFeaturePrivileges() + .find(pfp => privileges.includes(pfp.id)); + if (nextFeaturePrivilege) { + entry.feature[feature.id] = [nextFeaturePrivilege.id]; } }); } - this.setState({ role, + privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); }; @@ -557,7 +552,7 @@ export class PrivilegeSpaceForm extends Component { return false; } - const form = this.state.role.kibana[this.state.editingIndex]; + const form = this.state.role.kibana[this.state.privilegeIndex]; if (form.base.length === 0 && Object.keys(form.feature).length === 0) { return false; } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx index f0a391c98c910..b1c7cb4b631e6 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx @@ -5,14 +5,16 @@ */ import React from 'react'; -import { EuiBadge, EuiInMemoryTable, EuiIconTip } from '@elastic/eui'; +import { EuiBadge, EuiInMemoryTable } from '@elastic/eui'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { PrivilegeSpaceTable } from './privilege_space_table'; import { PrivilegeDisplay } from './privilege_display'; -import { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; -import { rawKibanaPrivileges } from './__fixtures__'; +import { Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { Feature } from '../../../../../../../../features/public'; +import { findTestSubject } from 'test_utils/find_test_subject'; interface TableRow { spaces: string[]; @@ -21,20 +23,125 @@ interface TableRow { }; } -const buildProps = (roleKibanaPrivileges: RoleKibanaPrivilege[]): PrivilegeSpaceTable['props'] => { - const kibanaPrivileges = new KibanaPrivileges(rawKibanaPrivileges); - return { - role: { - name: 'test role', - elasticsearch: { - cluster: ['all'], - indices: [] as any[], - run_as: [] as string[], +const features = [ + new Feature({ + id: 'normal', + name: 'normal feature', + app: [], + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: ['normal-feature-all', 'normal-feature-read'], + }, + read: { + savedObject: { all: [], read: [] }, + ui: ['normal-feature-read'], }, - kibana: roleKibanaPrivileges, }, - privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory(kibanaPrivileges), - onChange: (role: Role) => {}, + }), + new Feature({ + id: 'normal_with_sub', + name: 'normal feature with sub features', + app: [], + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: ['normal-feature-all', 'normal-feature-read'], + }, + read: { + savedObject: { all: [], read: [] }, + ui: ['normal-feature-read'], + }, + }, + subFeatures: [ + { + name: 'sub feature', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'normal_sub_all', + name: 'normal sub feature privilege', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['normal-sub-all', 'normal-sub-read'], + }, + { + id: 'normal_sub_read', + name: 'normal sub feature read privilege', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['normal-sub-read'], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'excluded_sub_priv', + name: 'excluded sub feature privilege', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['excluded-sub-priv'], + }, + ], + }, + ], + }, + ], + }), + new Feature({ + id: 'bothPrivilegesExcludedFromBase', + name: 'bothPrivilegesExcludedFromBase', + app: [], + privileges: { + all: { + excludeFromBasePrivileges: true, + savedObject: { all: [], read: [] }, + ui: ['both-privileges-excluded-from-base-all', 'both-privileges-excluded-from-base-read'], + }, + read: { + excludeFromBasePrivileges: true, + savedObject: { all: [], read: [] }, + ui: ['both-privileges-excluded-from-base-read'], + }, + }, + }), + new Feature({ + id: 'allPrivilegeExcludedFromBase', + name: 'allPrivilegeExcludedFromBase', + app: [], + privileges: { + all: { + excludeFromBasePrivileges: true, + savedObject: { all: [], read: [] }, + ui: ['all-privilege-excluded-from-base-all', 'all-privilege-excluded-from-base-read'], + }, + read: { + savedObject: { all: [], read: [] }, + ui: ['all-privilege-excluded-from-base-read'], + }, + }, + }), +]; + +const buildProps = (roleKibanaPrivileges: RoleKibanaPrivilege[]): PrivilegeSpaceTable['props'] => { + const kibanaPrivileges = createKibanaPrivileges(features); + const role = { + name: 'test role', + elasticsearch: { + cluster: ['all'], + indices: [] as any[], + run_as: [] as string[], + }, + kibana: roleKibanaPrivileges, + }; + return { + role, + privilegeCalculator: new PrivilegeFormCalculator(kibanaPrivileges, role), + onChange: (r: Role) => {}, onEdit: (spacesIndex: number) => {}, displaySpaces: [ { @@ -51,7 +158,6 @@ const buildProps = (roleKibanaPrivileges: RoleKibanaPrivilege[]): PrivilegeSpace disabledFeatures: [], }, ], - intl: {} as any, }; }; @@ -73,7 +179,9 @@ const getTableFromComponent = ( spaces: spacesBadge.map(badge => badge.text().trim()), privileges: { summary: privilegesDisplay.text().trim(), - overridden: privilegesDisplay.find(EuiIconTip).exists('[type="lock"]'), + overridden: + findTestSubject(row as ReactWrapper, 'spaceTablePrivilegeSupersededWarning') + .length > 0, }, }, ]; @@ -117,6 +225,28 @@ describe('only global', () => { ]); }); + it('normal feature privilege minimal_all', () => { + const props = buildProps([ + { spaces: ['*'], base: [], feature: { normal_with_sub: ['minimal_all'] } }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + + it('normal feature privilege minimal_all and normal_sub_read', () => { + const props = buildProps([ + { spaces: ['*'], base: [], feature: { normal_with_sub: ['minimal_all', 'normal_sub_read'] } }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + it('bothPrivilegesExcludedFromBase feature privilege all', () => { const props = buildProps([ { spaces: ['*'], base: [], feature: { bothPrivilegesExcludedFromBase: ['read'] } }, @@ -203,6 +333,32 @@ describe('only default and marketing space', () => { ]); }); + it('normal feature privilege minimal_all', () => { + const props = buildProps([ + { spaces: ['default', 'marketing'], base: [], feature: { normal_with_sub: ['minimal_all'] } }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + + it('normal feature privilege minimal_all and normal_sub_read', () => { + const props = buildProps([ + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_all', 'normal_sub_read'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + it('bothPrivilegesExcludedFromBase feature privilege all', () => { const props = buildProps([ { @@ -275,7 +431,7 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: false } }, ]); }); @@ -288,7 +444,7 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: true } }, ]); }); @@ -301,7 +457,7 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, ]); }); @@ -314,7 +470,41 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, + ]); + }); + + it('normal feature privilege minimal_all', () => { + const props = buildProps([ + { spaces: ['*'], base: ['all'], feature: {} }, + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_all'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, + ]); + }); + + it('normal feature privilege minimal_all and normal_sub_read', () => { + const props = buildProps([ + { spaces: ['*'], base: ['all'], feature: {} }, + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_all', 'normal_sub_read'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, ]); }); @@ -382,7 +572,7 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, ]); }); }); @@ -412,7 +602,7 @@ describe('global base read', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: false } }, ]); }); @@ -438,7 +628,41 @@ describe('global base read', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + + it('normal feature privilege minimal_read', () => { + const props = buildProps([ + { spaces: ['*'], base: ['read'], feature: {} }, + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_read'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, + ]); + }); + + it('normal feature privilege minimal_read and normal_sub_read', () => { + const props = buildProps([ + { spaces: ['*'], base: ['read'], feature: {} }, + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_read', 'normal_sub_read'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, ]); }); @@ -506,7 +730,7 @@ describe('global base read', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, ]); }); }); @@ -562,7 +786,7 @@ describe('global normal feature privilege all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, ]); }); @@ -844,7 +1068,7 @@ describe('global bothPrivilegesExcludedFromBase feature privilege all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, ]); }); @@ -1126,7 +1350,7 @@ describe('global allPrivilegeExcludedFromBase feature privilege all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, ]); }); }); @@ -1213,6 +1437,7 @@ describe('global allPrivilegeExcludedFromBase feature privilege read', () => { }, ]); const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx index 1a43fb9e2683a..ccb5398a11b23 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx @@ -10,35 +10,32 @@ import { EuiButtonIcon, EuiInMemoryTable, EuiBasicTableColumn, + EuiIcon, + EuiIconTip, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import _ from 'lodash'; import React, { Component } from 'react'; import { Space, getSpaceColor } from '../../../../../../../../spaces/public'; -import { - FeaturesPrivileges, - Role, - RoleKibanaPrivilege, - copyRole, -} from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; -import { - isGlobalPrivilegeDefinition, - hasAssignedFeaturePrivileges, -} from '../../../privilege_utils'; -import { CUSTOM_PRIVILEGE_VALUE, NO_PRIVILEGE_VALUE } from '../constants'; +import { FeaturesPrivileges, Role, copyRole } from '../../../../../../../common/model'; import { SpacesPopoverList } from '../../../spaces_popover_list'; import { PrivilegeDisplay } from './privilege_display'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; const SPACES_DISPLAY_COUNT = 4; interface Props { role: Role; - privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory; + privilegeCalculator: PrivilegeFormCalculator; onChange: (role: Role) => void; - onEdit: (spacesIndex: number) => void; + onEdit: (privilegeIndex: number) => void; displaySpaces: Space[]; disabled?: boolean; - intl: InjectedIntl; } interface State { @@ -52,12 +49,13 @@ type TableSpace = Space & interface TableRow { spaces: TableSpace[]; - spacesIndex: number; + privilegeIndex: number; isGlobal: boolean; privileges: { spaces: string[]; base: string[]; feature: FeaturesPrivileges; + reserved: string[]; }; } @@ -71,15 +69,11 @@ export class PrivilegeSpaceTable extends Component { } private renderKibanaPrivileges = () => { - const { privilegeCalculatorFactory, displaySpaces, intl } = this.props; + const { privilegeCalculator, displaySpaces } = this.props; const spacePrivileges = this.getSortedPrivileges(); - const privilegeCalculator = privilegeCalculatorFactory.getInstance(this.props.role); - - const effectivePrivileges = privilegeCalculator.calculateEffectivePrivileges(false); - - const rows: TableRow[] = spacePrivileges.map((spacePrivs, spacesIndex) => { + const rows: TableRow[] = spacePrivileges.map((spacePrivs, privilegeIndex) => { const spaces = spacePrivs.spaces.map( spaceId => displaySpaces.find(space => space.id === spaceId) || { @@ -92,12 +86,13 @@ export class PrivilegeSpaceTable extends Component { return { spaces, - spacesIndex, + privilegeIndex, isGlobal: isGlobalPrivilegeDefinition(spacePrivs), privileges: { spaces: spacePrivs.spaces, base: spacePrivs.base || [], feature: spacePrivs.feature || {}, + reserved: spacePrivs._reserved || [], }, }; }); @@ -117,26 +112,27 @@ export class PrivilegeSpaceTable extends Component { name: 'Spaces', width: '60%', render: (spaces: TableSpace[], record: TableRow) => { - const isExpanded = this.state.expandedSpacesGroups.includes(record.spacesIndex); + const isExpanded = this.state.expandedSpacesGroups.includes(record.privilegeIndex); const displayedSpaces = isExpanded ? spaces : spaces.slice(0, SPACES_DISPLAY_COUNT); let button = null; if (record.isGlobal) { button = ( s.id !== '*')} + buttonText={i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeTable.showAllSpacesLink', + { + defaultMessage: 'show spaces', + } + )} /> ); } else if (spaces.length > displayedSpaces.length) { button = ( this.toggleExpandSpacesGroup(record.spacesIndex)} + onClick={() => this.toggleExpandSpacesGroup(record.privilegeIndex)} > { button = ( this.toggleExpandSpacesGroup(record.spacesIndex)} + onClick={() => this.toggleExpandSpacesGroup(record.privilegeIndex)} > { return (
- {displayedSpaces.map((space: TableSpace) => ( - - {space.name} - - ))} + + {displayedSpaces.map((space: TableSpace) => ( + + {space.name} + + ))} + + {button}
); @@ -178,45 +177,48 @@ export class PrivilegeSpaceTable extends Component { { field: 'privileges', name: 'Privileges', - render: (privileges: RoleKibanaPrivilege, record: TableRow) => { - const effectivePrivilege = effectivePrivileges[record.spacesIndex]; - const basePrivilege = effectivePrivilege.base; - - if (effectivePrivilege.reserved != null && effectivePrivilege.reserved.length > 0) { - return ; - } else if (record.isGlobal) { + render: (privileges: TableRow['privileges'], record: TableRow) => { + if (privileges.reserved.length > 0) { return ( ); - } else { - const hasNonSupersededCustomizations = Object.keys(privileges.feature).some( - featureId => { - const featureEffectivePrivilege = effectivePrivilege.feature[featureId]; - return ( - featureEffectivePrivilege && - featureEffectivePrivilege.directlyAssignedFeaturePrivilegeMorePermissiveThanBase - ); - } - ); - - const showCustom = - hasNonSupersededCustomizations || - (hasAssignedFeaturePrivileges(privileges) && - effectivePrivilege.base.actualPrivilege === NO_PRIVILEGE_VALUE); + } - return ( - + let icon = ; + if (privilegeCalculator.hasSupersededInheritedPrivileges(record.privilegeIndex)) { + icon = ( + + + } + /> + ); } + + return ( + + {icon} + + + + + ); }, }, ]; @@ -229,19 +231,16 @@ export class PrivilegeSpaceTable extends Component { render: (record: TableRow) => { return ( s.name).join(', '), + values: { spaceNames: record.spaces.map(s => s.name).join(', ') }, } )} color={'primary'} iconType={'pencil'} - onClick={() => this.props.onEdit(record.spacesIndex)} + onClick={() => this.props.onEdit(record.privilegeIndex)} /> ); }, @@ -250,14 +249,11 @@ export class PrivilegeSpaceTable extends Component { render: (record: TableRow) => { return ( s.name).join(', '), + values: { spaceNames: record.spaces.map(s => s.name).join(', ') }, } )} color={'danger'} @@ -294,26 +290,26 @@ export class PrivilegeSpaceTable extends Component { }); }; - private toggleExpandSpacesGroup = (spacesIndex: number) => { - if (this.state.expandedSpacesGroups.includes(spacesIndex)) { + private toggleExpandSpacesGroup = (privilegeIndex: number) => { + if (this.state.expandedSpacesGroups.includes(privilegeIndex)) { this.setState({ - expandedSpacesGroups: this.state.expandedSpacesGroups.filter(i => i !== spacesIndex), + expandedSpacesGroups: this.state.expandedSpacesGroups.filter(i => i !== privilegeIndex), }); } else { this.setState({ - expandedSpacesGroups: [...this.state.expandedSpacesGroups, spacesIndex], + expandedSpacesGroups: [...this.state.expandedSpacesGroups, privilegeIndex], }); } }; private onDeleteSpacePrivilege = (item: TableRow) => { const roleCopy = copyRole(this.props.role); - roleCopy.kibana.splice(item.spacesIndex, 1); + roleCopy.kibana.splice(item.privilegeIndex, 1); this.props.onChange(roleCopy); this.setState({ - expandedSpacesGroups: this.state.expandedSpacesGroups.filter(i => i !== item.spacesIndex), + expandedSpacesGroups: this.state.expandedSpacesGroups.filter(i => i !== item.privilegeIndex), }); }; } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx index e06d2a4f7dc33..a9bcb5433fcc7 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx @@ -6,13 +6,13 @@ import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { KibanaPrivileges } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; import { RoleValidator } from '../../../validate_role'; -import { PrivilegeMatrix } from './privilege_matrix'; import { PrivilegeSpaceForm } from './privilege_space_form'; import { PrivilegeSpaceTable } from './privilege_space_table'; import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; +import { PrivilegeSummary } from '../privilege_summary'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; const buildProps = (customProps: any = {}) => { return { @@ -42,23 +42,12 @@ const buildProps = (customProps: any = {}) => { manage: true, }, }, - features: [], + features: kibanaFeatures, editable: true, onChange: jest.fn(), validator: new RoleValidator(), - privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory( - new KibanaPrivileges({ - features: { - feature1: { - all: ['*'], - read: ['read'], - }, - }, - global: {}, - space: {}, - reserved: {}, - }) - ), + kibanaPrivileges: createKibanaPrivileges(kibanaFeatures), + canCustomizeSubFeaturePrivileges: true, ...customProps, }; }; @@ -80,7 +69,7 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); const table = wrapper.find(PrivilegeSpaceTable); expect(table).toHaveLength(1); @@ -89,13 +78,13 @@ describe('', () => { it('hides the space table if there are no existing space privileges', () => { const props = buildProps(); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); const table = wrapper.find(PrivilegeSpaceTable); expect(table).toHaveLength(0); }); - it('Renders flyout after clicking "Add a privilege" button', () => { + it('Renders flyout after clicking "Add space privilege" button', () => { const props = buildProps({ role: { elasticsearch: { @@ -111,7 +100,7 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); expect(wrapper.find(PrivilegeSpaceForm)).toHaveLength(0); wrapper.find('button[data-test-subj="addSpacePrivilegeButton"]').simulate('click'); @@ -119,7 +108,7 @@ describe('', () => { expect(wrapper.find(PrivilegeSpaceForm)).toHaveLength(1); }); - it('hides privilege matrix when the role is reserved', () => { + it('hides privilege summary when the role is reserved', () => { const props = buildProps({ role: { name: '', @@ -135,8 +124,8 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); - expect(wrapper.find(PrivilegeMatrix)).toHaveLength(0); + const wrapper = mountWithIntl(); + expect(wrapper.find(PrivilegeSummary)).toHaveLength(0); }); describe('with base privilege set to "read"', () => { @@ -156,7 +145,7 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); const table = wrapper.find(PrivilegeSpaceTable); expect(table).toHaveLength(1); @@ -183,7 +172,7 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); const table = wrapper.find(PrivilegeSpaceTable); expect(table).toHaveLength(1); @@ -202,7 +191,7 @@ describe('', () => { }, }); - const wrapper = shallowWithIntl(); + const wrapper = shallowWithIntl(); expect(wrapper).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx index a847ccb677485..86b09e5332792 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx @@ -10,47 +10,49 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, + EuiErrorBoundary, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import _ from 'lodash'; import React, { Component, Fragment } from 'react'; import { Capabilities } from 'src/core/public'; import { Space } from '../../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role, isRoleReserved } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; +import { Role, isRoleReserved } from '../../../../../../../common/model'; import { RoleValidator } from '../../../validate_role'; -import { PrivilegeMatrix } from './privilege_matrix'; -import { PrivilegeSpaceForm } from './privilege_space_form'; import { PrivilegeSpaceTable } from './privilege_space_table'; +import { PrivilegeSpaceForm } from './privilege_space_form'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { PrivilegeSummary } from '../privilege_summary'; +import { KibanaPrivileges } from '../../../../model'; interface Props { kibanaPrivileges: KibanaPrivileges; role: Role; - privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory; spaces: Space[]; onChange: (role: Role) => void; editable: boolean; + canCustomizeSubFeaturePrivileges: boolean; validator: RoleValidator; - intl: InjectedIntl; uiCapabilities: Capabilities; - features: Feature[]; } interface State { role: Role | null; - editingIndex: number; + privilegeIndex: number; showSpacePrivilegeEditor: boolean; showPrivilegeMatrix: boolean; } -class SpaceAwarePrivilegeSectionUI extends Component { +export class SpaceAwarePrivilegeSection extends Component { private globalSpaceEntry: Space = { id: '*', - name: this.props.intl.formatMessage({ - id: 'xpack.security.management.editRole.spaceAwarePrivilegeForm.globalSpacesName', - defaultMessage: '* Global (all spaces)', - }), + name: i18n.translate( + 'xpack.security.management.editRole.spaceAwarePrivilegeForm.globalSpacesName', + { + defaultMessage: '* Global (all spaces)', + } + ), color: '#D3DAE6', initials: '*', disabledFeatures: [], @@ -63,12 +65,12 @@ class SpaceAwarePrivilegeSectionUI extends Component { showSpacePrivilegeEditor: false, showPrivilegeMatrix: false, role: null, - editingIndex: -1, + privilegeIndex: -1, }; } public render() { - const { uiCapabilities, privilegeCalculatorFactory } = this.props; + const { uiCapabilities } = this.props; if (!uiCapabilities.spaces.manage) { return ( @@ -113,22 +115,22 @@ class SpaceAwarePrivilegeSectionUI extends Component { } return ( - - {this.renderKibanaPrivileges()} - {this.state.showSpacePrivilegeEditor && ( - - )} - + + + {this.renderKibanaPrivileges()} + {this.state.showSpacePrivilegeEditor && ( + + )} + + ); } @@ -143,10 +145,11 @@ class SpaceAwarePrivilegeSectionUI extends Component { ); @@ -205,14 +208,11 @@ class SpaceAwarePrivilegeSectionUI extends Component { } const viewMatrixButton = ( - ); @@ -250,18 +250,18 @@ class SpaceAwarePrivilegeSectionUI extends Component { private addSpacePrivilege = () => { this.setState({ showSpacePrivilegeEditor: true, - editingIndex: -1, + privilegeIndex: -1, }); }; private onSpacesPrivilegeChange = (role: Role) => { - this.setState({ showSpacePrivilegeEditor: false, editingIndex: -1 }); + this.setState({ showSpacePrivilegeEditor: false, privilegeIndex: -1 }); this.props.onChange(role); }; - private onEditSpacesPrivileges = (spacesIndex: number) => { + private onEditSpacesPrivileges = (privilegeIndex: number) => { this.setState({ - editingIndex: spacesIndex, + privilegeIndex, showSpacePrivilegeEditor: true, }); }; @@ -270,5 +270,3 @@ class SpaceAwarePrivilegeSectionUI extends Component { this.setState({ showSpacePrivilegeEditor: false }); }; } - -export const SpaceAwarePrivilegeSection = injectI18n(SpaceAwarePrivilegeSectionUI); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx index 1e42a926c51f7..70790f785ad58 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx @@ -5,9 +5,10 @@ */ import { EuiComboBox, EuiComboBoxOptionOption, EuiHealth, EuiHighlight } from '@elastic/eui'; -import { InjectedIntl } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import React, { Component } from 'react'; -import { Space, getSpaceColor } from '../../../../../../../../spaces/public'; +import { getSpaceColor } from '../../../../../../../../spaces/public'; +import { Space } from '../../../../../../../../spaces/common/model/space'; const spaceToOption = (space?: Space, currentSelection?: 'global' | 'spaces') => { if (!space) { @@ -32,7 +33,6 @@ interface Props { selectedSpaceIds: string[]; onChange: (spaceIds: string[]) => void; disabled?: boolean; - intl: InjectedIntl; } export class SpaceSelector extends Component { @@ -51,8 +51,7 @@ export class SpaceSelector extends Component { return ( { + it('renders a button with the provided text', () => { + const wrapper = mountWithIntl(); + expect(wrapper.find(EuiButtonEmpty).text()).toEqual('hello world'); + expect(wrapper.find(EuiContextMenuPanel)).toHaveLength(0); + }); + + it('clicking the button renders a context menu with the provided spaces', () => { + const wrapper = mountWithIntl(); + wrapper.find(EuiButtonEmpty).simulate('click'); + wrapper.update(); + + const menu = wrapper.find(EuiContextMenuPanel); + expect(menu).toHaveLength(1); + + const items = menu.find(EuiContextMenuItem); + expect(items).toHaveLength(spaces.length); + + spaces.forEach((space, index) => { + const spaceAvatar = items.at(index).find(SpaceAvatar); + expect(spaceAvatar.props().space).toEqual(space); + }); + + expect(wrapper.find(EuiFieldSearch)).toHaveLength(0); + }); + + it('renders a search box when there are 8 or more spaces', () => { + const lotsOfSpaces = [1, 2, 3, 4, 5, 6, 7, 8].map(num => ({ + id: `space-${num}`, + name: `Space ${num}`, + disabledFeatures: [], + })); + + const wrapper = mountWithIntl( + + ); + wrapper.find(EuiButtonEmpty).simulate('click'); + wrapper.update(); + + const menu = wrapper.find(EuiContextMenuPanel).first(); + const items = menu.find(EuiContextMenuItem); + expect(items).toHaveLength(lotsOfSpaces.length); + + const searchField = wrapper.find(EuiFieldSearch); + expect(searchField).toHaveLength(1); + + searchField.props().onSearch!('Space 6'); + wrapper.update(); + expect(wrapper.find(SpaceAvatar)).toHaveLength(1); + + searchField.props().onSearch!('this does not match'); + wrapper.update(); + expect(wrapper.find(SpaceAvatar)).toHaveLength(0); + + const updatedMenu = wrapper.find(EuiContextMenuPanel).first(); + expect(updatedMenu.text()).toMatchInlineSnapshot(`"Spaces no spaces found "`); + }); + + it('can close its popover', () => { + const wrapper = mountWithIntl(); + wrapper.find(EuiButtonEmpty).simulate('click'); + wrapper.update(); + + expect(wrapper.find(EuiPopover).props().isOpen).toEqual(true); + + wrapper + .find(EuiPopover) + .props() + .closePopover(); + + wrapper.update(); + + expect(wrapper.find(EuiPopover).props().isOpen).toEqual(false); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx index f8b2991a844f7..92e42ec811afc 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx @@ -12,14 +12,14 @@ import { EuiPopover, EuiText, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import React, { Component } from 'react'; import { Space, SpaceAvatar } from '../../../../../../spaces/public'; import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../../../../spaces/common'; interface Props { spaces: Space[]; - intl: InjectedIntl; buttonText: string; } @@ -59,15 +59,13 @@ export class SpacesPopoverList extends Component { } private getMenuPanel = () => { - const { intl } = this.props; const { searchTerm } = this.state; const items = this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem); const panelProps = { className: 'spcMenu', - title: intl.formatMessage({ - id: 'xpack.security.management.editRole.spacesPopoverList.popoverTitle', + title: i18n.translate('xpack.security.management.editRole.spacesPopoverList.popoverTitle', { defaultMessage: 'Spaces', }), watchedItemProps: ['data-search-term'], @@ -141,15 +139,16 @@ export class SpacesPopoverList extends Component { }; private renderSearchField = () => { - const { intl } = this.props; return (
{ !knownActions.includes(action)); + + const hasAllRequested = + knownActions.length > 0 && candidateActions.length > 0 && missing.length === 0; + + return { + missing, + hasAllRequested, + }; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts new file mode 100644 index 0000000000000..a1f1e36e8df86 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createRawKibanaPrivileges } from '../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../__fixtures__/kibana_features'; +import { KibanaPrivileges } from './kibana_privileges'; +import { RoleKibanaPrivilege } from '../../../../common/model'; +import { KibanaPrivilege } from './kibana_privilege'; + +describe('KibanaPrivileges', () => { + describe('#getBasePrivileges', () => { + it('returns the space base privileges for a non-global entry', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const entry: RoleKibanaPrivilege = { + base: [], + feature: {}, + spaces: ['foo'], + }; + + const basePrivileges = kibanaPrivileges.getBasePrivileges(entry); + + const expectedPrivileges = rawPrivileges.space; + + expect(basePrivileges).toHaveLength(2); + expect(basePrivileges[0]).toMatchObject({ + id: 'all', + actions: expectedPrivileges.all, + }); + expect(basePrivileges[1]).toMatchObject({ + id: 'read', + actions: expectedPrivileges.read, + }); + }); + + it('returns the global base privileges for a global entry', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const entry: RoleKibanaPrivilege = { + base: [], + feature: {}, + spaces: ['*'], + }; + + const basePrivileges = kibanaPrivileges.getBasePrivileges(entry); + + const expectedPrivileges = rawPrivileges.global; + + expect(basePrivileges).toHaveLength(2); + expect(basePrivileges[0]).toMatchObject({ + id: 'all', + actions: expectedPrivileges.all, + }); + expect(basePrivileges[1]).toMatchObject({ + id: 'read', + actions: expectedPrivileges.read, + }); + }); + }); + + describe('#createCollectionFromRoleKibanaPrivileges', () => { + it('creates a collection from a role with no privileges assigned', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const assignedPrivileges: RoleKibanaPrivilege[] = []; + kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(assignedPrivileges); + }); + + it('creates a collection ignoring unknown privileges', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const assignedPrivileges: RoleKibanaPrivilege[] = [ + { + base: ['read', 'some-unknown-base-privilege'], + feature: {}, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['read', 'cool_all', 'some-unknown-feature-privilege'], + some_unknown_feature: ['all'], + }, + spaces: ['foo'], + }, + ]; + kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(assignedPrivileges); + }); + + it('creates a collection using all assigned privileges, and only the assigned privileges', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const assignedPrivileges: RoleKibanaPrivilege[] = [ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['read', 'cool_all'], + }, + spaces: ['foo'], + }, + ]; + const collection = kibanaPrivileges.createCollectionFromRoleKibanaPrivileges( + assignedPrivileges + ); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_excluded_sub_features.read]) + ) + ).toEqual(true); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_excluded_sub_features.all]) + ) + ).toEqual(false); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_sub_features.cool_all]) + ) + ).toEqual(true); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_sub_features.cool_toggle_1]) + ) + ).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts new file mode 100644 index 0000000000000..d8d75e90847e3 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts @@ -0,0 +1,86 @@ +/* + * 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 { RawKibanaPrivileges, RoleKibanaPrivilege } from '../../../../common/model'; +import { KibanaPrivilege } from './kibana_privilege'; +import { PrivilegeCollection } from './privilege_collection'; +import { SecuredFeature } from './secured_feature'; +import { Feature } from '../../../../../features/common'; +import { isGlobalPrivilegeDefinition } from '../edit_role/privilege_utils'; + +function toBasePrivilege(entry: [string, string[]]): [string, KibanaPrivilege] { + const [privilegeId, actions] = entry; + return [privilegeId, new KibanaPrivilege(privilegeId, actions)]; +} + +function recordsToBasePrivilegeMap( + record: Record +): ReadonlyMap { + return new Map(Object.entries(record).map(entry => toBasePrivilege(entry))); +} + +export class KibanaPrivileges { + private global: ReadonlyMap; + + private spaces: ReadonlyMap; + + private feature: ReadonlyMap; + + constructor(rawKibanaPrivileges: RawKibanaPrivileges, features: Feature[]) { + this.global = recordsToBasePrivilegeMap(rawKibanaPrivileges.global); + this.spaces = recordsToBasePrivilegeMap(rawKibanaPrivileges.space); + this.feature = new Map( + features.map(feature => { + const rawPrivs = rawKibanaPrivileges.features[feature.id]; + return [feature.id, new SecuredFeature(feature.toRaw(), rawPrivs)]; + }) + ); + } + + public getBasePrivileges(entry: RoleKibanaPrivilege) { + if (isGlobalPrivilegeDefinition(entry)) { + return Array.from(this.global.values()); + } + return Array.from(this.spaces.values()); + } + + public getSecuredFeature(featureId: string) { + return this.feature.get(featureId)!; + } + + public getSecuredFeatures() { + return Array.from(this.feature.values()); + } + + public createCollectionFromRoleKibanaPrivileges(roleKibanaPrivileges: RoleKibanaPrivilege[]) { + const filterAssigned = (assignedPrivileges: string[]) => (privilege: KibanaPrivilege) => + assignedPrivileges.includes(privilege.id); + + const privileges: KibanaPrivilege[] = roleKibanaPrivileges + .map(entry => { + const assignedBasePrivileges = this.getBasePrivileges(entry).filter( + filterAssigned(entry.base) + ); + + const assignedFeaturePrivileges: KibanaPrivilege[][] = Object.entries(entry.feature).map( + ([featureId, assignedFeaturePrivs]) => { + return this.getFeaturePrivileges(featureId).filter( + filterAssigned(assignedFeaturePrivs) + ); + } + ); + + return [assignedBasePrivileges, assignedFeaturePrivileges].flat(2); + }) + .flat(); + + return new PrivilegeCollection(privileges); + } + + private getFeaturePrivileges(featureId: string) { + return this.getSecuredFeature(featureId)?.getAllPrivileges() ?? []; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts b/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts new file mode 100644 index 0000000000000..9ed460fe734ef --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.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. + */ + +import { KibanaPrivilege } from './kibana_privilege'; +import { FeatureKibanaPrivileges } from '../../../../../features/public'; + +export class PrimaryFeaturePrivilege extends KibanaPrivilege { + constructor( + id: string, + protected readonly config: FeatureKibanaPrivileges, + public readonly actions: string[] = [] + ) { + super(id, actions); + } + + public isMinimalFeaturePrivilege() { + return this.id.startsWith('minimal_'); + } + + public getMinimalPrivilegeId() { + if (this.isMinimalFeaturePrivilege()) { + return this.id; + } + return `minimal_${this.id}`; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts b/x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts new file mode 100644 index 0000000000000..6b1c3785721b3 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { KibanaPrivilege } from './kibana_privilege'; +import { PrivilegeCollection } from './privilege_collection'; + +describe('PrivilegeCollection', () => { + describe('#grantsPrivilege', () => { + it('returns true when the collection contains the same privilege being tested', () => { + const privilege = new KibanaPrivilege('some-privilege', ['action:foo', 'action:bar']); + const collection = new PrivilegeCollection([privilege]); + + expect(collection.grantsPrivilege(privilege)).toEqual(true); + }); + + it('returns false when a non-empty collection tests an empty privilege', () => { + const privilege = new KibanaPrivilege('some-privilege', ['action:foo', 'action:bar']); + const collection = new PrivilegeCollection([privilege]); + + expect(collection.grantsPrivilege(new KibanaPrivilege('test', []))).toEqual(false); + }); + + it('returns true for collections comprised of multiple privileges, with actions spanning them', () => { + const collection = new PrivilegeCollection([ + new KibanaPrivilege('privilege1', ['action:foo', 'action:bar']), + new KibanaPrivilege('privilege1', ['action:baz']), + ]); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', ['action:foo', 'action:bar', 'action:baz']) + ) + ).toEqual(true); + }); + + it('returns false for collections which do not contain all necessary actions', () => { + const collection = new PrivilegeCollection([ + new KibanaPrivilege('privilege1', ['action:foo', 'action:bar']), + new KibanaPrivilege('privilege1', ['action:baz']), + ]); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', ['action:foo', 'action:bar', 'action:baz', 'actions:secret']) + ) + ).toEqual(false); + }); + + it('returns false for collections which contain no privileges', () => { + const collection = new PrivilegeCollection([]); + + expect(collection.grantsPrivilege(new KibanaPrivilege('test', ['action:foo']))).toEqual( + false + ); + }); + + it('returns false for collections which contain no privileges, even if the requested privilege has no actions', () => { + const collection = new PrivilegeCollection([]); + + expect(collection.grantsPrivilege(new KibanaPrivilege('test', []))).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/model/privilege_collection.ts b/x-pack/plugins/security/public/management/roles/model/privilege_collection.ts new file mode 100644 index 0000000000000..cbbd22857666e --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/privilege_collection.ts @@ -0,0 +1,33 @@ +/* + * 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 { KibanaPrivilege } from './kibana_privilege'; + +export class PrivilegeCollection { + private actions: ReadonlySet; + + constructor(privileges: KibanaPrivilege[]) { + this.actions = new Set( + privileges.reduce((acc, priv) => [...acc, ...priv.actions], [] as string[]) + ); + } + + public grantsPrivilege(privilege: KibanaPrivilege) { + return this.checkActions(this.actions, privilege.actions).hasAllRequested; + } + + private checkActions(knownActions: ReadonlySet, candidateActions: string[]) { + const missing = candidateActions.filter(action => !knownActions.has(action)); + + const hasAllRequested = + knownActions.size > 0 && candidateActions.length > 0 && missing.length === 0; + + return { + missing, + hasAllRequested, + }; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/secured_feature.ts b/x-pack/plugins/security/public/management/roles/model/secured_feature.ts new file mode 100644 index 0000000000000..7fc466a70b984 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/secured_feature.ts @@ -0,0 +1,77 @@ +/* + * 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 { Feature, FeatureConfig } from '../../../../../features/common'; +import { PrimaryFeaturePrivilege } from './primary_feature_privilege'; +import { SecuredSubFeature } from './secured_sub_feature'; +import { SubFeaturePrivilege } from './sub_feature_privilege'; + +export class SecuredFeature extends Feature { + private readonly primaryFeaturePrivileges: PrimaryFeaturePrivilege[]; + + private readonly minimalPrimaryFeaturePrivileges: PrimaryFeaturePrivilege[]; + + private readonly subFeaturePrivileges: SubFeaturePrivilege[]; + + private readonly securedSubFeatures: SecuredSubFeature[]; + + constructor(config: FeatureConfig, actionMapping: { [privilegeId: string]: string[] } = {}) { + super(config); + this.primaryFeaturePrivileges = Object.entries(this.config.privileges || {}).map( + ([id, privilege]) => new PrimaryFeaturePrivilege(id, privilege, actionMapping[id]) + ); + + if (this.config.subFeatures?.length ?? 0 > 0) { + this.minimalPrimaryFeaturePrivileges = Object.entries(this.config.privileges || {}).map( + ([id, privilege]) => + new PrimaryFeaturePrivilege(`minimal_${id}`, privilege, actionMapping[`minimal_${id}`]) + ); + } else { + this.minimalPrimaryFeaturePrivileges = []; + } + + this.securedSubFeatures = + this.config.subFeatures?.map(sf => new SecuredSubFeature(sf, actionMapping)) ?? []; + + this.subFeaturePrivileges = this.securedSubFeatures.reduce((acc, subFeature) => { + return [...acc, ...subFeature.privilegeIterator()]; + }, [] as SubFeaturePrivilege[]); + } + + public getPrivilegesTooltip() { + return this.config.privilegesTooltip; + } + + public getAllPrivileges() { + return [ + ...this.primaryFeaturePrivileges, + ...this.minimalPrimaryFeaturePrivileges, + ...this.subFeaturePrivileges, + ]; + } + + public getPrimaryFeaturePrivileges( + { includeMinimalFeaturePrivileges }: { includeMinimalFeaturePrivileges: boolean } = { + includeMinimalFeaturePrivileges: false, + } + ) { + return includeMinimalFeaturePrivileges + ? [this.primaryFeaturePrivileges, this.minimalPrimaryFeaturePrivileges].flat() + : [...this.primaryFeaturePrivileges]; + } + + public getMinimalFeaturePrivileges() { + return [...this.minimalPrimaryFeaturePrivileges]; + } + + public getSubFeaturePrivileges() { + return [...this.subFeaturePrivileges]; + } + + public getSubFeatures() { + return [...this.securedSubFeatures]; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/secured_sub_feature.ts b/x-pack/plugins/security/public/management/roles/model/secured_sub_feature.ts new file mode 100644 index 0000000000000..3d69e5e709bb0 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/secured_sub_feature.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SubFeature, SubFeatureConfig } from '../../../../../features/common'; +import { SubFeaturePrivilege } from './sub_feature_privilege'; +import { SubFeaturePrivilegeGroup } from './sub_feature_privilege_group'; + +export class SecuredSubFeature extends SubFeature { + public readonly privileges: SubFeaturePrivilege[]; + + constructor( + config: SubFeatureConfig, + private readonly actionMapping: { [privilegeId: string]: string[] } = {} + ) { + super(config); + + this.privileges = []; + for (const privilege of this.privilegeIterator()) { + this.privileges.push(privilege); + } + } + + public getPrivilegeGroups() { + return this.privilegeGroups.map(pg => new SubFeaturePrivilegeGroup(pg, this.actionMapping)); + } + + public *privilegeIterator({ + predicate = () => true, + }: { + predicate?: (privilege: SubFeaturePrivilege, feature: SecuredSubFeature) => boolean; + } = {}): IterableIterator { + for (const group of this.privilegeGroups) { + yield* group.privileges + .map(gp => new SubFeaturePrivilege(gp, this.actionMapping[gp.id])) + .filter(privilege => predicate(privilege, this)); + } + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.ts b/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.ts new file mode 100644 index 0000000000000..e149a59e12edf --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.ts @@ -0,0 +1,21 @@ +/* + * 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 { SubFeaturePrivilegeConfig } from '../../../../../features/public'; +import { KibanaPrivilege } from './kibana_privilege'; + +export class SubFeaturePrivilege extends KibanaPrivilege { + constructor( + protected readonly subPrivilegeConfig: SubFeaturePrivilegeConfig, + public readonly actions: string[] = [] + ) { + super(subPrivilegeConfig.id, actions); + } + + public get name() { + return this.subPrivilegeConfig.name; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts b/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts new file mode 100644 index 0000000000000..b437649236e27 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts @@ -0,0 +1,25 @@ +/* + * 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 { SubFeaturePrivilegeGroupConfig } from '../../../../../features/common'; +import { SubFeaturePrivilege } from './sub_feature_privilege'; + +export class SubFeaturePrivilegeGroup { + constructor( + private readonly config: SubFeaturePrivilegeGroupConfig, + private readonly actionMapping: { [privilegeId: string]: string[] } = {} + ) {} + + public get groupType() { + return this.config.groupType; + } + + public get privileges() { + return this.config.privileges.map( + p => new SubFeaturePrivilege(p, this.actionMapping[p.id] || []) + ); + } +} diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx index 5936409eb6e8b..96051dbd7fa56 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx @@ -17,17 +17,22 @@ jest.mock('./edit_role', () => ({ import { rolesManagementApp } from './roles_management_app'; import { coreMock } from '../../../../../../src/core/public/mocks'; +import { featuresPluginMock } from '../../../../features/public/mocks'; async function mountApp(basePath: string) { const { fatalErrors } = coreMock.createSetup(); const container = document.createElement('div'); const setBreadcrumbs = jest.fn(); + const featuresStart = featuresPluginMock.createStart(); + const unmount = await rolesManagementApp .create({ license: licenseMock.create(), fatalErrors, - getStartServices: jest.fn().mockResolvedValue([coreMock.createStart(), { data: {} }]), + getStartServices: jest + .fn() + .mockResolvedValue([coreMock.createStart(), { data: {}, features: featuresStart }]), }) .mount({ basePath, element: container, setBreadcrumbs }); diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.tsx index 4265cac22ece0..e1a10fdc2b8c3 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.tsx @@ -36,7 +36,7 @@ export const rolesManagementApp = Object.freeze({ async mount({ basePath, element, setBreadcrumbs }) { const [ { application, docLinks, http, i18n: i18nStart, injectedMetadata, notifications }, - { data }, + { data, features }, ] = await getStartServices(); const rolesBreadcrumbs = [ @@ -77,6 +77,7 @@ export const rolesManagementApp = Object.freeze({ userAPIClient={new UserAPIClient(http)} indicesAPIClient={new IndicesAPIClient(http)} privilegesAPIClient={new PrivilegesAPIClient(http)} + getFeatures={features.getFeatures} http={http} notifications={notifications} fatalErrors={fatalErrors} diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 3d0ef3b2cabc7..122b26378d22b 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -15,6 +15,7 @@ import { coreMock } from '../../../../src/core/public/mocks'; import { managementPluginMock } from '../../../../src/plugins/management/public/mocks'; import { licensingMock } from '../../licensing/public/mocks'; import { ManagementService } from './management'; +import { FeaturesPluginStart } from '../../features/public'; describe('Security Plugin', () => { beforeAll(() => { @@ -86,6 +87,7 @@ describe('Security Plugin', () => { expect( plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { data: {} as DataPublicPluginStart, + features: {} as FeaturesPluginStart, }) ).toBeUndefined(); }); @@ -110,6 +112,7 @@ describe('Security Plugin', () => { plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { data: {} as DataPublicPluginStart, + features: {} as FeaturesPluginStart, management: managementStartMock, }); @@ -139,6 +142,7 @@ describe('Security Plugin', () => { plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { data: {} as DataPublicPluginStart, + features: {} as FeaturesPluginStart, }); expect(() => plugin.stop()).not.toThrow(); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index dcd90b1738f10..38ef552e75a9e 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -11,6 +11,7 @@ import { Plugin, PluginInitializerContext, } from '../../../../src/core/public'; +import { FeaturesPluginStart } from '../../features/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { FeatureCatalogueCategory, @@ -40,6 +41,7 @@ export interface PluginSetupDependencies { export interface PluginStartDependencies { data: DataPublicPluginStart; + features: FeaturesPluginStart; management?: ManagementStart; } diff --git a/x-pack/plugins/security/server/authorization/actions/actions.ts b/x-pack/plugins/security/server/authorization/actions/actions.ts index 4bf7a41550cc6..00293e88abe76 100644 --- a/x-pack/plugins/security/server/authorization/actions/actions.ts +++ b/x-pack/plugins/security/server/authorization/actions/actions.ts @@ -15,13 +15,6 @@ import { UIActions } from './ui'; * by the various `checkPrivilegesWithRequest` derivatives */ export class Actions { - /** - * The allHack action is used to differentiate the `all` privilege from the `read` privilege - * for those applications which register the same set of actions for both privileges. This is a - * temporary hack until we remove this assumption in the role management UI - */ - public readonly allHack = 'allHack:'; - public readonly api = new ApiActions(this.versionNumber); public readonly app = new AppActions(this.versionNumber); diff --git a/x-pack/plugins/security/server/authorization/actions/api.test.ts b/x-pack/plugins/security/server/authorization/actions/api.test.ts index 60a42ba6a78a2..d6e7a5d242d49 100644 --- a/x-pack/plugins/security/server/authorization/actions/api.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/api.test.ts @@ -8,13 +8,6 @@ import { ApiActions } from './api'; const version = '1.0.0-zeta1'; -describe('#all', () => { - test('returns `api:${version}:*`', () => { - const apiActions = new ApiActions(version); - expect(apiActions.all).toBe('api:1.0.0-zeta1:*'); - }); -}); - describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((operation: any) => { test(`operation of ${JSON.stringify(operation)} throws error`, () => { diff --git a/x-pack/plugins/security/server/authorization/actions/api.ts b/x-pack/plugins/security/server/authorization/actions/api.ts index 35e614e7a03d4..60b135acc15ef 100644 --- a/x-pack/plugins/security/server/authorization/actions/api.ts +++ b/x-pack/plugins/security/server/authorization/actions/api.ts @@ -12,10 +12,6 @@ export class ApiActions { this.prefix = `api:${versionNumber}:`; } - public get all(): string { - return `${this.prefix}*`; - } - public get(operation: string) { if (!operation || !isString(operation)) { throw new Error('operation is required and must be a string'); diff --git a/x-pack/plugins/security/server/authorization/actions/app.test.ts b/x-pack/plugins/security/server/authorization/actions/app.test.ts index a696fd8693997..74c372a0699a2 100644 --- a/x-pack/plugins/security/server/authorization/actions/app.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/app.test.ts @@ -8,13 +8,6 @@ import { AppActions } from './app'; const version = '1.0.0-zeta1'; -describe('#all', () => { - test('returns `app:${version}:*`', () => { - const appActions = new AppActions(version); - expect(appActions.all).toBe('app:1.0.0-zeta1:*'); - }); -}); - describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((appid: any) => { test(`appId of ${JSON.stringify(appid)} throws error`, () => { diff --git a/x-pack/plugins/security/server/authorization/actions/app.ts b/x-pack/plugins/security/server/authorization/actions/app.ts index ed0854e8a805b..227c658619175 100644 --- a/x-pack/plugins/security/server/authorization/actions/app.ts +++ b/x-pack/plugins/security/server/authorization/actions/app.ts @@ -12,10 +12,6 @@ export class AppActions { this.prefix = `app:${versionNumber}:`; } - public get all(): string { - return `${this.prefix}*`; - } - public get(appId: string) { if (!appId || !isString(appId)) { throw new Error('appId is required and must be a string'); diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts index 5e5da7233d93e..9e8bfb6ad795f 100644 --- a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts @@ -8,13 +8,6 @@ import { SavedObjectActions } from './saved_object'; const version = '1.0.0-zeta1'; -describe('#all', () => { - test(`returns saved_object:*`, () => { - const savedObjectActions = new SavedObjectActions(version); - expect(savedObjectActions.all).toBe('saved_object:1.0.0-zeta1:*'); - }); -}); - describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((type: any) => { test(`type of ${JSON.stringify(type)} throws error`, () => { diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.ts index 4a0bc7cda1b8f..e3a02d3807399 100644 --- a/x-pack/plugins/security/server/authorization/actions/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/actions/saved_object.ts @@ -13,10 +13,6 @@ export class SavedObjectActions { this.prefix = `saved_object:${versionNumber}:`; } - public get all(): string { - return `${this.prefix}*`; - } - public get(type: string, operation: string): string { if (!type || !isString(type)) { throw new Error('type is required and must be a string'); diff --git a/x-pack/plugins/security/server/authorization/actions/ui.test.ts b/x-pack/plugins/security/server/authorization/actions/ui.test.ts index f91b7baf78baa..32827822117d0 100644 --- a/x-pack/plugins/security/server/authorization/actions/ui.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/ui.test.ts @@ -8,34 +8,6 @@ import { UIActions } from './ui'; const version = '1.0.0-zeta1'; -describe('#all', () => { - test('returns `ui:${version}:*`', () => { - const uiActions = new UIActions(version); - expect(uiActions.all).toBe('ui:1.0.0-zeta1:*'); - }); -}); - -describe('#allNavlinks', () => { - test('returns `ui:${version}:navLinks/*`', () => { - const uiActions = new UIActions(version); - expect(uiActions.allNavLinks).toBe('ui:1.0.0-zeta1:navLinks/*'); - }); -}); - -describe('#allCatalogueEntries', () => { - test('returns `ui:${version}:catalogue/*`', () => { - const uiActions = new UIActions(version); - expect(uiActions.allCatalogueEntries).toBe('ui:1.0.0-zeta1:catalogue/*'); - }); -}); - -describe('#allManagementLinks', () => { - test('returns `ui:${version}:management/*`', () => { - const uiActions = new UIActions(version); - expect(uiActions.allManagementLinks).toBe('ui:1.0.0-zeta1:management/*'); - }); -}); - describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((featureId: any) => { test(`featureId of ${JSON.stringify(featureId)} throws error`, () => { diff --git a/x-pack/plugins/security/server/authorization/actions/ui.ts b/x-pack/plugins/security/server/authorization/actions/ui.ts index 9e77c319a9b3a..3dae9a47b3827 100644 --- a/x-pack/plugins/security/server/authorization/actions/ui.ts +++ b/x-pack/plugins/security/server/authorization/actions/ui.ts @@ -14,22 +14,6 @@ export class UIActions { this.prefix = `ui:${versionNumber}:`; } - public get all(): string { - return `${this.prefix}*`; - } - - public get allNavLinks(): string { - return `${this.prefix}navLinks/*`; - } - - public get allCatalogueEntries(): string { - return `${this.prefix}catalogue/*`; - } - - public get allManagementLinks(): string { - return `${this.prefix}management/*`; - } - public get(featureId: keyof UICapabilities, ...uiCapabilityParts: string[]) { if (!featureId || !isString(featureId)) { throw new Error('featureId is required and must be a string'); diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index 49c9db2d0e6e3..912ae60e12065 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -9,6 +9,7 @@ import { disableUICapabilitiesFactory } from './disable_ui_capabilities'; import { httpServerMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; import { authorizationMock } from './index.mock'; +import { Feature } from '../../../features/server'; type MockAuthzOptions = { rejectCheckPrivileges: any } | { resolveCheckPrivileges: any }; @@ -42,7 +43,15 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, - [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + [ + new Feature({ + id: 'fooFeature', + name: 'Foo Feature', + app: [], + navLinkId: 'foo', + privileges: null, + }), + ], mockLoggers.get(), mockAuthz ); @@ -108,7 +117,15 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, - [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + [ + new Feature({ + id: 'fooFeature', + name: 'Foo Feature', + app: [], + navLinkId: 'foo', + privileges: null, + }), + ], mockLoggers.get(), mockAuthz ); @@ -226,20 +243,20 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, [ - { + new Feature({ id: 'fooFeature', name: 'Foo Feature', navLinkId: 'foo', app: [], - privileges: {}, - }, - { + privileges: null, + }), + new Feature({ id: 'barFeature', name: 'Bar Feature', navLinkId: 'bar', app: [], - privileges: {}, - }, + privileges: null, + }), ], loggingServiceMock.create().get(), mockAuthz @@ -312,20 +329,20 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, [ - { + new Feature({ id: 'fooFeature', name: 'Foo Feature', navLinkId: 'foo', app: [], - privileges: {}, - }, - { + privileges: null, + }), + new Feature({ id: 'barFeature', name: 'Bar Feature', navLinkId: 'bar', app: [], - privileges: {}, - }, + privileges: null, + }), ], loggingServiceMock.create().get(), mockAuthz @@ -383,7 +400,15 @@ describe('all', () => { const { all } = disableUICapabilitiesFactory( mockRequest, - [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + [ + new Feature({ + id: 'fooFeature', + name: 'Foo Feature', + app: [], + navLinkId: 'foo', + privileges: null, + }), + ], loggingServiceMock.create().get(), mockAuthz ); diff --git a/x-pack/plugins/security/server/authorization/index.test.ts b/x-pack/plugins/security/server/authorization/index.test.ts index 9e99cae620633..3252053454764 100644 --- a/x-pack/plugins/security/server/authorization/index.test.ts +++ b/x-pack/plugins/security/server/authorization/index.test.ts @@ -93,7 +93,7 @@ test(`returns exposed services`, () => { ); expect(authz.privileges).toBe(mockPrivilegesService); - expect(privilegesFactory).toHaveBeenCalledWith(authz.actions, mockFeaturesService); + expect(privilegesFactory).toHaveBeenCalledWith(authz.actions, mockFeaturesService, mockLicense); expect(authz.mode).toBe(mockAuthorizationMode); expect(authorizationModeFactory).toHaveBeenCalledWith(mockLicense); diff --git a/x-pack/plugins/security/server/authorization/index.ts b/x-pack/plugins/security/server/authorization/index.ts index 4cbc76ecb6be4..f065c9cfd90ba 100644 --- a/x-pack/plugins/security/server/authorization/index.ts +++ b/x-pack/plugins/security/server/authorization/index.ts @@ -35,6 +35,7 @@ import { SecurityLicense } from '../../common/licensing'; export { Actions } from './actions'; export { CheckSavedObjectsPrivileges } from './check_saved_objects_privileges'; +export { featurePrivilegeIterator } from './privileges'; interface SetupAuthorizationParams { packageVersion: string; @@ -80,7 +81,7 @@ export function setupAuthorization({ clusterClient, applicationName ); - const privileges = privilegesFactory(actions, featuresService); + const privileges = privilegesFactory(actions, featuresService, license); const logger = loggers.get('authorization'); const authz = { @@ -120,7 +121,7 @@ export function setupAuthorization({ }, registerPrivilegesWithCluster: async () => { - validateFeaturePrivileges(actions, featuresService.getFeatures()); + validateFeaturePrivileges(featuresService.getFeatures()); await registerPrivilegesWithCluster(logger, privileges, applicationName, clusterClient); }, diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts index c874886d908eb..514d6734b47ba 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts @@ -9,7 +9,7 @@ import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeAppBuilder extends BaseFeaturePrivilegeBuilder { public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { - const appIds = privilegeDefinition.app || feature.app; + const appIds = privilegeDefinition.app; if (!appIds) { return []; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts index 3dbe71db93f4a..fc15aff32b975 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts @@ -9,7 +9,7 @@ import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeCatalogueBuilder extends BaseFeaturePrivilegeBuilder { public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { - const catalogueEntries = privilegeDefinition.catalogue || feature.catalogue; + const catalogueEntries = privilegeDefinition.catalogue; if (!catalogueEntries) { return []; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts index 0180554a47ccc..7a2bb87d72b45 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts @@ -9,7 +9,7 @@ import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeManagementBuilder extends BaseFeaturePrivilegeBuilder { public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { - const managementSections = privilegeDefinition.management || feature.management; + const managementSections = privilegeDefinition.management; if (!managementSections) { return []; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts new file mode 100644 index 0000000000000..7d92eacfe6b35 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts @@ -0,0 +1,891 @@ +/* + * 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 { Feature } from '../../../../../features/server'; +import { featurePrivilegeIterator } from './feature_privilege_iterator'; + +describe('featurePrivilegeIterator', () => { + it('handles features with no privileges', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + privileges: null, + app: [], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toHaveLength(0); + }); + + it('handles features with no sub-features', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + app: ['foo'], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('filters privileges using the provided predicate', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + app: ['foo'], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + predicate: privilegeId => privilegeId === 'all', + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('ignores sub features when `augmentWithSubFeaturePrivileges` is false', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: false, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('ignores sub features when `includeIn` is none, even if `augmentWithSubFeaturePrivileges` is true', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'none', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('includes sub feature privileges into both all and read when`augmentWithSubFeaturePrivileges` is true and `includeIn: read`', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api', 'sub-feature-api'], + app: ['foo', 'sub-app'], + catalogue: ['foo-catalogue', 'sub-catalogue'], + management: { + section: ['foo-management', 'other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-type', 'all-sub-type'], + read: ['read-type', 'read-sub-type'], + }, + ui: ['ui-action', 'ui-sub-type'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api', 'sub-feature-api'], + app: ['foo', 'sub-app'], + catalogue: ['foo-catalogue', 'sub-catalogue'], + management: { + section: ['foo-management', 'other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-type', 'read-sub-type'], + }, + ui: ['ui-action', 'ui-sub-type'], + }, + }, + ]); + }); + + it('does not duplicate privileges when merging', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('includes sub feature privileges into both all and read when`augmentWithSubFeaturePrivileges` is true and `includeIn: all`', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'all', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api', 'sub-feature-api'], + app: ['foo', 'sub-app'], + catalogue: ['foo-catalogue', 'sub-catalogue'], + management: { + section: ['foo-management', 'other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-type', 'all-sub-type'], + read: ['read-type', 'read-sub-type'], + }, + ui: ['ui-action', 'ui-sub-type'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it(`can augment primary feature privileges even if they don't specify their own`, () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + }, + ]); + }); + + it(`can augment primary feature privileges even if the sub-feature privileges don't specify their own`, () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts new file mode 100644 index 0000000000000..e239a6e280aec --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts @@ -0,0 +1,83 @@ +/* + * 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 _ from 'lodash'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { subFeaturePrivilegeIterator } from './sub_feature_privilege_iterator'; + +interface IteratorOptions { + augmentWithSubFeaturePrivileges: boolean; + predicate?: (privilegeId: string, privilege: FeatureKibanaPrivileges) => boolean; +} + +export function* featurePrivilegeIterator( + feature: Feature, + options: IteratorOptions +): IterableIterator<{ privilegeId: string; privilege: FeatureKibanaPrivileges }> { + for (const entry of Object.entries(feature.privileges ?? {})) { + const [privilegeId, privilege] = entry; + + if (options.predicate && !options.predicate(privilegeId, privilege)) { + continue; + } + + if (options.augmentWithSubFeaturePrivileges) { + yield { privilegeId, privilege: mergeWithSubFeatures(privilegeId, privilege, feature) }; + } else { + yield { privilegeId, privilege }; + } + } +} + +function mergeWithSubFeatures( + privilegeId: string, + privilege: FeatureKibanaPrivileges, + feature: Feature +) { + const mergedConfig = _.cloneDeep(privilege); + for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature)) { + if (subFeaturePrivilege.includeIn !== 'read' && subFeaturePrivilege.includeIn !== privilegeId) { + continue; + } + + mergedConfig.api = mergeArrays(mergedConfig.api, subFeaturePrivilege.api); + + mergedConfig.app = mergeArrays(mergedConfig.app, subFeaturePrivilege.app); + + mergedConfig.catalogue = mergeArrays(mergedConfig.catalogue, subFeaturePrivilege.catalogue); + + const managementEntries = Object.entries(mergedConfig.management ?? {}); + const subFeatureManagementEntries = Object.entries(subFeaturePrivilege.management ?? {}); + + mergedConfig.management = [managementEntries, subFeatureManagementEntries] + .flat() + .reduce((acc, [sectionId, managementApps]) => { + return { + ...acc, + [sectionId]: mergeArrays(acc[sectionId], managementApps), + }; + }, {} as Record); + + mergedConfig.ui = mergeArrays(mergedConfig.ui, subFeaturePrivilege.ui); + + mergedConfig.savedObject.all = mergeArrays( + mergedConfig.savedObject.all, + subFeaturePrivilege.savedObject.all + ); + + mergedConfig.savedObject.read = mergeArrays( + mergedConfig.savedObject.read, + subFeaturePrivilege.savedObject.read + ); + } + return mergedConfig; +} + +function mergeArrays(input1: string[] | undefined, input2: string[] | undefined) { + const first = input1 ?? []; + const second = input2 ?? []; + return Array.from(new Set([...first, ...second])); +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/index.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/index.ts similarity index 57% rename from x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/index.ts rename to x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/index.ts index 253dcaed9f19e..24af524c350b0 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/index.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/index.ts @@ -4,6 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { defaultPrivilegeDefinition } from './default_privilege_definition'; -export { buildRole, BuildRoleOpts } from './build_role'; -export * from './common_allowed_privileges'; +export { featurePrivilegeIterator } from './feature_privilege_iterator'; +export { subFeaturePrivilegeIterator } from './sub_feature_privilege_iterator'; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts new file mode 100644 index 0000000000000..b288262be25c6 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts @@ -0,0 +1,18 @@ +/* + * 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 { SubFeaturePrivilegeConfig } from '../../../../../features/common'; +import { Feature } from '../../../../../features/server'; + +export function* subFeaturePrivilegeIterator( + feature: Feature +): IterableIterator { + for (const subFeature of feature.subFeatures) { + for (const group of subFeature.privilegeGroups) { + yield* group.privileges; + } + } +} diff --git a/x-pack/plugins/security/server/authorization/privileges/index.ts b/x-pack/plugins/security/server/authorization/privileges/index.ts index 22b9cd45d4c0f..e12a33ce509bd 100644 --- a/x-pack/plugins/security/server/authorization/privileges/index.ts +++ b/x-pack/plugins/security/server/authorization/privileges/index.ts @@ -5,3 +5,4 @@ */ export { privilegesFactory, PrivilegesService } from './privileges'; +export { featurePrivilegeIterator } from './feature_privilege_iterator'; diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 38d4d413c591e..3d25fc03f568b 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -11,9 +11,9 @@ import { privilegesFactory } from './privileges'; const actions = new Actions('1.0.0-zeta1'); describe('features', () => { - test('actions defined at the feature cascade to the privileges', () => { + test('actions defined at the feature do not cascade to the privileges', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo-feature', name: 'Foo Feature', icon: 'arrowDown', @@ -39,115 +39,25 @@ describe('features', () => { ui: [], }, }, - }, + }), ]; const mockFeaturesService = { getFeatures: jest.fn().mockReturnValue(features) }; - const privileges = privilegesFactory(actions, mockFeaturesService); - - const actual = privileges.get(); - expect(actual).toHaveProperty('features.foo-feature', { - all: [ - actions.login, - actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - actions.allHack, - ], - read: [ - actions.login, - actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - ], - }); - }); - - test('actions defined at the privilege take precedence', () => { - const features: Feature[] = [ - { - id: 'foo', - name: 'Foo Feature', - icon: 'arrowDown', - app: ['ignore-me-1', 'ignore-me-2'], - catalogue: ['ignore-me-1', 'ignore-me-2'], - management: { - foo: ['ignore-me-1', 'ignore-me-2'], - }, - privileges: { - all: { - app: ['all-app-1', 'all-app-2'], - catalogue: ['catalogue-all-1', 'catalogue-all-2'], - management: { - all: ['all-management-1', 'all-management-2'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - read: { - app: ['read-app-1', 'read-app-2'], - catalogue: ['catalogue-read-1', 'catalogue-read-2'], - management: { - read: ['read-management-1', 'read-management-2'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - }, - }, - ]; - - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const privileges = privilegesFactory(actions, mockFeaturesService, mockLicenseService); const actual = privileges.get(); - expect(actual).toHaveProperty('features.foo', { - all: [ - actions.login, - actions.version, - actions.app.get('all-app-1'), - actions.app.get('all-app-2'), - actions.ui.get('catalogue', 'catalogue-all-1'), - actions.ui.get('catalogue', 'catalogue-all-2'), - actions.ui.get('management', 'all', 'all-management-1'), - actions.ui.get('management', 'all', 'all-management-2'), - actions.allHack, - ], - read: [ - actions.login, - actions.version, - actions.app.get('read-app-1'), - actions.app.get('read-app-2'), - actions.ui.get('catalogue', 'catalogue-read-1'), - actions.ui.get('catalogue', 'catalogue-read-2'), - actions.ui.get('management', 'read', 'read-management-1'), - actions.ui.get('management', 'read', 'read-management-2'), - ], + expect(actual).toHaveProperty('features.foo-feature', { + all: [actions.login, actions.version, actions.ui.get('navLinks', 'kibana:foo')], + read: [actions.login, actions.version, actions.ui.get('navLinks', 'kibana:foo')], }); }); test(`actions only specified at the privilege are alright too`, () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -168,93 +78,100 @@ describe('features', () => { ui: ['read-ui-1', 'read-ui-2'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const expectedAllPrivileges = [ + actions.login, + actions.version, + actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), + actions.savedObject.get('all-savedObject-all-1', 'get'), + actions.savedObject.get('all-savedObject-all-1', 'find'), + actions.savedObject.get('all-savedObject-all-1', 'create'), + actions.savedObject.get('all-savedObject-all-1', 'bulk_create'), + actions.savedObject.get('all-savedObject-all-1', 'update'), + actions.savedObject.get('all-savedObject-all-1', 'bulk_update'), + actions.savedObject.get('all-savedObject-all-1', 'delete'), + actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), + actions.savedObject.get('all-savedObject-all-2', 'get'), + actions.savedObject.get('all-savedObject-all-2', 'find'), + actions.savedObject.get('all-savedObject-all-2', 'create'), + actions.savedObject.get('all-savedObject-all-2', 'bulk_create'), + actions.savedObject.get('all-savedObject-all-2', 'update'), + actions.savedObject.get('all-savedObject-all-2', 'bulk_update'), + actions.savedObject.get('all-savedObject-all-2', 'delete'), + actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), + actions.savedObject.get('all-savedObject-read-1', 'get'), + actions.savedObject.get('all-savedObject-read-1', 'find'), + actions.savedObject.get('all-savedObject-read-2', 'bulk_get'), + actions.savedObject.get('all-savedObject-read-2', 'get'), + actions.savedObject.get('all-savedObject-read-2', 'find'), + actions.ui.get('foo', 'all-ui-1'), + actions.ui.get('foo', 'all-ui-2'), + ]; + + const expectedReadPrivileges = [ + actions.login, + actions.version, + actions.savedObject.get('read-savedObject-all-1', 'bulk_get'), + actions.savedObject.get('read-savedObject-all-1', 'get'), + actions.savedObject.get('read-savedObject-all-1', 'find'), + actions.savedObject.get('read-savedObject-all-1', 'create'), + actions.savedObject.get('read-savedObject-all-1', 'bulk_create'), + actions.savedObject.get('read-savedObject-all-1', 'update'), + actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), + actions.savedObject.get('read-savedObject-all-1', 'delete'), + actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), + actions.savedObject.get('read-savedObject-all-2', 'get'), + actions.savedObject.get('read-savedObject-all-2', 'find'), + actions.savedObject.get('read-savedObject-all-2', 'create'), + actions.savedObject.get('read-savedObject-all-2', 'bulk_create'), + actions.savedObject.get('read-savedObject-all-2', 'update'), + actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), + actions.savedObject.get('read-savedObject-all-2', 'delete'), + actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), + actions.savedObject.get('read-savedObject-read-1', 'get'), + actions.savedObject.get('read-savedObject-read-1', 'find'), + actions.savedObject.get('read-savedObject-read-2', 'bulk_get'), + actions.savedObject.get('read-savedObject-read-2', 'get'), + actions.savedObject.get('read-savedObject-read-2', 'find'), + actions.ui.get('foo', 'read-ui-1'), + actions.ui.get('foo', 'read-ui-2'), + ]; const actual = privileges.get(); expect(actual).toHaveProperty('features.foo', { - all: [ - actions.login, - actions.version, - actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), - actions.savedObject.get('all-savedObject-all-1', 'get'), - actions.savedObject.get('all-savedObject-all-1', 'find'), - actions.savedObject.get('all-savedObject-all-1', 'create'), - actions.savedObject.get('all-savedObject-all-1', 'bulk_create'), - actions.savedObject.get('all-savedObject-all-1', 'update'), - actions.savedObject.get('all-savedObject-all-1', 'bulk_update'), - actions.savedObject.get('all-savedObject-all-1', 'delete'), - actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), - actions.savedObject.get('all-savedObject-all-2', 'get'), - actions.savedObject.get('all-savedObject-all-2', 'find'), - actions.savedObject.get('all-savedObject-all-2', 'create'), - actions.savedObject.get('all-savedObject-all-2', 'bulk_create'), - actions.savedObject.get('all-savedObject-all-2', 'update'), - actions.savedObject.get('all-savedObject-all-2', 'bulk_update'), - actions.savedObject.get('all-savedObject-all-2', 'delete'), - actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), - actions.savedObject.get('all-savedObject-read-1', 'get'), - actions.savedObject.get('all-savedObject-read-1', 'find'), - actions.savedObject.get('all-savedObject-read-2', 'bulk_get'), - actions.savedObject.get('all-savedObject-read-2', 'get'), - actions.savedObject.get('all-savedObject-read-2', 'find'), - actions.ui.get('foo', 'all-ui-1'), - actions.ui.get('foo', 'all-ui-2'), - actions.allHack, - ], - read: [ - actions.login, - actions.version, - actions.savedObject.get('read-savedObject-all-1', 'bulk_get'), - actions.savedObject.get('read-savedObject-all-1', 'get'), - actions.savedObject.get('read-savedObject-all-1', 'find'), - actions.savedObject.get('read-savedObject-all-1', 'create'), - actions.savedObject.get('read-savedObject-all-1', 'bulk_create'), - actions.savedObject.get('read-savedObject-all-1', 'update'), - actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), - actions.savedObject.get('read-savedObject-all-1', 'delete'), - actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), - actions.savedObject.get('read-savedObject-all-2', 'get'), - actions.savedObject.get('read-savedObject-all-2', 'find'), - actions.savedObject.get('read-savedObject-all-2', 'create'), - actions.savedObject.get('read-savedObject-all-2', 'bulk_create'), - actions.savedObject.get('read-savedObject-all-2', 'update'), - actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), - actions.savedObject.get('read-savedObject-all-2', 'delete'), - actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), - actions.savedObject.get('read-savedObject-read-1', 'get'), - actions.savedObject.get('read-savedObject-read-1', 'find'), - actions.savedObject.get('read-savedObject-read-2', 'bulk_get'), - actions.savedObject.get('read-savedObject-read-2', 'get'), - actions.savedObject.get('read-savedObject-read-2', 'find'), - actions.ui.get('foo', 'read-ui-1'), - actions.ui.get('foo', 'read-ui-2'), - ], + all: [...expectedAllPrivileges], + read: [...expectedReadPrivileges], }); }); test(`features with no privileges aren't listed`, () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', app: [], - privileges: {}, - }, + privileges: null, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).not.toHaveProperty('features.foo'); @@ -276,82 +193,9 @@ describe('features', () => { }, ].forEach(({ group, expectManageSpaces, expectGetFeatures }) => { describe(`${group}`, () => { - test('actions defined only at the feature are included in `all` and `read`', () => { - const features: Feature[] = [ - { - id: 'foo', - name: 'Foo Feature', - icon: 'arrowDown', - navLinkId: 'kibana:foo', - app: ['app-1', 'app-2'], - catalogue: ['catalogue-1', 'catalogue-2'], - management: { - foo: ['management-1', 'management-2'], - }, - privileges: { - all: { - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - read: { - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - }, - }, - ]; - - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), - }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); - - const actual = privileges.get(); - expect(actual).toHaveProperty(group, { - all: [ - actions.login, - actions.version, - ...(expectGetFeatures ? [actions.api.get('features')] : []), - ...(expectManageSpaces - ? [ - actions.space.manage, - actions.ui.get('spaces', 'manage'), - actions.ui.get('management', 'kibana', 'spaces'), - ] - : []), - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - actions.allHack, - ], - read: [ - actions.login, - actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - ], - }); - }); - test('actions defined in any feature privilege are included in `all`', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -362,17 +206,6 @@ describe('features', () => { foo: ['ignore-me-1', 'ignore-me-2'], }, privileges: { - bar: { - management: { - 'bar-management': ['bar-management-1', 'bar-management-2'], - }, - catalogue: ['bar-catalogue-1', 'bar-catalogue-2'], - savedObject: { - all: ['bar-savedObject-all-1', 'bar-savedObject-all-2'], - read: ['bar-savedObject-read-1', 'bar-savedObject-read-2'], - }, - ui: ['bar-ui-1', 'bar-ui-2'], - }, all: { management: { 'all-management': ['all-management-1', 'all-management-2'], @@ -396,14 +229,16 @@ describe('features', () => { ui: ['read-ui-1', 'read-ui-2'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -417,39 +252,11 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - actions.ui.get('catalogue', 'bar-catalogue-1'), - actions.ui.get('catalogue', 'bar-catalogue-2'), - actions.ui.get('management', 'bar-management', 'bar-management-1'), - actions.ui.get('management', 'bar-management', 'bar-management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - actions.savedObject.get('bar-savedObject-all-1', 'bulk_get'), - actions.savedObject.get('bar-savedObject-all-1', 'get'), - actions.savedObject.get('bar-savedObject-all-1', 'find'), - actions.savedObject.get('bar-savedObject-all-1', 'create'), - actions.savedObject.get('bar-savedObject-all-1', 'bulk_create'), - actions.savedObject.get('bar-savedObject-all-1', 'update'), - actions.savedObject.get('bar-savedObject-all-1', 'bulk_update'), - actions.savedObject.get('bar-savedObject-all-1', 'delete'), - actions.savedObject.get('bar-savedObject-all-2', 'bulk_get'), - actions.savedObject.get('bar-savedObject-all-2', 'get'), - actions.savedObject.get('bar-savedObject-all-2', 'find'), - actions.savedObject.get('bar-savedObject-all-2', 'create'), - actions.savedObject.get('bar-savedObject-all-2', 'bulk_create'), - actions.savedObject.get('bar-savedObject-all-2', 'update'), - actions.savedObject.get('bar-savedObject-all-2', 'bulk_update'), - actions.savedObject.get('bar-savedObject-all-2', 'delete'), - actions.savedObject.get('bar-savedObject-read-1', 'bulk_get'), - actions.savedObject.get('bar-savedObject-read-1', 'get'), - actions.savedObject.get('bar-savedObject-read-1', 'find'), - actions.savedObject.get('bar-savedObject-read-2', 'bulk_get'), - actions.savedObject.get('bar-savedObject-read-2', 'get'), - actions.savedObject.get('bar-savedObject-read-2', 'find'), - actions.ui.get('foo', 'bar-ui-1'), - actions.ui.get('foo', 'bar-ui-2'), actions.ui.get('catalogue', 'all-catalogue-1'), actions.ui.get('catalogue', 'all-catalogue-2'), actions.ui.get('management', 'all-management', 'all-management-1'), actions.ui.get('management', 'all-management', 'all-management-2'), + actions.ui.get('navLinks', 'kibana:foo'), actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), actions.savedObject.get('all-savedObject-all-1', 'get'), actions.savedObject.get('all-savedObject-all-1', 'find'), @@ -502,13 +309,12 @@ describe('features', () => { actions.savedObject.get('read-savedObject-read-2', 'find'), actions.ui.get('foo', 'read-ui-1'), actions.ui.get('foo', 'read-ui-2'), - actions.allHack, ]); }); test('actions defined in a feature privilege with name `read` are included in `read`', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -519,17 +325,6 @@ describe('features', () => { foo: ['ignore-me-1', 'ignore-me-2'], }, privileges: { - bar: { - management: { - 'ignore-me': ['ignore-me-1', 'ignore-me-2'], - }, - catalogue: ['ignore-me-1', 'ignore-me-2'], - savedObject: { - all: ['ignore-me-1', 'ignore-me-2'], - read: ['ignore-me-1', 'ignore-me-2'], - }, - ui: ['ignore-me-1', 'ignore-me-2'], - }, all: { management: { 'ignore-me': ['ignore-me-1', 'ignore-me-2'], @@ -553,14 +348,16 @@ describe('features', () => { ui: ['read-ui-1', 'read-ui-2'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.read`, [ @@ -600,7 +397,7 @@ describe('features', () => { test('actions defined in a reserved privilege are not included in `all` or `read`', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -610,7 +407,7 @@ describe('features', () => { management: { foo: ['ignore-me-1', 'ignore-me-2'], }, - privileges: {}, + privileges: null, reserved: { privilege: { savedObject: { @@ -621,14 +418,16 @@ describe('features', () => { }, description: '', }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -642,14 +441,13 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - actions.allHack, ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); test('actions defined in a feature with excludeFromBasePrivileges are not included in `all` or `read', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', excludeFromBasePrivileges: true, @@ -661,17 +459,6 @@ describe('features', () => { foo: ['ignore-me-1', 'ignore-me-2'], }, privileges: { - bar: { - management: { - 'bar-management': ['bar-management-1'], - }, - catalogue: ['bar-catalogue-1'], - savedObject: { - all: ['bar-savedObject-all-1'], - read: ['bar-savedObject-read-1'], - }, - ui: ['bar-ui-1'], - }, all: { management: { 'all-management': ['all-management-1'], @@ -695,14 +482,16 @@ describe('features', () => { ui: ['read-ui-1'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -716,14 +505,13 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - actions.allHack, ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); test('actions defined in an individual feature privilege with excludeFromBasePrivileges are not included in `all` or `read`', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -734,18 +522,6 @@ describe('features', () => { foo: ['ignore-me-1', 'ignore-me-2'], }, privileges: { - bar: { - excludeFromBasePrivileges: true, - management: { - 'bar-management': ['bar-management-1'], - }, - catalogue: ['bar-catalogue-1'], - savedObject: { - all: ['bar-savedObject-all-1'], - read: ['bar-savedObject-read-1'], - }, - ui: ['bar-ui-1'], - }, all: { excludeFromBasePrivileges: true, management: { @@ -771,14 +547,16 @@ describe('features', () => { ui: ['read-ui-1'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -792,7 +570,6 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - actions.allHack, ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -800,9 +577,9 @@ describe('features', () => { }); describe('reserved', () => { - test('actions defined at the feature cascade to the privileges', () => { + test('actions defined at the feature do not cascade to the privileges', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -812,7 +589,7 @@ describe('reserved', () => { management: { foo: ['management-1', 'management-2'], }, - privileges: {}, + privileges: null, reserved: { privilege: { savedObject: { @@ -823,84 +600,32 @@ describe('reserved', () => { }, description: '', }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); - - const actual = privileges.get(); - expect(actual).toHaveProperty('reserved.foo', [ - actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - ]); - }); - - test('actions defined at the reservedPrivilege take precedence', () => { - const features: Feature[] = [ - { - id: 'foo', - name: 'Foo Feature', - icon: 'arrowDown', - app: ['ignore-me-1', 'ignore-me-2'], - catalogue: ['ignore-me-1', 'ignore-me-2'], - management: { - foo: ['ignore-me-1', 'ignore-me-2'], - }, - privileges: {}, - reserved: { - privilege: { - app: ['app-1', 'app-2'], - catalogue: ['catalogue-1', 'catalogue-2'], - management: { - bar: ['management-1', 'management-2'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - description: '', - }, - }, - ]; - - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty('reserved.foo', [ actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'bar', 'management-1'), - actions.ui.get('management', 'bar', 'management-2'), + actions.ui.get('navLinks', 'kibana:foo'), ]); }); test(`actions only specified at the privilege are alright too`, () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', app: [], - privileges: {}, + privileges: null, reserved: { privilege: { savedObject: { @@ -911,14 +636,16 @@ describe('reserved', () => { }, description: '', }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty('reserved.foo', [ @@ -952,7 +679,7 @@ describe('reserved', () => { test(`features with no reservedPrivileges aren't listed`, () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -965,17 +692,953 @@ describe('reserved', () => { }, ui: ['foo'], }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).not.toHaveProperty('reserved.foo'); }); }); + +describe('subFeatures', () => { + describe(`with includeIn: 'none'`, () => { + test(`should not augment the primary feature privileges, base privileges, or minimal feature privileges`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'none', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty('foo.all', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + expect(actual.features).toHaveProperty('foo.minimal_all', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty('foo.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + expect(actual.features).toHaveProperty('foo.minimal_read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('foo', 'foo'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + }); + }); + + describe(`with includeIn: 'read'`, () => { + test(`should augment the primary feature privileges and base privileges, but never the minimal versions`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'read', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + }); + + test(`should augment the primary feature privileges, but not base privileges if feature is excluded from them.`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + excludeFromBasePrivileges: true, + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'read', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + ]); + expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); + + expect(actual).toHaveProperty('space.all', [actions.login, actions.version]); + expect(actual).toHaveProperty('space.read', [actions.login, actions.version]); + }); + }); + + describe(`with includeIn: 'all'`, () => { + test(`should augment the primary 'all' feature privileges and base 'all' privileges, but never the minimal versions`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'all', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + }); + + test(`should augment the primary 'all' feature privileges, but not the base privileges if the feature is excluded from them`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + excludeFromBasePrivileges: true, + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'all', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + ]); + expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); + + expect(actual).toHaveProperty('space.all', [actions.login, actions.version]); + expect(actual).toHaveProperty('space.read', [actions.login, actions.version]); + }); + }); + + describe(`when license does not allow sub features`, () => { + test(`should augment the primary feature privileges, and should not create minimal or sub-feature privileges`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'read', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: false }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).not.toHaveProperty(`foo.subFeaturePriv1`); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).not.toHaveProperty(`foo.minimal_all`); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).not.toHaveProperty(`foo.minimal_read`); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + }); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index c73c4be8f36ac..b25aad30a3423 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -4,65 +4,94 @@ * you may not use this file except in compliance with the Elastic License. */ -import { flatten, mapValues, uniq } from 'lodash'; +import { uniq } from 'lodash'; +import { SecurityLicense } from '../../../common/licensing'; import { Feature } from '../../../../features/server'; -import { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from '../../../common/model'; +import { RawKibanaPrivileges } from '../../../common/model'; import { Actions } from '../actions'; import { featurePrivilegeBuilderFactory } from './feature_privilege_builder'; import { FeaturesService } from '../../plugin'; +import { + featurePrivilegeIterator, + subFeaturePrivilegeIterator, +} from './feature_privilege_iterator'; export interface PrivilegesService { get(): RawKibanaPrivileges; } -export function privilegesFactory(actions: Actions, featuresService: FeaturesService) { +export function privilegesFactory( + actions: Actions, + featuresService: FeaturesService, + licenseService: Pick +) { const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions); return { get() { const features = featuresService.getFeatures(); + const { allowSubFeaturePrivileges } = licenseService.getFeatures(); const basePrivilegeFeatures = features.filter(feature => !feature.excludeFromBasePrivileges); - const allActions = uniq( - flatten( - basePrivilegeFeatures.map(feature => - Object.values(feature.privileges).reduce((acc, privilege) => { - if (privilege.excludeFromBasePrivileges) { - return acc; - } + let allActions: string[] = []; + let readActions: string[] = []; - return [...acc, ...featurePrivilegeBuilder.getActions(privilege, feature)]; - }, []) - ) - ) - ); + basePrivilegeFeatures.forEach(feature => { + for (const { privilegeId, privilege } of featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + predicate: (pId, featurePrivilege) => !featurePrivilege.excludeFromBasePrivileges, + })) { + const privilegeActions = featurePrivilegeBuilder.getActions(privilege, feature); + allActions = [...allActions, ...privilegeActions]; + if (privilegeId === 'read') { + readActions = [...readActions, ...privilegeActions]; + } + } + }); - const readActions = uniq( - flatten( - basePrivilegeFeatures.map(feature => - Object.entries(feature.privileges).reduce((acc, [privilegeId, privilege]) => { - if (privilegeId !== 'read' || privilege.excludeFromBasePrivileges) { - return acc; - } + allActions = uniq(allActions); + readActions = uniq(readActions); - return [...acc, ...featurePrivilegeBuilder.getActions(privilege, feature)]; - }, []) - ) - ) - ); + const featurePrivileges: Record> = {}; + for (const feature of features) { + featurePrivileges[feature.id] = {}; + for (const featurePrivilege of featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + })) { + featurePrivileges[feature.id][featurePrivilege.privilegeId] = [ + actions.login, + actions.version, + ...uniq(featurePrivilegeBuilder.getActions(featurePrivilege.privilege, feature)), + ]; + } - return { - features: features.reduce((acc: RawKibanaFeaturePrivileges, feature: Feature) => { - if (Object.keys(feature.privileges).length > 0) { - acc[feature.id] = mapValues(feature.privileges, (privilege, privilegeId) => [ + if (allowSubFeaturePrivileges && feature.subFeatures?.length > 0) { + for (const featurePrivilege of featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: false, + })) { + featurePrivileges[feature.id][`minimal_${featurePrivilege.privilegeId}`] = [ actions.login, actions.version, - ...featurePrivilegeBuilder.getActions(privilege, feature), - ...(privilegeId === 'all' ? [actions.allHack] : []), - ]); + ...uniq(featurePrivilegeBuilder.getActions(featurePrivilege.privilege, feature)), + ]; } - return acc; - }, {}), + + for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature)) { + featurePrivileges[feature.id][subFeaturePrivilege.id] = [ + actions.login, + actions.version, + ...uniq(featurePrivilegeBuilder.getActions(subFeaturePrivilege, feature)), + ]; + } + } + + if (Object.keys(featurePrivileges[feature.id]).length === 0) { + delete featurePrivileges[feature.id]; + } + } + + return { + features: featurePrivileges, global: { all: [ actions.login, @@ -72,12 +101,11 @@ export function privilegesFactory(actions: Actions, featuresService: FeaturesSer actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), ...allActions, - actions.allHack, ], read: [actions.login, actions.version, ...readActions], }, space: { - all: [actions.login, actions.version, ...allActions, actions.allHack], + all: [actions.login, actions.version, ...allActions], read: [actions.login, actions.version, ...readActions], }, reserved: features.reduce((acc: Record, feature: Feature) => { diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts index 3dc3ae03b18cb..ac386d287cff1 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts @@ -5,13 +5,42 @@ */ import { Feature } from '../../../features/server'; -import { Actions } from './actions'; import { validateFeaturePrivileges } from './validate_feature_privileges'; -const actions = new Actions('1.0.0-zeta1'); +it('allows features to be defined without privileges', () => { + const feature: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: null, + }); -it(`doesn't allow read to grant privileges which aren't also included in all`, () => { - const feature: Feature = { + validateFeaturePrivileges([feature]); +}); + +it('allows features with reserved privileges to be defined', () => { + const feature: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: null, + reserved: { + description: 'foo', + privilege: { + savedObject: { + all: ['foo'], + read: ['bar'], + }, + ui: [], + }, + }, + }); + + validateFeaturePrivileges([feature]); +}); + +it('allows features with sub-features to be defined', () => { + const feature: Feature = new Feature({ id: 'foo', name: 'foo', app: [], @@ -31,15 +60,50 @@ it(`doesn't allow read to grant privileges which aren't also included in all`, ( ui: [], }, }, - }; + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-1-priv-1', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-1-priv-2', + name: 'some second sub feature', + includeIn: 'none', + savedObject: { + all: ['foo', 'bar'], + read: ['baz'], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); - expect(() => validateFeaturePrivileges(actions, [feature])).toThrowErrorMatchingInlineSnapshot( - `"foo's \\"all\\" privilege should be a superset of the \\"read\\" privilege."` - ); + validateFeaturePrivileges([feature]); }); -it(`allows all and read to grant the same privileges`, () => { - const feature: Feature = { +it('does not allow features with sub-features which have id conflicts with the minimal privileges', () => { + const feature: Feature = new Feature({ id: 'foo', name: 'foo', app: [], @@ -54,18 +118,42 @@ it(`allows all and read to grant the same privileges`, () => { read: { savedObject: { all: ['foo'], - read: ['bar'], + read: ['bar', 'baz'], }, ui: [], }, }, - }; + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'minimal_all', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); - validateFeaturePrivileges(actions, [feature]); + expect(() => validateFeaturePrivileges([feature])).toThrowErrorMatchingInlineSnapshot( + `"Feature 'foo' already has a privilege with ID 'minimal_all'. Sub feature 'sub-feature-1' cannot also specify this."` + ); }); -it(`allows all to grant privileges in addition to read`, () => { - const feature: Feature = { +it('does not allow features with sub-features which have id conflicts with the primary feature privileges', () => { + const feature: Feature = new Feature({ id: 'foo', name: 'foo', app: [], @@ -73,19 +161,113 @@ it(`allows all to grant privileges in addition to read`, () => { all: { savedObject: { all: ['foo'], - read: ['bar', 'baz'], + read: ['bar'], }, ui: [], }, read: { + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + }, + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'read', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); + + expect(() => validateFeaturePrivileges([feature])).toThrowErrorMatchingInlineSnapshot( + `"Feature 'foo' already has a privilege with ID 'read'. Sub feature 'sub-feature-1' cannot also specify this."` + ); +}); + +it('does not allow features with sub-features which have id conflicts each other', () => { + const feature: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { savedObject: { all: ['foo'], read: ['bar'], }, ui: [], }, + read: { + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, }, - }; + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'some-sub-feature', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + ], + }, + { + name: 'sub-feature-2', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'some-sub-feature', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); - validateFeaturePrivileges(actions, [feature]); + expect(() => validateFeaturePrivileges([feature])).toThrowErrorMatchingInlineSnapshot( + `"Feature 'foo' already has a privilege with ID 'some-sub-feature'. Sub feature 'sub-feature-2' cannot also specify this."` + ); }); diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts index 7998c816ae1c7..510feb1151a9b 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts @@ -5,21 +5,27 @@ */ import { Feature } from '../../../features/server'; -import { areActionsFullyCovered } from '../../common/privilege_calculator_utils'; -import { Actions } from './actions'; -import { featurePrivilegeBuilderFactory } from './privileges/feature_privilege_builder'; -export function validateFeaturePrivileges(actions: Actions, features: Feature[]) { - const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions); +export function validateFeaturePrivileges(features: Feature[]) { for (const feature of features) { - if (feature.privileges.all != null && feature.privileges.read != null) { - const allActions = featurePrivilegeBuilder.getActions(feature.privileges.all, feature); - const readActions = featurePrivilegeBuilder.getActions(feature.privileges.read, feature); - if (!areActionsFullyCovered(allActions, readActions)) { - throw new Error( - `${feature.id}'s "all" privilege should be a superset of the "read" privilege.` - ); - } - } + const seenPrivilegeIds = new Set(); + Object.keys(feature.privileges ?? {}).forEach(privilegeId => { + seenPrivilegeIds.add(privilegeId); + seenPrivilegeIds.add(`minimal_${privilegeId}`); + }); + + const subFeatureEntries = feature.subFeatures ?? []; + subFeatureEntries.forEach(subFeature => { + subFeature.privilegeGroups.forEach(subFeaturePrivilegeGroup => { + subFeaturePrivilegeGroup.privileges.forEach(subFeaturePrivilege => { + if (seenPrivilegeIds.has(subFeaturePrivilege.id)) { + throw new Error( + `Feature '${feature.id}' already has a privilege with ID '${subFeaturePrivilege.id}'. Sub feature '${subFeature.name}' cannot also specify this.` + ); + } + seenPrivilegeIds.add(subFeaturePrivilege.id); + }); + }); + }); } } diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index a23c826b32fbd..4767f57de764c 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -82,7 +82,6 @@ describe('Security Plugin', () => { }, "authz": Object { "actions": Actions { - "allHack": "allHack:", "api": ApiActions { "prefix": "api:version:", }, diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts index 9217d5a437f9c..7751f9a952c09 100644 --- a/x-pack/plugins/security/server/routes/views/login.test.ts +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -163,6 +163,7 @@ describe('Login view routes', () => { layout: 'error-es-unavailable', showLinks: false, showRoleMappingsManagement: true, + allowSubFeaturePrivileges: true, showLogin: true, }); diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap index 7db3d5456fbd3..6d40ce15fc57f 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap @@ -91,14 +91,14 @@ exports[`EnabledFeatures renders as expected 1`] = ` "icon": "spacesApp", "id": "feature-1", "name": "Feature 1", - "privileges": Object {}, + "privileges": null, }, Object { "app": Array [], "icon": "spacesApp", "id": "feature-2", "name": "Feature 2", - "privileges": Object {}, + "privileges": null, }, ] } diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx index d9282ad0457dd..ca53a9eb17253 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx @@ -10,22 +10,22 @@ import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { Space } from '../../../../common/model/space'; import { SectionPanel } from '../section_panel'; import { EnabledFeatures } from './enabled_features'; -import { Feature } from '../../../../../features/public'; +import { FeatureConfig } from '../../../../../features/public'; -const features: Feature[] = [ +const features: FeatureConfig[] = [ { id: 'feature-1', name: 'Feature 1', icon: 'spacesApp', app: [], - privileges: {}, + privileges: null, }, { id: 'feature-2', name: 'Feature 2', icon: 'spacesApp', app: [], - privileges: {}, + privileges: null, }, ]; diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx index 52a0fe8d4d26c..6f0462a6ddcc2 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx @@ -8,7 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment, ReactNode } from 'react'; -import { Feature } from '../../../../../../plugins/features/public'; +import { FeatureConfig } from '../../../../../../plugins/features/public'; import { Space } from '../../../../common/model/space'; import { getEnabledFeatures } from '../../lib/feature_utils'; import { SectionPanel } from '../section_panel'; @@ -16,7 +16,7 @@ import { FeatureTable } from './feature_table'; interface Props { space: Partial; - features: Feature[]; + features: FeatureConfig[]; securityEnabled: boolean; onChange: (space: Partial) => void; } diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx index 380f151b54a18..880842ed0ae30 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx @@ -9,13 +9,13 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import _ from 'lodash'; import React, { ChangeEvent, Component } from 'react'; -import { Feature } from '../../../../../../plugins/features/public'; +import { FeatureConfig } from '../../../../../../plugins/features/public'; import { Space } from '../../../../common/model/space'; import { ToggleAllFeatures } from './toggle_all_features'; interface Props { space: Partial; - features: Feature[]; + features: FeatureConfig[]; onChange: (space: Partial) => void; } @@ -69,7 +69,10 @@ export class FeatureTable extends Component { name: i18n.translate('xpack.spaces.management.enabledSpaceFeaturesFeatureColumnTitle', { defaultMessage: 'Feature', }), - render: (feature: Feature, _item: { feature: Feature; space: Props['space'] }) => { + render: ( + feature: FeatureConfig, + _item: { feature: FeatureConfig; space: Props['space'] } + ) => { return ( diff --git a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx index 2aba1522a7e3f..b79bbd0d6ab3f 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx @@ -13,7 +13,9 @@ import { ManageSpacePage } from './manage_space_page'; import { SectionPanel } from './section_panel'; import { spacesManagerMock } from '../../spaces_manager/mocks'; import { SpacesManager } from '../../spaces_manager'; -import { httpServiceMock, notificationServiceMock } from 'src/core/public/mocks'; +import { notificationServiceMock } from 'src/core/public/mocks'; +import { featuresPluginMock } from '../../../../features/public/mocks'; +import { Feature } from '../../../../features/public'; const space = { id: 'my-space', @@ -21,19 +23,27 @@ const space = { disabledFeatures: [], }; +const featuresStart = featuresPluginMock.createStart(); +featuresStart.getFeatures.mockResolvedValue([ + new Feature({ + id: 'feature-1', + name: 'feature 1', + icon: 'spacesApp', + app: [], + privileges: null, + }), +]); + describe('ManageSpacePage', () => { it('allows a space to be created', async () => { const spacesManager = spacesManagerMock.create(); spacesManager.createSpace = jest.fn(spacesManager.createSpace); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - const httpStart = httpServiceMock.createStartContract(); - httpStart.get.mockResolvedValue([{ id: 'feature-1', name: 'feature 1', icon: 'spacesApp' }]); - const wrapper = mountWithIntl( { }); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - const httpStart = httpServiceMock.createStartContract(); - httpStart.get.mockResolvedValue([{ id: 'feature-1', name: 'feature 1', icon: 'spacesApp' }]); - const onLoadSpace = jest.fn(); const wrapper = mountWithIntl( @@ -93,7 +100,7 @@ describe('ManageSpacePage', () => { spaceId={'existing-space'} spacesManager={(spacesManager as unknown) as SpacesManager} onLoadSpace={onLoadSpace} - http={httpStart} + getFeatures={featuresStart.getFeatures} notifications={notificationServiceMock.createStartContract()} securityEnabled={true} capabilities={{ @@ -130,6 +137,37 @@ describe('ManageSpacePage', () => { }); }); + it('notifies when there is an error retrieving features', async () => { + const spacesManager = spacesManagerMock.create(); + spacesManager.createSpace = jest.fn(spacesManager.createSpace); + spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); + + const error = new Error('something awful happened'); + + const notifications = notificationServiceMock.createStartContract(); + + const wrapper = mountWithIntl( + Promise.reject(error)} + notifications={notifications} + securityEnabled={true} + capabilities={{ + navLinks: {}, + management: {}, + catalogue: {}, + spaces: { manage: true }, + }} + /> + ); + + await waitForDataLoad(wrapper); + + expect(notifications.toasts.addError).toHaveBeenCalledWith(error, { + title: 'Error loading available features', + }); + }); + it('warns when updating features in the active space', async () => { const spacesManager = spacesManagerMock.create(); spacesManager.getSpace = jest.fn().mockResolvedValue({ @@ -142,14 +180,11 @@ describe('ManageSpacePage', () => { }); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - const httpStart = httpServiceMock.createStartContract(); - httpStart.get.mockResolvedValue([{ id: 'feature-1', name: 'feature 1', icon: 'spacesApp' }]); - const wrapper = mountWithIntl( { }); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - const httpStart = httpServiceMock.createStartContract(); - httpStart.get.mockResolvedValue([{ id: 'feature-1', name: 'feature 1', icon: 'spacesApp' }]); - const wrapper = mountWithIntl( { return; } - const { spaceId, http } = this.props; + const { spaceId, getFeatures, notifications } = this.props; - const getFeatures = http.get('/api/features'); - - if (spaceId) { - await this.loadSpace(spaceId, getFeatures); - } else { - const features = await getFeatures; - this.setState({ isLoading: false, features }); + try { + if (spaceId) { + await this.loadSpace(spaceId, getFeatures()); + } else { + const features = await getFeatures(); + this.setState({ isLoading: false, features }); + } + } catch (e) { + notifications.toasts.addError(e, { + title: i18n.translate('xpack.spaces.management.manageSpacePage.loadErrorTitle', { + defaultMessage: 'Error loading available features', + }), + }); } } @@ -318,7 +324,7 @@ export class ManageSpacePage extends Component { this.setState({ space, - features: await features, + features, originalSpace: space, isLoading: false, }); diff --git a/x-pack/plugins/spaces/public/management/lib/feature_utils.ts b/x-pack/plugins/spaces/public/management/lib/feature_utils.ts index a1b64eb954403..09dbe886ab191 100644 --- a/x-pack/plugins/spaces/public/management/lib/feature_utils.ts +++ b/x-pack/plugins/spaces/public/management/lib/feature_utils.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../../features/common'; +import { FeatureConfig } from '../../../../features/common'; import { Space } from '../..'; -export function getEnabledFeatures(features: Feature[], space: Partial) { +export function getEnabledFeatures(features: FeatureConfig[], space: Partial) { return features.filter(feature => !(space.disabledFeatures || []).includes(feature.id)); } diff --git a/x-pack/plugins/spaces/public/management/management_service.test.ts b/x-pack/plugins/spaces/public/management/management_service.test.ts index d4c6bdaea2776..782c261be9664 100644 --- a/x-pack/plugins/spaces/public/management/management_service.test.ts +++ b/x-pack/plugins/spaces/public/management/management_service.test.ts @@ -10,6 +10,8 @@ import { spacesManagerMock } from '../spaces_manager/mocks'; import { managementPluginMock } from '../../../../../src/plugins/management/public/mocks'; import { ManagementSection } from 'src/plugins/management/public'; import { Capabilities } from 'kibana/public'; +import { PluginsStart } from '../plugin'; +import { CoreSetup } from 'src/core/public'; describe('ManagementService', () => { describe('#setup', () => { @@ -19,7 +21,9 @@ describe('ManagementService', () => { } as unknown) as ManagementSection; const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; @@ -43,7 +47,9 @@ describe('ManagementService', () => { it('will not crash if the kibana section is missing', () => { const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; @@ -61,7 +67,9 @@ describe('ManagementService', () => { const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; @@ -88,7 +96,9 @@ describe('ManagementService', () => { const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; @@ -117,7 +127,9 @@ describe('ManagementService', () => { const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx index 4cc4190e9591b..ff4be84207832 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx @@ -20,8 +20,8 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Capabilities, HttpStart, NotificationsStart } from 'src/core/public'; -import { Feature } from '../../../../features/public'; +import { Capabilities, NotificationsStart } from 'src/core/public'; +import { Feature, FeaturesPluginStart } from '../../../../features/public'; import { isReservedSpace } from '../../../common'; import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { Space } from '../../../common/model/space'; @@ -36,7 +36,7 @@ import { getEnabledFeatures } from '../lib/feature_utils'; interface Props { spacesManager: SpacesManager; notifications: NotificationsStart; - http: HttpStart; + getFeatures: FeaturesPluginStart['getFeatures']; capabilities: Capabilities; securityEnabled: boolean; } @@ -47,7 +47,6 @@ interface State { loading: boolean; showConfirmDeleteModal: boolean; selectedSpace: Space | null; - error: Error | null; } export class SpacesGridPage extends Component { @@ -59,7 +58,6 @@ export class SpacesGridPage extends Component { loading: true, showConfirmDeleteModal: false, selectedSpace: null, - error: null, }; } @@ -211,7 +209,7 @@ export class SpacesGridPage extends Component { }; public loadGrid = async () => { - const { spacesManager, http } = this.props; + const { spacesManager, getFeatures, notifications } = this.props; this.setState({ loading: true, @@ -220,10 +218,9 @@ export class SpacesGridPage extends Component { }); const getSpaces = spacesManager.getSpaces(); - const getFeatures = http.get('/api/features'); try { - const [spaces, features] = await Promise.all([getSpaces, getFeatures]); + const [spaces, features] = await Promise.all([getSpaces, getFeatures()]); this.setState({ loading: false, spaces, @@ -232,7 +229,11 @@ export class SpacesGridPage extends Component { } catch (error) { this.setState({ loading: false, - error, + }); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.spaces.management.spacesGridPage.errorTitle', { + defaultMessage: 'Error loading spaces', + }), }); } }; diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx index 90c7aba65e3d6..9b7dc921b9a25 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx @@ -12,6 +12,8 @@ import { SpacesManager } from '../../spaces_manager'; import { SpacesGridPage } from './spaces_grid_page'; import { httpServiceMock } from 'src/core/public/mocks'; import { notificationServiceMock } from 'src/core/public/mocks'; +import { featuresPluginMock } from '../../../../features/public/mocks'; +import { Feature } from '../../../../features/public'; const spaces = [ { @@ -38,6 +40,17 @@ const spaces = [ const spacesManager = spacesManagerMock.create(); spacesManager.getSpaces = jest.fn().mockResolvedValue(spaces); +const featuresStart = featuresPluginMock.createStart(); +featuresStart.getFeatures.mockResolvedValue([ + new Feature({ + id: 'feature-1', + name: 'feature 1', + icon: 'spacesApp', + app: [], + privileges: null, + }), +]); + describe('SpacesGridPage', () => { it('renders as expected', () => { const httpStart = httpServiceMock.createStartContract(); @@ -47,7 +60,7 @@ describe('SpacesGridPage', () => { shallowWithIntl( { const wrapper = mountWithIntl( { expect(wrapper.find(SpaceAvatar)).toHaveLength(spaces.length); expect(wrapper.find(SpaceAvatar)).toMatchSnapshot(); }); + + it('notifies when spaces fail to load', async () => { + const httpStart = httpServiceMock.createStartContract(); + httpStart.get.mockResolvedValue([]); + + const error = new Error('something awful happened'); + spacesManager.getSpaces.mockRejectedValue(error); + + const notifications = notificationServiceMock.createStartContract(); + + const wrapper = mountWithIntl( + + ); + + // allow spacesManager to load spaces + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SpaceAvatar)).toHaveLength(0); + expect(notifications.toasts.addError).toHaveBeenCalledWith(error, { + title: 'Error loading spaces', + }); + }); + + it('notifies when features fail to load', async () => { + const httpStart = httpServiceMock.createStartContract(); + httpStart.get.mockResolvedValue([]); + + const error = new Error('something awful happened'); + + const notifications = notificationServiceMock.createStartContract(); + + const wrapper = mountWithIntl( + Promise.reject(error)} + notifications={notifications} + securityEnabled={true} + capabilities={{ + navLinks: {}, + management: {}, + catalogue: {}, + spaces: { manage: true }, + }} + /> + ); + + // allow spacesManager to load spaces + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SpaceAvatar)).toHaveLength(0); + // For end-users, the effect is that spaces won't load, even though this was a request to retrieve features. + expect(notifications.toasts.addError).toHaveBeenCalledWith(error, { + title: 'Error loading spaces', + }); + }); }); diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx index 2e274e08ee13b..7738a440cb5e1 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx @@ -23,6 +23,8 @@ import { coreMock } from '../../../../../src/core/public/mocks'; import { securityMock } from '../../../security/public/mocks'; import { spacesManagerMock } from '../spaces_manager/mocks'; import { SecurityLicenseFeatures } from '../../../security/public'; +import { featuresPluginMock } from '../../../features/public/mocks'; +import { PluginsStart } from '../plugin'; async function mountApp(basePath: string, spaceId?: string) { const container = document.createElement('div'); @@ -42,11 +44,14 @@ async function mountApp(basePath: string, spaceId?: string) { showLinks: true, } as SecurityLicenseFeatures); + const [coreStart, pluginsStart] = await coreMock.createSetup().getStartServices(); + (pluginsStart as PluginsStart).features = featuresPluginMock.createStart(); + const unmount = await spacesManagementApp .create({ spacesManager, securityLicense, - getStartServices: coreMock.createSetup().getStartServices as any, + getStartServices: async () => [coreStart, pluginsStart as PluginsStart], }) .mount({ basePath, element: container, setBreadcrumbs }); @@ -81,7 +86,7 @@ describe('spacesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `#${basePath}`, text: 'Spaces' }]); expect(container).toMatchInlineSnapshot(`
- Spaces Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"securityEnabled":true} + Spaces Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"securityEnabled":true}
`); @@ -103,7 +108,7 @@ describe('spacesManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"securityEnabled":true} + Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"securityEnabled":true}
`); @@ -126,7 +131,7 @@ describe('spacesManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"spaceId":"some-space","securityEnabled":true} + Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"spaceId":"some-space","securityEnabled":true}
`); diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx index 2a93e684bb716..92b369807b0da 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx @@ -33,7 +33,10 @@ export const spacesManagementApp = Object.freeze({ defaultMessage: 'Spaces', }), async mount({ basePath, element, setBreadcrumbs }) { - const [{ http, notifications, i18n: i18nStart, application }] = await getStartServices(); + const [ + { notifications, i18n: i18nStart, application }, + { features }, + ] = await getStartServices(); const spacesBreadcrumbs = [ { text: i18n.translate('xpack.spaces.management.breadcrumb', { @@ -48,7 +51,7 @@ export const spacesManagementApp = Object.freeze({ return ( { describe('#setup', () => { @@ -101,7 +102,7 @@ describe('Spaces plugin', () => { const plugin = new SpacesPlugin(); plugin.setup(coreSetup, {}); - plugin.start(coreStart, {}); + plugin.start(coreStart, { features: featuresPluginMock.createStart() }); expect(coreStart.chrome.navControls.registerLeft).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 44215ec538002..876ab39df3a1f 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -9,6 +9,7 @@ import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { SavedObjectsManagementAction } from 'src/legacy/core_plugins/management/public'; import { ManagementStart, ManagementSetup } from 'src/plugins/management/public'; import { AdvancedSettingsSetup } from 'src/plugins/advanced_settings/public'; +import { FeaturesPluginStart } from '../../features/public'; import { SecurityPluginStart, SecurityPluginSetup } from '../../security/public'; import { SpacesManager } from './spaces_manager'; import { initSpacesNavControl } from './nav_control'; @@ -26,6 +27,7 @@ export interface PluginsSetup { } export interface PluginsStart { + features: FeaturesPluginStart; management?: ManagementStart; security?: SecurityPluginStart; } @@ -53,7 +55,7 @@ export class SpacesPlugin implements Plugin['getStartServices'], spacesManager: this.spacesManager, securityLicense: plugins.security?.license, }); diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts index fcd756c2aca10..2c1ab26dd3d82 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts @@ -13,12 +13,11 @@ import { featuresPluginMock } from '../../../features/server/mocks'; import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; import { PluginsStart } from '../plugin'; -const features: Feature[] = [ +const features = ([ { id: 'feature_1', name: 'Feature 1', app: [], - privileges: {}, }, { id: 'feature_2', @@ -60,7 +59,7 @@ const features: Feature[] = [ }, }, }, -]; +] as unknown) as Feature[]; const buildCapabilities = () => Object.freeze({ diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f02b52e5922d1..09392093b8f62 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10429,7 +10429,6 @@ "xpack.security.management.editRole.elasticSearchPrivileges.runAsPrivilegesTitle": "権限として実行", "xpack.security.management.editRole.featureTable.enabledRoleFeaturesEnabledColumnTitle": "権限", "xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle": "機能", - "xpack.security.management.editRole.featureTable.excludedFromBasePrivilegsTooltip": "アクセスを許可するには、「カスタム」特権を使用します。{featureName} は基本権限の一部ではありません。", "xpack.security.management.editRole.indexPrivilegeForm.deleteSpacePrivilegeAriaLabel": "インデックスの権限を削除", "xpack.security.management.editRole.indexPrivilegeForm.grantedDocumentsQueryFormRowLabel": "提供されたドキュメントのクエリ", "xpack.security.management.editRole.indexPrivilegeForm.grantReadPrivilegesLabel": "特定のドキュメントの読み込み権限を提供", @@ -10459,13 +10458,6 @@ "xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeInput": "読み込み", "xpack.security.management.editRole.simplePrivilegeForm.specifyPrivilegeForRoleDescription": "このロールの Kibana の権限を指定します。", "xpack.security.management.editRole.simplePrivilegeForm.unsupportedSpacePrivilegesWarning": "このロールはスペースへの権限が定義されていますが、Kibana でスペースが有効ではありません。このロールを保存するとこれらの権限が削除されます。", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.effectivePrivilegeMessage": "{source} で許可されています。", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalBasePrivilegeSource": "グローバルベース権限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalFeaturePrivilegeSource": "グローバル機能権限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.privilegeSupercededMessage": "{supersededPrivilege} のオリジナルの権限は {actualPrivilegeSource} により上書きされています", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceBasePrivilegeSource": "スペースベース権限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceFeaturePrivilegeSource": "スペース機能権限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.unknownPrivilegeSource": "**不明**", "xpack.security.management.editRole.spaceAwarePrivilegeForm.ensureAccountHasAllPrivilegesGrantedDescription": "{kibanaAdmin} ロールによりアカウントにすべての権限が提供されていることを確認し、再試行してください。", "xpack.security.management.editRole.spaceAwarePrivilegeForm.globalSpacesName": "* グローバル (すべてのスペース)", "xpack.security.management.editRole.spaceAwarePrivilegeForm.howToViewAllAvailableSpacesDescription": "利用可能なすべてのスペースを表示する権限がありません。", @@ -10489,15 +10481,9 @@ "xpack.security.management.editRole.spacePrivilegeForm.readPrivilegeDropdownDisplay": "読み込み", "xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormLabel": "スペース", "xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges": "機能権限のサマリー", - "xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeText": "ベース権限", - "xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeTooltip": "基本権限は自動的にすべての機能に与えられます。", - "xpack.security.management.editRole.spacePrivilegeMatrix.closeButton": "閉じる", - "xpack.security.management.editRole.spacePrivilegeMatrix.featureColumnTitle": "機能", "xpack.security.management.editRole.spacePrivilegeMatrix.globalSpaceName": "グローバル", - "xpack.security.management.editRole.spacePrivilegeMatrix.modalTitle": "権限のサマリー", "xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink": "(すべてのスペース)", "xpack.security.management.editRole.spacePrivilegeMatrix.showNMoreSpacesLink": "他 {count} 件", - "xpack.security.management.editRole.spacePrivilegeMatrix.showSummaryText": "権限サマリーを表示", "xpack.security.management.editRole.spacePrivilegeSection.addSpacePrivilegeButton": "スペース権限を追加", "xpack.security.management.editRole.spacePrivilegeSection.noAccessToKibanaTitle": "このロールは Kibana へのアクセスを許可しません", "xpack.security.management.editRole.spacePrivilegeTable.deletePrivilegesLabel": "次のスペースの権限を削除: {spaceNames}", @@ -10524,7 +10510,6 @@ "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowHelpText": "フィールドが提供されていない場合、このロールのユーザーはこのインデックスのデータを表示できません。", "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowLabel": "許可されたフィールド", "xpack.security.management.editRoles.indexPrivilegeForm.grantFieldPrivilegesLabel": "特定のフィールドへのアクセスを許可", - "xpack.security.management.editRolespacePrivilegeForm.cancelButton": "キャンセル", "xpack.security.management.editRolespacePrivilegeForm.createGlobalPrivilegeButton": "グローバル権限を作成", "xpack.security.management.editRolespacePrivilegeForm.createPrivilegeButton": "スペース権限を作成", "xpack.security.management.editRolespacePrivilegeForm.updateGlobalPrivilegeButton": "グローバル特権を更新", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index be6a3df6b6c18..a3382a19f76b7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10429,7 +10429,6 @@ "xpack.security.management.editRole.elasticSearchPrivileges.runAsPrivilegesTitle": "运行身份权限", "xpack.security.management.editRole.featureTable.enabledRoleFeaturesEnabledColumnTitle": "权限", "xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle": "功能", - "xpack.security.management.editRole.featureTable.excludedFromBasePrivilegsTooltip": "使用“定制”权限来授予权限。{featureName} 不属于基础权限。", "xpack.security.management.editRole.indexPrivilegeForm.deleteSpacePrivilegeAriaLabel": "删除索引权限", "xpack.security.management.editRole.indexPrivilegeForm.grantedDocumentsQueryFormRowLabel": "已授权文档查询", "xpack.security.management.editRole.indexPrivilegeForm.grantReadPrivilegesLabel": "授予特定文档的读取权限", @@ -10459,13 +10458,6 @@ "xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeInput": "读取", "xpack.security.management.editRole.simplePrivilegeForm.specifyPrivilegeForRoleDescription": "为此角色指定 Kibana 权限。", "xpack.security.management.editRole.simplePrivilegeForm.unsupportedSpacePrivilegesWarning": "此角色包含工作区的权限定义,但在 Kibana 中未启用工作区。保存此角色将会移除这些权限。", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.effectivePrivilegeMessage": "已通过 {source} 授予。", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalBasePrivilegeSource": "全局基本权限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalFeaturePrivilegeSource": "全局功能权限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.privilegeSupercededMessage": "{supersededPrivilege} 的原始权限已为 {actualPrivilegeSource} 所覆盖", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceBasePrivilegeSource": "工作区基本权限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceFeaturePrivilegeSource": "全局功能权限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.unknownPrivilegeSource": "**未知**", "xpack.security.management.editRole.spaceAwarePrivilegeForm.ensureAccountHasAllPrivilegesGrantedDescription": "请确保您的帐户具有 {kibanaAdmin} 角色授予的所有权限,然后重试。", "xpack.security.management.editRole.spaceAwarePrivilegeForm.globalSpacesName": "* 全局(所有工作区)", "xpack.security.management.editRole.spaceAwarePrivilegeForm.howToViewAllAvailableSpacesDescription": "您无权查看所有可用工作区。", @@ -10489,15 +10481,9 @@ "xpack.security.management.editRole.spacePrivilegeForm.readPrivilegeDropdownDisplay": "读取", "xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormLabel": "工作区", "xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges": "功能权限的摘要", - "xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeText": "基本权限", - "xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeTooltip": "所有功能的基本权限将自动授予。", - "xpack.security.management.editRole.spacePrivilegeMatrix.closeButton": "关闭", - "xpack.security.management.editRole.spacePrivilegeMatrix.featureColumnTitle": "功能", "xpack.security.management.editRole.spacePrivilegeMatrix.globalSpaceName": "全局", - "xpack.security.management.editRole.spacePrivilegeMatrix.modalTitle": "权限摘要", "xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink": "(所有工作区)", "xpack.security.management.editRole.spacePrivilegeMatrix.showNMoreSpacesLink": "另外 {count} 个", - "xpack.security.management.editRole.spacePrivilegeMatrix.showSummaryText": "查看权限摘要", "xpack.security.management.editRole.spacePrivilegeSection.addSpacePrivilegeButton": "添加工作区权限", "xpack.security.management.editRole.spacePrivilegeSection.noAccessToKibanaTitle": "此角色未授予对 Kibana 的访问权限", "xpack.security.management.editRole.spacePrivilegeTable.deletePrivilegesLabel": "删除以下工作区的权限:{spaceNames}。", @@ -10524,7 +10510,6 @@ "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowHelpText": "如果未授权任何字段,则分配到此角色的用户将无法查看此索引的任何数据。", "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowLabel": "已授权字段", "xpack.security.management.editRoles.indexPrivilegeForm.grantFieldPrivilegesLabel": "授予对特定字段的访问权限", - "xpack.security.management.editRolespacePrivilegeForm.cancelButton": "取消", "xpack.security.management.editRolespacePrivilegeForm.createGlobalPrivilegeButton": "创建全局权限", "xpack.security.management.editRolespacePrivilegeForm.createPrivilegeButton": "创建工作区权限", "xpack.security.management.editRolespacePrivilegeForm.updateGlobalPrivilegeButton": "更新全局权限", diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index 19506bb316a05..da208e13acdad 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -32,12 +32,15 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor features.registerFeature({ id: PLUGIN.ID, name: PLUGIN.NAME, + order: 1000, navLinkId: PLUGIN.ID, icon: 'uptimeApp', app: ['uptime', 'kibana'], catalogue: ['uptime'], privileges: { all: { + app: ['uptime', 'kibana'], + catalogue: ['uptime'], api: ['uptime-read', 'uptime-write'], savedObject: { all: [umDynamicSettings.name], @@ -46,6 +49,8 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor ui: ['save', 'configureSettings', 'show'], }, read: { + app: ['uptime', 'kibana'], + catalogue: ['uptime'], api: ['uptime-read'], savedObject: { all: [], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts index acd14e8a2bf7b..019b15cc1862a 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts @@ -57,6 +57,7 @@ export default function(kibana: any) { app: ['actions', 'kibana'], privileges: { all: { + app: ['actions', 'kibana'], savedObject: { all: ['action', 'action_task_params'], read: [], @@ -65,6 +66,7 @@ export default function(kibana: any) { api: ['actions-read', 'actions-all'], }, read: { + app: ['actions', 'kibana'], savedObject: { all: ['action_task_params'], read: ['action'], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts index 9b4a2d14de9ea..fe0f630830a56 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts @@ -20,6 +20,7 @@ export default function(kibana: any) { app: ['alerting', 'kibana'], privileges: { all: { + app: ['alerting', 'kibana'], savedObject: { all: ['alert'], read: [], @@ -28,6 +29,7 @@ export default function(kibana: any) { api: ['alerting-read', 'alerting-all'], }, read: { + app: ['alerting', 'kibana'], savedObject: { all: [], read: ['alert'], diff --git a/x-pack/test/api_integration/apis/security/index.js b/x-pack/test/api_integration/apis/security/index.js index df35ec2195dc5..ad1876cb717f1 100644 --- a/x-pack/test/api_integration/apis/security/index.js +++ b/x-pack/test/api_integration/apis/security/index.js @@ -8,6 +8,9 @@ export default function({ loadTestFile }) { describe('security', function() { this.tags('ciGroup6'); + // Updates here should be mirrored in `./security_basic.ts` if tests + // should also run under a basic license. + loadTestFile(require.resolve('./basic_login')); loadTestFile(require.resolve('./builtin_es_privileges')); loadTestFile(require.resolve('./change_password')); diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 0b29fc1cac7de..77293ddff3f9f 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -5,6 +5,8 @@ */ import util from 'util'; import { isEqual } from 'lodash'; +import expect from '@kbn/expect/expect.js'; +import { RawKibanaPrivileges } from '../../../../plugins/security/common/model'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getService }: FtrProviderContext) { @@ -18,9 +20,9 @@ export default function({ getService }: FtrProviderContext) { // Roles are associated with these privileges, and we shouldn't be removing them in a minor version. const expected = { features: { - discover: ['all', 'read'], - visualize: ['all', 'read'], - dashboard: ['all', 'read'], + discover: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'], + visualize: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'], + dashboard: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'], dev_tools: ['all', 'read'], advancedSettings: ['all', 'read'], indexPatterns: ['all', 'read'], @@ -48,13 +50,18 @@ export default function({ getService }: FtrProviderContext) { .send() .expect(200) .expect((res: any) => { - // when comparing privileges, the order of the privileges doesn't matter. + // when comparing privileges, the order of the features doesn't matter (but the order of the privileges does) // supertest uses assert.deepStrictEqual. // expect.js doesn't help us here. // and lodash's isEqual doesn't know how to compare Sets. const success = isEqual(res.body, expected, (value, other, key) => { if (Array.isArray(value) && Array.isArray(other)) { - return isEqual(value.sort(), other.sort()); + if (key === 'reserved') { + // order does not matter for the reserved privilege set. + return isEqual(value.sort(), other.sort()); + } + // order matters for the rest, as the UI assumes they are returned in a descending order of permissiveness. + return isEqual(value, other); } // Lodash types aren't correct, `undefined` should be supported as a return value here and it @@ -71,5 +78,70 @@ export default function({ getService }: FtrProviderContext) { .expect(200); }); }); + + describe('GET /api/security/privileges?includeActions=true', () => { + // The UI assumes that no wildcards are present when calculating the effective set of privileges. + // If this changes, then the "privilege calculators" will need revisiting to account for these wildcards. + it('should return a privilege map with actions which do not include wildcards', async () => { + await supertest + .get('/api/security/privileges?includeActions=true') + .set('kbn-xsrf', 'xxx') + .send() + .expect(200) + .expect((res: any) => { + const { features, global, space, reserved } = res.body as RawKibanaPrivileges; + expect(features).to.be.an('object'); + expect(global).to.be.an('object'); + expect(space).to.be.an('object'); + expect(reserved).to.be.an('object'); + + Object.entries(features).forEach(([featureId, featurePrivs]) => { + Object.values(featurePrivs).forEach(actions => { + expect(actions).to.be.an('array'); + actions.forEach(action => { + expect(action).to.be.a('string'); + expect(action.indexOf('*')).to.eql( + -1, + `Feature ${featureId} with action ${action} cannot contain a wildcard` + ); + }); + }); + }); + + Object.entries(global).forEach(([privilegeId, actions]) => { + expect(actions).to.be.an('array'); + actions.forEach(action => { + expect(action).to.be.a('string'); + expect(action.indexOf('*')).to.eql( + -1, + `Global privilege ${privilegeId} with action ${action} cannot contain a wildcard` + ); + }); + }); + + Object.entries(space).forEach(([privilegeId, actions]) => { + expect(actions).to.be.an('array'); + actions.forEach(action => { + expect(action).to.be.a('string'); + expect(action.indexOf('*')).to.eql( + -1, + `Space privilege ${privilegeId} with action ${action} cannot contain a wildcard` + ); + }); + }); + + Object.entries(reserved).forEach(([privilegeId, actions]) => { + expect(actions).to.be.an('array'); + actions.forEach(action => { + expect(action).to.be.a('string'); + expect(action.indexOf('*')).to.eql( + -1, + `Reserved privilege ${privilegeId} with action ${action} cannot contain a wildcard` + ); + }); + }); + }); + }); + }); }); } diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts new file mode 100644 index 0000000000000..0b29fc1cac7de --- /dev/null +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import util from 'util'; +import { isEqual } from 'lodash'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Privileges', () => { + describe('GET /api/security/privileges', () => { + it('should return a privilege map with all known privileges, without actions', async () => { + // If you're adding a privilege to the following, that's great! + // If you're removing a privilege, this breaks backwards compatibility + // Roles are associated with these privileges, and we shouldn't be removing them in a minor version. + const expected = { + features: { + discover: ['all', 'read'], + visualize: ['all', 'read'], + dashboard: ['all', 'read'], + dev_tools: ['all', 'read'], + advancedSettings: ['all', 'read'], + indexPatterns: ['all', 'read'], + savedObjectsManagement: ['all', 'read'], + timelion: ['all', 'read'], + graph: ['all', 'read'], + maps: ['all', 'read'], + canvas: ['all', 'read'], + infrastructure: ['all', 'read'], + logs: ['all', 'read'], + uptime: ['all', 'read'], + apm: ['all', 'read'], + siem: ['all', 'read'], + endpoint: ['all', 'read'], + ingestManager: ['all', 'read'], + }, + global: ['all', 'read'], + space: ['all', 'read'], + reserved: ['ml', 'monitoring'], + }; + + await supertest + .get('/api/security/privileges') + .set('kbn-xsrf', 'xxx') + .send() + .expect(200) + .expect((res: any) => { + // when comparing privileges, the order of the privileges doesn't matter. + // supertest uses assert.deepStrictEqual. + // expect.js doesn't help us here. + // and lodash's isEqual doesn't know how to compare Sets. + const success = isEqual(res.body, expected, (value, other, key) => { + if (Array.isArray(value) && Array.isArray(other)) { + return isEqual(value.sort(), other.sort()); + } + + // Lodash types aren't correct, `undefined` should be supported as a return value here and it + // has special meaning. + return undefined as any; + }); + + if (!success) { + throw new Error( + `Expected ${util.inspect(res.body)} to equal ${util.inspect(expected)}` + ); + } + }) + .expect(200); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/security/security_basic.ts b/x-pack/test/api_integration/apis/security/security_basic.ts new file mode 100644 index 0000000000000..dcbdb17724249 --- /dev/null +++ b/x-pack/test/api_integration/apis/security/security_basic.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('security (basic license)', function() { + this.tags('ciGroup6'); + + // Updates here should be mirrored in `./index.js` if tests + // should also run under a trial/platinum license. + + loadTestFile(require.resolve('./basic_login')); + loadTestFile(require.resolve('./builtin_es_privileges')); + loadTestFile(require.resolve('./change_password')); + loadTestFile(require.resolve('./index_fields')); + loadTestFile(require.resolve('./roles')); + loadTestFile(require.resolve('./privileges_basic')); + loadTestFile(require.resolve('./session')); + }); +} diff --git a/x-pack/test/api_integration/config_security_basic.js b/x-pack/test/api_integration/config_security_basic.js index c427bf7fa8f28..d21bfa4d7031a 100644 --- a/x-pack/test/api_integration/config_security_basic.js +++ b/x-pack/test/api_integration/config_security_basic.js @@ -14,7 +14,7 @@ export default async function({ readConfigFile }) { 'xpack.license.self_generated.type=basic', 'xpack.security.enabled=true', ]; - config.testFiles = [require.resolve('./apis/security')]; + config.testFiles = [require.resolve('./apis/security/security_basic')]; return config; }); } diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index b966d37becc3f..de68ec0c64c17 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -338,6 +338,115 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); }); + describe('global dashboard read-only with url_create privileges', () => { + before(async () => { + await security.role.create('global_dashboard_read_url_create_role', { + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + dashboard: ['read', 'url_create'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('global_dashboard_read_url_create_user', { + password: 'global_dashboard_read_url_create_user-password', + roles: ['global_dashboard_read_url_create_role'], + full_name: 'test user', + }); + + await PageObjects.security.login( + 'global_dashboard_read_url_create_user', + 'global_dashboard_read_url_create_user-password', + { + expectSpaceSelector: false, + } + ); + }); + + after(async () => { + await security.role.delete('global_dashboard_read_url_create_role'); + await security.user.delete('global_dashboard_read_url_create_user'); + }); + + it('shows dashboard navlink', async () => { + const navLinks = (await appsMenu.readLinks()).map(link => link.text); + expect(navLinks).to.eql(['Dashboard', 'Management']); + }); + + it(`landing page doesn't show "Create new Dashboard" button`, async () => { + await PageObjects.common.navigateToActualUrl( + 'kibana', + DashboardConstants.LANDING_PAGE_PATH, + { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + } + ); + await testSubjects.existOrFail('dashboardLandingPage', { timeout: 10000 }); + await testSubjects.missingOrFail('newItemButton'); + }); + + it(`shows read-only badge`, async () => { + await globalNav.badgeExistsOrFail('Read only'); + }); + + it(`create new dashboard redirects to the home page`, async () => { + await PageObjects.common.navigateToActualUrl( + 'kibana', + DashboardConstants.CREATE_NEW_DASHBOARD_URL, + { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + } + ); + await testSubjects.existOrFail('homeApp', { timeout: 20000 }); + }); + + it(`can view existing Dashboard`, async () => { + await PageObjects.common.navigateToActualUrl('kibana', createDashboardEditUrl('i-exist'), { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + }); + await testSubjects.existOrFail('embeddablePanelHeading-APie', { timeout: 10000 }); + }); + + it(`Permalinks shows create short-url button`, async () => { + await PageObjects.share.openShareMenuItem('Permalinks'); + await PageObjects.share.createShortUrlExistOrFail(); + }); + + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + }); + }); + describe('no dashboard privileges', () => { before(async () => { await security.role.create('no_dashboard_privileges_role', { diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index 98ab4c1f15a54..dc8c488460100 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -221,6 +221,97 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); }); + describe('global discover read-only privileges with url_create', () => { + before(async () => { + await security.role.create('global_discover_read_url_create_role', { + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + discover: ['read', 'url_create'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('global_discover_read_url_create_user', { + password: 'global_discover_read_url_create_user-password', + roles: ['global_discover_read_url_create_role'], + full_name: 'test user', + }); + + await PageObjects.security.login( + 'global_discover_read_url_create_user', + 'global_discover_read_url_create_user-password', + { + expectSpaceSelector: false, + } + ); + }); + + after(async () => { + await security.user.delete('global_discover_read_url_create_user'); + await security.role.delete('global_discover_read_url_create_role'); + }); + + it('shows discover navlink', async () => { + const navLinks = (await appsMenu.readLinks()).map(link => link.text); + expect(navLinks).to.eql(['Discover', 'Management']); + }); + + it(`doesn't show save button`, async () => { + await PageObjects.common.navigateToApp('discover'); + await testSubjects.existOrFail('discoverNewButton', { timeout: 10000 }); + await testSubjects.missingOrFail('discoverSaveButton'); + }); + + it(`shows read-only badge`, async () => { + await globalNav.badgeExistsOrFail('Read only'); + }); + + it(`doesn't show visualize button`, async () => { + await PageObjects.common.navigateToApp('discover'); + await setDiscoverTimeRange(); + await PageObjects.discover.clickFieldListItem('bytes'); + await PageObjects.discover.expectMissingFieldListItemVisualize('bytes'); + }); + + it('Permalinks shows create short-url button', async () => { + await PageObjects.share.openShareMenuItem('Permalinks'); + await PageObjects.share.createShortUrlExistOrFail(); + // close the menu + await PageObjects.share.clickShareTopNavButton(); + }); + + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + }); + }); + describe('discover and visualize privileges', () => { before(async () => { await security.role.create('global_discover_visualize_read_role', { diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts index e5b6512d1c1b0..9f080a056e91f 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts @@ -276,6 +276,113 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); }); + describe('global visualize read-only with url_create privileges', () => { + before(async () => { + await security.role.create('global_visualize_read_url_create_role', { + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + visualize: ['read', 'url_create'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('global_visualize_read_url_create_user', { + password: 'global_visualize_read_url_create_user-password', + roles: ['global_visualize_read_url_create_role'], + full_name: 'test user', + }); + + await PageObjects.security.login( + 'global_visualize_read_url_create_user', + 'global_visualize_read_url_create_user-password', + { + expectSpaceSelector: false, + } + ); + }); + + after(async () => { + await PageObjects.security.forceLogout(); + await security.role.delete('global_visualize_read_url_create_role'); + await security.user.delete('global_visualize_read_url_create_user'); + }); + + it('shows visualize navlink', async () => { + const navLinks = (await appsMenu.readLinks()).map(link => link.text); + expect(navLinks).to.eql(['Visualize', 'Management']); + }); + + it(`landing page shows "Create new Visualization" button`, async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await testSubjects.existOrFail('visualizeLandingPage', { timeout: 10000 }); + await testSubjects.existOrFail('newItemButton'); + }); + + it(`shows read-only badge`, async () => { + await globalNav.badgeExistsOrFail('Read only'); + }); + + it(`can view existing Visualization`, async () => { + await PageObjects.common.navigateToActualUrl('visualize', '/visualize/edit/i-exist', { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + }); + await testSubjects.existOrFail('visualizationLoader', { timeout: 10000 }); + }); + + it(`can't save existing Visualization`, async () => { + await PageObjects.common.navigateToActualUrl('visualize', '/visualize/edit/i-exist', { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + }); + await testSubjects.existOrFail('shareTopNavButton', { timeout: 10000 }); + await testSubjects.missingOrFail('visualizeSaveButton', { timeout: 10000 }); + }); + + it('Embed code shows create short-url button', async () => { + await PageObjects.share.openShareMenuItem('Embedcode'); + await PageObjects.share.createShortUrlExistOrFail(); + }); + + it('Permalinks shows create short-url button', async () => { + await PageObjects.share.openShareMenuItem('Permalinks'); + await PageObjects.share.createShortUrlExistOrFail(); + // close menu + await PageObjects.share.clickShareTopNavButton(); + }); + + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + }); + }); + describe('no visualize privileges', () => { before(async () => { await security.role.create('no_visualize_privileges_role', { diff --git a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/index.js b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/index.js index 6110996a553dc..89ae0125614b6 100644 --- a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/index.js +++ b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/index.js @@ -28,6 +28,8 @@ export default function(kibana) { catalogue: ['foo'], privileges: { all: { + app: ['kibana'], + catalogue: ['foo'], savedObject: { all: ['foo'], read: ['index-pattern'], @@ -35,6 +37,8 @@ export default function(kibana) { ui: ['create', 'edit', 'delete', 'show'], }, read: { + app: ['kibana'], + catalogue: ['foo'], savedObject: { all: [], read: ['foo', 'index-pattern'], From e55ee76b26505fead41854ad10ad8e8f7da5f5da Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Tue, 24 Mar 2020 09:38:00 -0600 Subject: [PATCH 10/56] [Maps] convert layer utils to TS (#60791) * [Maps] convert layer utils to TS * clean up * renovate changes Co-authored-by: Elastic Machine --- renovate.json5 | 8 ++++ .../common/data_request_descriptor_types.d.ts | 30 ++++++++++--- .../plugins/maps/common/map_descriptor.ts | 13 ++++++ .../public/layers/blended_vector_layer.ts | 1 - .../maps/public/layers/sources/es_source.d.ts | 6 +-- .../maps/public/layers/sources/source.d.ts | 17 ++++++- .../public/layers/sources/vector_source.d.ts | 3 +- .../maps/public/layers/tile_layer.test.ts | 13 ++---- ...ids.test.js => assign_feature_ids.test.ts} | 44 +++++++++++++------ ...n_feature_ids.js => assign_feature_ids.ts} | 7 +-- .../{can_skip_fetch.js => can_skip_fetch.ts} | 40 ++++++++++++++--- ...only_query.js => is_refresh_only_query.ts} | 7 ++- ...xpressions.js => mb_filter_expressions.ts} | 6 +-- .../maps/public/layers/vector_layer.d.ts | 4 +- x-pack/package.json | 1 + yarn.lock | 2 +- 16 files changed, 149 insertions(+), 53 deletions(-) create mode 100644 x-pack/legacy/plugins/maps/common/map_descriptor.ts rename x-pack/legacy/plugins/maps/public/layers/util/{assign_feature_ids.test.js => assign_feature_ids.test.ts} (74%) rename x-pack/legacy/plugins/maps/public/layers/util/{assign_feature_ids.js => assign_feature_ids.ts} (90%) rename x-pack/legacy/plugins/maps/public/layers/util/{can_skip_fetch.js => can_skip_fetch.ts} (84%) rename x-pack/legacy/plugins/maps/public/layers/util/{is_refresh_only_query.js => is_refresh_only_query.ts} (78%) rename x-pack/legacy/plugins/maps/public/layers/util/{mb_filter_expressions.js => mb_filter_expressions.ts} (87%) diff --git a/renovate.json5 b/renovate.json5 index e4836537df703..57f175d1afc8e 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -297,6 +297,14 @@ '@types/flot', ], }, + { + groupSlug: 'geojson', + groupName: 'geojson related packages', + packageNames: [ + 'geojson', + '@types/geojson', + ], + }, { groupSlug: 'getopts', groupName: 'getopts related packages', diff --git a/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts b/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts index 3281fb5892eac..a0102a4249a59 100644 --- a/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts +++ b/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts @@ -5,25 +5,41 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { Query } from './map_descriptor'; + +type Extent = { + maxLat: number; + maxLon: number; + minLat: number; + minLon: number; +}; + // Global map state passed to every layer. export type MapFilters = { - buffer: unknown; - extent: unknown; + buffer: Extent; // extent with additional buffer + extent: Extent; // map viewport filters: unknown[]; - query: unknown; + query: Query; refreshTimerLastTriggeredAt: string; timeFilters: unknown; zoom: number; }; -export type VectorLayerRequestMeta = MapFilters & { +export type VectorSourceRequestMeta = MapFilters & { applyGlobalQuery: boolean; fieldNames: string[]; geogridPrecision: number; - sourceQuery: unknown; + sourceQuery: Query; sourceMeta: unknown; }; +export type VectorStyleRequestMeta = MapFilters & { + dynamicStyleFields: string[]; + isTimeAware: boolean; + sourceQuery: Query; + timeFilters: unknown; +}; + export type ESSearchSourceResponseMeta = { areResultsTrimmed?: boolean; sourceType?: string; @@ -35,7 +51,9 @@ export type ESSearchSourceResponseMeta = { }; // Partial because objects are justified downstream in constructors -export type DataMeta = Partial & Partial; +export type DataMeta = Partial & + Partial & + Partial; export type DataRequestDescriptor = { dataId: string; diff --git a/x-pack/legacy/plugins/maps/common/map_descriptor.ts b/x-pack/legacy/plugins/maps/common/map_descriptor.ts new file mode 100644 index 0000000000000..570398e37c5d4 --- /dev/null +++ b/x-pack/legacy/plugins/maps/common/map_descriptor.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +export type Query = { + language: string; + query: string; + queryLastTriggeredAt: string; +}; diff --git a/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts b/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts index 22990538bd5d3..8c54720987e41 100644 --- a/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts +++ b/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts @@ -23,7 +23,6 @@ import { FIELD_ORIGIN, } from '../../common/constants'; import { ESGeoGridSource } from './sources/es_geo_grid_source/es_geo_grid_source'; -// @ts-ignore import { canSkipSourceUpdate } from './util/can_skip_fetch'; import { IVectorLayer, VectorLayerArguments } from './vector_layer'; import { IESSource } from './sources/es_source'; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts index 963a30c7413e8..b565cb9108aea 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts @@ -7,7 +7,7 @@ import { AbstractVectorSource } from './vector_source'; import { IVectorSource } from './vector_source'; import { IndexPattern, SearchSource } from '../../../../../../../src/plugins/data/public'; -import { VectorLayerRequestMeta } from '../../../common/data_request_descriptor_types'; +import { VectorSourceRequestMeta } from '../../../common/data_request_descriptor_types'; export interface IESSource extends IVectorSource { getId(): string; @@ -16,7 +16,7 @@ export interface IESSource extends IVectorSource { getGeoFieldName(): string; getMaxResultWindow(): Promise; makeSearchSource( - searchFilters: VectorLayerRequestMeta, + searchFilters: VectorSourceRequestMeta, limit: number, initialSearchContext?: object ): Promise; @@ -29,7 +29,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource getGeoFieldName(): string; getMaxResultWindow(): Promise; makeSearchSource( - searchFilters: VectorLayerRequestMeta, + searchFilters: VectorSourceRequestMeta, limit: number, initialSearchContext?: object ): Promise; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts index 2ca18e47a4bf9..e1706ad7b7d77 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts @@ -9,15 +9,28 @@ import { ILayer } from '../layer'; export interface ISource { createDefaultLayer(): ILayer; - getDisplayName(): Promise; destroy(): void; + getDisplayName(): Promise; getInspectorAdapters(): object; + isFieldAware(): boolean; + isFilterByMapBounds(): boolean; + isGeoGridPrecisionAware(): boolean; + isQueryAware(): boolean; + isRefreshTimerAware(): Promise; + isTimeAware(): Promise; } export class AbstractSource implements ISource { constructor(sourceDescriptor: AbstractSourceDescriptor, inspectorAdapters: object); + + destroy(): void; createDefaultLayer(): ILayer; getDisplayName(): Promise; - destroy(): void; getInspectorAdapters(): object; + isFieldAware(): boolean; + isFilterByMapBounds(): boolean; + isGeoGridPrecisionAware(): boolean; + isQueryAware(): boolean; + isRefreshTimerAware(): Promise; + isTimeAware(): Promise; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts index 14fc23751ac1a..fd585e100924e 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts @@ -5,6 +5,7 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { FeatureCollection } from 'geojson'; import { AbstractSource, ISource } from './source'; import { IField } from '../fields/field'; import { ESSearchSourceResponseMeta } from '../../../common/data_request_descriptor_types'; @@ -12,7 +13,7 @@ import { ESSearchSourceResponseMeta } from '../../../common/data_request_descrip export type GeoJsonFetchMeta = ESSearchSourceResponseMeta; export type GeoJsonWithMeta = { - data: unknown; // geojson feature collection + data: FeatureCollection; meta?: GeoJsonFetchMeta; }; diff --git a/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts b/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts index 8ce38a128ebc4..1cb99dcbc1a70 100644 --- a/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts +++ b/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts @@ -7,7 +7,7 @@ import { TileLayer } from './tile_layer'; import { EMS_XYZ } from '../../common/constants'; import { XYZTMSSourceDescriptor } from '../../common/descriptor_types'; -import { ITMSSource } from './sources/tms_source'; +import { ITMSSource, AbstractTMSSource } from './sources/tms_source'; import { ILayer } from './layer'; const sourceDescriptor: XYZTMSSourceDescriptor = { @@ -16,9 +16,10 @@ const sourceDescriptor: XYZTMSSourceDescriptor = { id: 'foobar', }; -class MockTileSource implements ITMSSource { +class MockTileSource extends AbstractTMSSource implements ITMSSource { private readonly _descriptor: XYZTMSSourceDescriptor; constructor(descriptor: XYZTMSSourceDescriptor) { + super(descriptor, {}); this._descriptor = descriptor; } createDefaultLayer(): ILayer { @@ -32,14 +33,6 @@ class MockTileSource implements ITMSSource { async getUrlTemplate(): Promise { return 'template/{x}/{y}/{z}.png'; } - - destroy(): void { - // no-op - } - - getInspectorAdapters(): object { - return {}; - } } describe('TileLayer', () => { diff --git a/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.js b/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.ts similarity index 74% rename from x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.js rename to x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.ts index cc8dff14ec4f0..fb07a523e1e07 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.ts @@ -6,19 +6,25 @@ import { assignFeatureIds } from './assign_feature_ids'; import { FEATURE_ID_PROPERTY_NAME } from '../../../common/constants'; +import { FeatureCollection, Feature, Point } from 'geojson'; const featureId = 'myFeature1'; +const geometry: Point = { + type: 'Point', + coordinates: [0, 0], +}; + +const defaultFeature: Feature = { + type: 'Feature', + geometry, + properties: {}, +}; + test('should provide unique id when feature.id is not provided', () => { - const featureCollection = { - features: [ - { - properties: {}, - }, - { - properties: {}, - }, - ], + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', + features: [{ ...defaultFeature }, { ...defaultFeature }], }; const updatedFeatureCollection = assignFeatureIds(featureCollection); @@ -26,16 +32,18 @@ test('should provide unique id when feature.id is not provided', () => { const feature2 = updatedFeatureCollection.features[1]; expect(typeof feature1.id).toBe('number'); expect(typeof feature2.id).toBe('number'); + // @ts-ignore expect(feature1.id).toBe(feature1.properties[FEATURE_ID_PROPERTY_NAME]); expect(feature1.id).not.toBe(feature2.id); }); test('should preserve feature id when provided', () => { - const featureCollection = { + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', features: [ { + ...defaultFeature, id: featureId, - properties: {}, }, ], }; @@ -43,16 +51,19 @@ test('should preserve feature id when provided', () => { const updatedFeatureCollection = assignFeatureIds(featureCollection); const feature1 = updatedFeatureCollection.features[0]; expect(typeof feature1.id).toBe('number'); + // @ts-ignore expect(feature1.id).not.toBe(feature1.properties[FEATURE_ID_PROPERTY_NAME]); + // @ts-ignore expect(feature1.properties[FEATURE_ID_PROPERTY_NAME]).toBe(featureId); }); test('should preserve feature id for falsy value', () => { - const featureCollection = { + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', features: [ { + ...defaultFeature, id: 0, - properties: {}, }, ], }; @@ -60,15 +71,19 @@ test('should preserve feature id for falsy value', () => { const updatedFeatureCollection = assignFeatureIds(featureCollection); const feature1 = updatedFeatureCollection.features[0]; expect(typeof feature1.id).toBe('number'); + // @ts-ignore expect(feature1.id).not.toBe(feature1.properties[FEATURE_ID_PROPERTY_NAME]); + // @ts-ignore expect(feature1.properties[FEATURE_ID_PROPERTY_NAME]).toBe(0); }); test('should not modify original feature properties', () => { const featureProperties = {}; - const featureCollection = { + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', features: [ { + ...defaultFeature, id: featureId, properties: featureProperties, }, @@ -77,6 +92,7 @@ test('should not modify original feature properties', () => { const updatedFeatureCollection = assignFeatureIds(featureCollection); const feature1 = updatedFeatureCollection.features[0]; + // @ts-ignore expect(feature1.properties[FEATURE_ID_PROPERTY_NAME]).toBe(featureId); expect(featureProperties).not.toHaveProperty(FEATURE_ID_PROPERTY_NAME); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.js b/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.ts similarity index 90% rename from x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.js rename to x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.ts index a943b0b22a189..e5c170a803174 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.ts @@ -5,17 +5,18 @@ */ import _ from 'lodash'; +import { FeatureCollection, Feature } from 'geojson'; import { FEATURE_ID_PROPERTY_NAME } from '../../../common/constants'; let idCounter = 0; -function generateNumericalId() { +function generateNumericalId(): number { const newId = idCounter < Number.MAX_SAFE_INTEGER ? idCounter : 0; idCounter = newId + 1; return newId; } -export function assignFeatureIds(featureCollection) { +export function assignFeatureIds(featureCollection: FeatureCollection): FeatureCollection { // wrt https://github.com/elastic/kibana/issues/39317 // In constrained resource environments, mapbox-gl may throw a stackoverflow error due to hitting the browser's recursion limit. This crashes Kibana. // This error is thrown in mapbox-gl's quicksort implementation, when it is sorting all the features by id. @@ -32,7 +33,7 @@ export function assignFeatureIds(featureCollection) { } const randomizedIds = _.shuffle(ids); - const features = []; + const features: Feature[] = []; for (let i = 0; i < featureCollection.features.length; i++) { const numericId = randomizedIds[i]; const feature = featureCollection.features[i]; diff --git a/x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.js b/x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.ts similarity index 84% rename from x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.js rename to x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.ts index 7abfee1b184f0..7b75bb0f21b79 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.ts @@ -4,14 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ import _ from 'lodash'; +// @ts-ignore import turf from 'turf'; import turfBooleanContains from '@turf/boolean-contains'; import { isRefreshOnlyQuery } from './is_refresh_only_query'; +import { ISource } from '../sources/source'; +import { DataMeta } from '../../../common/data_request_descriptor_types'; +import { DataRequest } from './data_request'; const SOURCE_UPDATE_REQUIRED = true; const NO_SOURCE_UPDATE_REQUIRED = false; -export function updateDueToExtent(source, prevMeta = {}, nextMeta = {}) { +export function updateDueToExtent( + source: ISource, + prevMeta: DataMeta = {}, + nextMeta: DataMeta = {} +) { const extentAware = source.isFilterByMapBounds(); if (!extentAware) { return NO_SOURCE_UPDATE_REQUIRED; @@ -20,7 +28,7 @@ export function updateDueToExtent(source, prevMeta = {}, nextMeta = {}) { const { buffer: previousBuffer } = prevMeta; const { buffer: newBuffer } = nextMeta; - if (!previousBuffer) { + if (!previousBuffer || !previousBuffer || !newBuffer) { return SOURCE_UPDATE_REQUIRED; } @@ -51,7 +59,15 @@ export function updateDueToExtent(source, prevMeta = {}, nextMeta = {}) { : SOURCE_UPDATE_REQUIRED; } -export async function canSkipSourceUpdate({ source, prevDataRequest, nextMeta }) { +export async function canSkipSourceUpdate({ + source, + prevDataRequest, + nextMeta, +}: { + source: ISource; + prevDataRequest: DataRequest | undefined; + nextMeta: DataMeta; +}): Promise { const timeAware = await source.isTimeAware(); const refreshTimerAware = await source.isRefreshTimerAware(); const extentAware = source.isFilterByMapBounds(); @@ -67,7 +83,7 @@ export async function canSkipSourceUpdate({ source, prevDataRequest, nextMeta }) !isQueryAware && !isGeoGridPrecisionAware ) { - return prevDataRequest && prevDataRequest.hasDataOrRequestInProgress(); + return !!prevDataRequest && prevDataRequest.hasDataOrRequestInProgress(); } if (!prevDataRequest) { @@ -136,7 +152,13 @@ export async function canSkipSourceUpdate({ source, prevDataRequest, nextMeta }) ); } -export function canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }) { +export function canSkipStyleMetaUpdate({ + prevDataRequest, + nextMeta, +}: { + prevDataRequest: DataRequest | undefined; + nextMeta: DataMeta; +}): boolean { if (!prevDataRequest) { return false; } @@ -159,7 +181,13 @@ export function canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }) { ); } -export function canSkipFormattersUpdate({ prevDataRequest, nextMeta }) { +export function canSkipFormattersUpdate({ + prevDataRequest, + nextMeta, +}: { + prevDataRequest: DataRequest | undefined; + nextMeta: DataMeta; +}): boolean { if (!prevDataRequest) { return false; } diff --git a/x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.js b/x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.ts similarity index 78% rename from x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.js rename to x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.ts index f3dc08a7a7a58..48b1340207fd4 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.ts @@ -4,9 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Query } from '../../../common/map_descriptor'; + // Refresh only query is query where timestamps are different but query is the same. // Triggered by clicking "Refresh" button in QueryBar -export function isRefreshOnlyQuery(prevQuery, newQuery) { +export function isRefreshOnlyQuery( + prevQuery: Query | undefined, + newQuery: Query | undefined +): boolean { if (!prevQuery || !newQuery) { return false; } diff --git a/x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.js b/x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.ts similarity index 87% rename from x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.js rename to x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.ts index 36841dc727dd3..8da6fa2318de9 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.ts @@ -34,14 +34,14 @@ const POINT_MB_FILTER = [ const VISIBLE_POINT_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, POINT_MB_FILTER]; -export function getFillFilterExpression(hasJoins) { +export function getFillFilterExpression(hasJoins: boolean): unknown[] { return hasJoins ? VISIBLE_CLOSED_SHAPE_MB_FILTER : CLOSED_SHAPE_MB_FILTER; } -export function getLineFilterExpression(hasJoins) { +export function getLineFilterExpression(hasJoins: boolean): unknown[] { return hasJoins ? VISIBLE_ALL_SHAPE_MB_FILTER : ALL_SHAPE_MB_FILTER; } -export function getPointFilterExpression(hasJoins) { +export function getPointFilterExpression(hasJoins: boolean): unknown[] { return hasJoins ? VISIBLE_POINT_MB_FILTER : POINT_MB_FILTER; } diff --git a/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts b/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts index 77e8ab768cd00..390374f761fc7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts @@ -8,7 +8,7 @@ import { AbstractLayer } from './layer'; import { IVectorSource } from './sources/vector_source'; import { VectorLayerDescriptor } from '../../common/descriptor_types'; -import { MapFilters, VectorLayerRequestMeta } from '../../common/data_request_descriptor_types'; +import { MapFilters, VectorSourceRequestMeta } from '../../common/data_request_descriptor_types'; import { ILayer } from './layer'; import { IJoin } from './joins/join'; import { IVectorStyle } from './styles/vector/vector_style'; @@ -45,6 +45,6 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer { dataFilters: MapFilters, source: IVectorSource, style: IVectorStyle - ): VectorLayerRequestMeta; + ): VectorSourceRequestMeta; _syncData(syncContext: SyncContext, source: IVectorSource, style: IVectorStyle): Promise; } diff --git a/x-pack/package.json b/x-pack/package.json index fdd2ef3719959..1fd4c261474f7 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -59,6 +59,7 @@ "@types/elasticsearch": "^5.0.33", "@types/fancy-log": "^1.3.1", "@types/file-saver": "^2.0.0", + "@types/geojson": "7946.0.7", "@types/getos": "^3.0.0", "@types/git-url-parse": "^9.0.0", "@types/glob": "^7.1.1", diff --git a/yarn.lock b/yarn.lock index 8b73ceeaef904..37cebe420a362 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4504,7 +4504,7 @@ dependencies: "@types/jquery" "*" -"@types/geojson@*": +"@types/geojson@*", "@types/geojson@7946.0.7": version "7946.0.7" resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.7.tgz#c8fa532b60a0042219cdf173ca21a975ef0666ad" integrity sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ== From c7c6eba0799819d41df4b360581884b9c3c0ff8a Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Tue, 24 Mar 2020 15:41:31 +0000 Subject: [PATCH 11/56] Add async search notification (#60706) * notifications ui * increase timeout to 10s * trigger notification from search interceptor * added an enhanced interceptor * added an enhanced interceptor * docs * docs * fix ts * Fix jest tests for interceptor * update docs * docs * Fix handling syntax error in discover * docs and translations * fix scripted fields err Co-authored-by: Lukas Olson --- .../kibana-plugin-plugins-data-public.md | 2 + ...ublic.requesttimeouterror._constructor_.md | 20 +++ ...plugins-data-public.requesttimeouterror.md | 20 +++ ...-public.searchinterceptor._constructor_.md | 22 +++ ...ublic.searchinterceptor.abortcontroller.md | 13 ++ ...ta-public.searchinterceptor.application.md | 11 ++ ...blic.searchinterceptor.getpendingcount_.md | 13 ++ ...data-public.searchinterceptor.hidetoast.md | 11 ++ ...blic.searchinterceptor.longrunningtoast.md | 13 ++ ...n-plugins-data-public.searchinterceptor.md | 33 ++++ ...public.searchinterceptor.requesttimeout.md | 11 ++ ...ns-data-public.searchinterceptor.search.md | 13 ++ ...data-public.searchinterceptor.showtoast.md | 11 ++ ....searchinterceptor.timeoutsubscriptions.md | 13 ++ ...ns-data-public.searchinterceptor.toasts.md | 11 ++ src/plugins/data/public/index.ts | 2 + src/plugins/data/public/public.api.md | 63 ++++++-- src/plugins/data/public/search/index.ts | 1 + .../public/search/long_query_notification.tsx | 61 ++++++++ src/plugins/data/public/search/mocks.ts | 4 +- .../public/search/search_interceptor.test.ts | 57 ++----- .../data/public/search/search_interceptor.ts | 141 +++++++++++------- .../data/public/search/search_service.ts | 14 +- src/plugins/data/public/search/types.ts | 6 +- x-pack/plugins/data_enhanced/public/plugin.ts | 7 + .../public/search/long_query_notification.tsx | 47 ++++++ .../public/search/search_interceptor.test.ts | 67 +++++++++ .../public/search/search_interceptor.ts | 56 +++++++ 28 files changed, 620 insertions(+), 123 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md create mode 100644 src/plugins/data/public/search/long_query_notification.tsx create mode 100644 x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx create mode 100644 x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts create mode 100644 x-pack/plugins/data_enhanced/public/search/search_interceptor.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index ea77d6f39389b..6964c070097c5 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -18,7 +18,9 @@ | [IndexPatternSelect](./kibana-plugin-plugins-data-public.indexpatternselect.md) | | | [OptionedParamType](./kibana-plugin-plugins-data-public.optionedparamtype.md) | | | [Plugin](./kibana-plugin-plugins-data-public.plugin.md) | | +| [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) | Class used to signify that a request timed out. Useful for applications to conditionally handle this type of error differently than other errors. | | [SearchError](./kibana-plugin-plugins-data-public.searcherror.md) | | +| [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) | | | [SearchSource](./kibana-plugin-plugins-data-public.searchsource.md) | | | [TimeHistory](./kibana-plugin-plugins-data-public.timehistory.md) | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md new file mode 100644 index 0000000000000..25e472817b46d --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) > [(constructor)](./kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md) + +## RequestTimeoutError.(constructor) + +Constructs a new instance of the `RequestTimeoutError` class + +Signature: + +```typescript +constructor(message?: string); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| message | string | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md new file mode 100644 index 0000000000000..84b2fc3fe0b17 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) + +## RequestTimeoutError class + +Class used to signify that a request timed out. Useful for applications to conditionally handle this type of error differently than other errors. + +Signature: + +```typescript +export declare class RequestTimeoutError extends Error +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(message)](./kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md) | | Constructs a new instance of the RequestTimeoutError class | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md new file mode 100644 index 0000000000000..6eabefb9eb912 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [(constructor)](./kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md) + +## SearchInterceptor.(constructor) + +This class should be instantiated with a `requestTimeout` corresponding with how many ms after requests are initiated that they should automatically cancel. + +Signature: + +```typescript +constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number | undefined); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| toasts | ToastsStart | | +| application | ApplicationStart | | +| requestTimeout | number | undefined | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md new file mode 100644 index 0000000000000..0451a2254dc40 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [abortController](./kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md) + +## SearchInterceptor.abortController property + +`abortController` used to signal all searches to abort. + +Signature: + +```typescript +protected abortController: AbortController; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md new file mode 100644 index 0000000000000..e44910161aa60 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [application](./kibana-plugin-plugins-data-public.searchinterceptor.application.md) + +## SearchInterceptor.application property + +Signature: + +```typescript +protected readonly application: ApplicationStart; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md new file mode 100644 index 0000000000000..59b107c92424f --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [getPendingCount$](./kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md) + +## SearchInterceptor.getPendingCount$ property + +Returns an `Observable` over the current number of pending searches. This could mean that one of the search requests is still in flight, or that it has only received partial responses. + +Signature: + +```typescript +getPendingCount$: () => import("rxjs").Observable; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md new file mode 100644 index 0000000000000..59938a755a99e --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [hideToast](./kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md) + +## SearchInterceptor.hideToast property + +Signature: + +```typescript +protected hideToast: () => void; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md new file mode 100644 index 0000000000000..5799039de91bc --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [longRunningToast](./kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md) + +## SearchInterceptor.longRunningToast property + +The current long-running toast (if there is one). + +Signature: + +```typescript +protected longRunningToast?: Toast; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md new file mode 100644 index 0000000000000..0c7b123be72af --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -0,0 +1,33 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) + +## SearchInterceptor class + +Signature: + +```typescript +export declare class SearchInterceptor +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(toasts, application, requestTimeout)](./kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md) | | This class should be instantiated with a requestTimeout corresponding with how many ms after requests are initiated that they should automatically cancel. | + +## Properties + +| Property | Modifiers | Type | Description | +| --- | --- | --- | --- | +| [abortController](./kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md) | | AbortController | abortController used to signal all searches to abort. | +| [application](./kibana-plugin-plugins-data-public.searchinterceptor.application.md) | | ApplicationStart | | +| [getPendingCount$](./kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md) | | () => import("rxjs").Observable<number> | Returns an Observable over the current number of pending searches. This could mean that one of the search requests is still in flight, or that it has only received partial responses. | +| [hideToast](./kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md) | | () => void | | +| [longRunningToast](./kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md) | | Toast | The current long-running toast (if there is one). | +| [requestTimeout](./kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md) | | number | undefined | | +| [search](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable<import("../../common/search").IEsSearchResponse<unknown>> | Searches using the given search method. Overrides the AbortSignal with one that will abort either when cancelPending is called, when the request times out, or when the original AbortSignal is aborted. Updates the pendingCount when the request is started/finalized. | +| [showToast](./kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md) | | () => void | | +| [timeoutSubscriptions](./kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md) | | Set<Subscription> | The subscriptions from scheduling the automatic timeout for each request. | +| [toasts](./kibana-plugin-plugins-data-public.searchinterceptor.toasts.md) | | ToastsStart | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md new file mode 100644 index 0000000000000..3123433762991 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [requestTimeout](./kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md) + +## SearchInterceptor.requestTimeout property + +Signature: + +```typescript +protected readonly requestTimeout?: number | undefined; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md new file mode 100644 index 0000000000000..80c98ab84fb40 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [search](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) + +## SearchInterceptor.search property + +Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort either when `cancelPending` is called, when the request times out, or when the original `AbortSignal` is aborted. Updates the `pendingCount` when the request is started/finalized. + +Signature: + +```typescript +search: (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable>; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md new file mode 100644 index 0000000000000..e495c72b57215 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [showToast](./kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md) + +## SearchInterceptor.showToast property + +Signature: + +```typescript +protected showToast: () => void; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md new file mode 100644 index 0000000000000..072f67591f097 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [timeoutSubscriptions](./kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md) + +## SearchInterceptor.timeoutSubscriptions property + +The subscriptions from scheduling the automatic timeout for each request. + +Signature: + +```typescript +protected timeoutSubscriptions: Set; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md new file mode 100644 index 0000000000000..4953d17c89c39 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [toasts](./kibana-plugin-plugins-data-public.searchinterceptor.toasts.md) + +## SearchInterceptor.toasts property + +Signature: + +```typescript +protected readonly toasts: ToastsStart; +``` diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 977b9568ceaa6..efafea44167d4 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -374,6 +374,8 @@ export { TabbedAggColumn, TabbedAggRow, TabbedTable, + SearchInterceptor, + RequestTimeoutError, } from './search'; // Search namespace diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index f0807187fc254..fcdbccfb42592 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -7,6 +7,7 @@ import { $Values } from '@kbn/utility-types'; import _ from 'lodash'; import { Action } from 'history'; +import { ApplicationStart } from 'kibana/public'; import { Assign } from '@kbn/utility-types'; import { Breadcrumb } from '@elastic/eui'; import { Component } from 'react'; @@ -48,6 +49,9 @@ import { SavedObjectsClientContract } from 'src/core/public'; import { SearchParams } from 'elasticsearch'; import { SearchResponse as SearchResponse_2 } from 'elasticsearch'; import { SimpleSavedObject } from 'src/core/public'; +import { Subscription } from 'rxjs'; +import { Toast } from 'kibana/public'; +import { ToastsStart } from 'kibana/public'; import { UiActionsSetup } from 'src/plugins/ui_actions/public'; import { UiActionsStart } from 'src/plugins/ui_actions/public'; import { Unit } from '@elastic/datemath'; @@ -1465,6 +1469,13 @@ export interface RefreshInterval { value: number; } +// Warning: (ae-missing-release-tag) "RequestTimeoutError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class RequestTimeoutError extends Error { + constructor(message?: string); +} + // Warning: (ae-missing-release-tag) "SavedQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1582,6 +1593,28 @@ export class SearchError extends Error { type: string; } +// Warning: (ae-missing-release-tag) "SearchInterceptor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class SearchInterceptor { + constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number | undefined); + protected abortController: AbortController; + // (undocumented) + protected readonly application: ApplicationStart; + getPendingCount$: () => import("rxjs").Observable; + // (undocumented) + protected hideToast: () => void; + protected longRunningToast?: Toast; + // (undocumented) + protected readonly requestTimeout?: number | undefined; + search: (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable>; + // (undocumented) + protected showToast: () => void; + protected timeoutSubscriptions: Set; + // (undocumented) + protected readonly toasts: ToastsStart; +} + // Warning: (ae-missing-release-tag) "SearchRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1848,21 +1881,21 @@ export type TSearchStrategyProvider = (context: ISearc // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:385:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:386:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:402:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:403:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:407:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:404:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:405:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromEvent" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index f3d2d99af5998..1687d749f46e2 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -57,5 +57,6 @@ export { } from './search_source'; export { SearchInterceptor } from './search_interceptor'; +export { RequestTimeoutError } from './request_timeout_error'; export { FetchOptions } from './fetch'; diff --git a/src/plugins/data/public/search/long_query_notification.tsx b/src/plugins/data/public/search/long_query_notification.tsx new file mode 100644 index 0000000000000..590fee20db690 --- /dev/null +++ b/src/plugins/data/public/search/long_query_notification.tsx @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { ApplicationStart } from 'kibana/public'; +import { toMountPoint } from '../../../kibana_react/public'; + +interface Props { + application: ApplicationStart; +} + +export function getLongQueryNotification(props: Props) { + return toMountPoint(); +} + +export function LongQueryNotification(props: Props) { + return ( +
+ + + + + { + await props.application.navigateToApp( + 'kibana#/management/elasticsearch/license_management' + ); + }} + > + + + + +
+ ); +} diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index 12cf258759a99..b70e889066a45 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -31,10 +31,8 @@ export const searchSetupMock = { export const searchStartMock: jest.Mocked = { aggs: searchAggsStartMock(), + setInterceptor: jest.fn(), search: jest.fn(), - cancel: jest.fn(), - getPendingCount$: jest.fn(), - runBeyondTimeout: jest.fn(), __LEGACY: { AggConfig: jest.fn() as any, AggType: jest.fn(), diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index a89d17464b9e0..bd056271688c1 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -18,27 +18,38 @@ */ import { Observable, Subject } from 'rxjs'; +import { CoreStart } from '../../../../core/public'; +import { coreMock } from '../../../../core/public/mocks'; import { IKibanaSearchRequest } from '../../common/search'; import { RequestTimeoutError } from './request_timeout_error'; import { SearchInterceptor } from './search_interceptor'; jest.useFakeTimers(); -const flushPromises = () => new Promise(resolve => setImmediate(resolve)); const mockSearch = jest.fn(); let searchInterceptor: SearchInterceptor; +let mockCoreStart: MockedKeys; describe('SearchInterceptor', () => { beforeEach(() => { + mockCoreStart = coreMock.createStart(); mockSearch.mockClear(); - searchInterceptor = new SearchInterceptor(1000); + searchInterceptor = new SearchInterceptor( + mockCoreStart.notifications.toasts, + mockCoreStart.application, + 1000 + ); }); describe('search', () => { test('should invoke `search` with the request', () => { - mockSearch.mockReturnValue(new Observable()); + const mockResponse = new Subject(); + mockSearch.mockReturnValue(mockResponse.asObservable()); const mockRequest: IKibanaSearchRequest = {}; - searchInterceptor.search(mockSearch, mockRequest); + const response = searchInterceptor.search(mockSearch, mockRequest); + mockResponse.complete(); + + response.subscribe(); expect(mockSearch.mock.calls[0][0]).toBe(mockRequest); }); @@ -92,44 +103,6 @@ describe('SearchInterceptor', () => { }); }); - describe('cancelPending', () => { - test('should abort all pending requests', async () => { - mockSearch.mockReturnValue(new Observable()); - - searchInterceptor.search(mockSearch, {}); - searchInterceptor.search(mockSearch, {}); - searchInterceptor.cancelPending(); - - await flushPromises(); - - const areAllRequestsAborted = mockSearch.mock.calls.every(([, { signal }]) => signal.aborted); - expect(areAllRequestsAborted).toBe(true); - }); - }); - - describe('runBeyondTimeout', () => { - test('should prevent the request from timing out', () => { - const mockResponse = new Subject(); - mockSearch.mockReturnValue(mockResponse.asObservable()); - const response = searchInterceptor.search(mockSearch, {}); - - setTimeout(searchInterceptor.runBeyondTimeout, 500); - setTimeout(() => mockResponse.next('hi'), 250); - setTimeout(() => mockResponse.complete(), 2000); - - const next = jest.fn(); - const complete = jest.fn(); - const error = jest.fn(); - response.subscribe({ next, error, complete }); - - jest.advanceTimersByTime(2000); - - expect(next).toHaveBeenCalledWith('hi'); - expect(error).not.toHaveBeenCalled(); - expect(complete).toHaveBeenCalled(); - }); - }); - describe('getPendingCount$', () => { test('should observe the number of pending requests', () => { let i = 0; diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index 3f83214f6050c..d83ddab807bc5 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -17,51 +17,59 @@ * under the License. */ -import { BehaviorSubject, fromEvent, throwError } from 'rxjs'; -import { mergeMap, takeUntil, finalize } from 'rxjs/operators'; +import { BehaviorSubject, throwError, timer, Subscription, defer, fromEvent } from 'rxjs'; +import { takeUntil, finalize, filter, mergeMapTo } from 'rxjs/operators'; +import { ApplicationStart, Toast, ToastsStart } from 'kibana/public'; import { getCombinedSignal } from '../../common/utils'; import { IKibanaSearchRequest } from '../../common/search'; import { ISearchGeneric, ISearchOptions } from './i_search'; import { RequestTimeoutError } from './request_timeout_error'; +import { getLongQueryNotification } from './long_query_notification'; export class SearchInterceptor { /** * `abortController` used to signal all searches to abort. */ - private abortController = new AbortController(); + protected abortController = new AbortController(); /** - * Observable that emits when the number of pending requests changes. + * The number of pending search requests. */ - private pendingCount$ = new BehaviorSubject(0); + private pendingCount = 0; /** - * The IDs from `setTimeout` when scheduling the automatic timeout for each request. + * Observable that emits when the number of pending requests changes. */ - private timeoutIds: Set = new Set(); + private pendingCount$ = new BehaviorSubject(this.pendingCount); /** - * This class should be instantiated with a `requestTimeout` corresponding with how many ms after - * requests are initiated that they should automatically cancel. - * @param requestTimeout Usually config value `elasticsearch.requestTimeout` + * The subscriptions from scheduling the automatic timeout for each request. */ - constructor(private readonly requestTimeout?: number) {} + protected timeoutSubscriptions: Set = new Set(); /** - * Abort our `AbortController`, which in turn aborts any intercepted searches. + * The current long-running toast (if there is one). */ - public cancelPending = () => { - this.abortController.abort(); - this.abortController = new AbortController(); - }; + protected longRunningToast?: Toast; /** - * Un-schedule timing out all of the searches intercepted. + * This class should be instantiated with a `requestTimeout` corresponding with how many ms after + * requests are initiated that they should automatically cancel. + * @param toasts The `core.notifications.toasts` service + * @param application The `core.application` service + * @param requestTimeout Usually config value `elasticsearch.requestTimeout` */ - public runBeyondTimeout = () => { - this.timeoutIds.forEach(clearTimeout); - this.timeoutIds.clear(); - }; + constructor( + protected readonly toasts: ToastsStart, + protected readonly application: ApplicationStart, + protected readonly requestTimeout?: number + ) { + // When search requests go out, a notification is scheduled allowing users to continue the + // request past the timeout. When all search requests complete, we remove the notification. + this.getPendingCount$() + .pipe(filter(count => count === 0)) + .subscribe(this.hideToast); + } /** * Returns an `Observable` over the current number of pending searches. This could mean that one @@ -81,41 +89,66 @@ export class SearchInterceptor { request: IKibanaSearchRequest, options?: ISearchOptions ) => { - // Schedule this request to automatically timeout after some interval - const timeoutController = new AbortController(); - const { signal: timeoutSignal } = timeoutController; - const timeoutId = window.setTimeout(() => { - timeoutController.abort(); - }, this.requestTimeout); - this.addTimeoutId(timeoutId); - - // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: - // 1. The user manually aborts (via `cancelPending`) - // 2. The request times out - // 3. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines) - const signals = [this.abortController.signal, timeoutSignal, options?.signal].filter( - Boolean - ) as AbortSignal[]; - const combinedSignal = getCombinedSignal(signals); - - // If the request timed out, throw a `RequestTimeoutError` - const timeoutError$ = fromEvent(timeoutSignal, 'abort').pipe( - mergeMap(() => throwError(new RequestTimeoutError())) - ); + // Defer the following logic until `subscribe` is actually called + return defer(() => { + this.pendingCount$.next(++this.pendingCount); - return search(request as any, { ...options, signal: combinedSignal }).pipe( - takeUntil(timeoutError$), - finalize(() => this.removeTimeoutId(timeoutId)) - ); + // Schedule this request to automatically timeout after some interval + const timeoutController = new AbortController(); + const { signal: timeoutSignal } = timeoutController; + const timeout$ = timer(this.requestTimeout); + const subscription = timeout$.subscribe(() => timeoutController.abort()); + this.timeoutSubscriptions.add(subscription); + + // If the request timed out, throw a `RequestTimeoutError` + const timeoutError$ = fromEvent(timeoutSignal, 'abort').pipe( + mergeMapTo(throwError(new RequestTimeoutError())) + ); + + // Schedule the notification to allow users to cancel or wait beyond the timeout + const notificationSubscription = timer(10000).subscribe(this.showToast); + + // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: + // 1. The user manually aborts (via `cancelPending`) + // 2. The request times out + // 3. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines) + const signals = [ + this.abortController.signal, + timeoutSignal, + ...(options?.signal ? [options.signal] : []), + ]; + const combinedSignal = getCombinedSignal(signals); + + return search(request as any, { ...options, signal: combinedSignal }).pipe( + takeUntil(timeoutError$), + finalize(() => { + this.pendingCount$.next(--this.pendingCount); + this.timeoutSubscriptions.delete(subscription); + notificationSubscription.unsubscribe(); + }) + ); + }); }; - private addTimeoutId(id: number) { - this.timeoutIds.add(id); - this.pendingCount$.next(this.timeoutIds.size); - } + protected showToast = () => { + if (this.longRunningToast) return; + this.longRunningToast = this.toasts.addInfo( + { + title: 'Your query is taking awhile', + text: getLongQueryNotification({ + application: this.application, + }), + }, + { + toastLifeTimeMs: Infinity, + } + ); + }; - private removeTimeoutId(id: number) { - this.timeoutIds.delete(id); - this.pendingCount$.next(this.timeoutIds.size); - } + protected hideToast = () => { + if (this.longRunningToast) { + this.toasts.remove(this.longRunningToast); + delete this.longRunningToast; + } + }; } diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 62c7e0468bb88..311a8a2fc6f60 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -58,6 +58,7 @@ export class SearchService implements Plugin { private esClient?: LegacyApiCaller; private readonly aggTypesRegistry = new AggTypesRegistry(); + private searchInterceptor!: SearchInterceptor; private registerSearchStrategyProvider = ( name: T, @@ -98,7 +99,9 @@ export class SearchService implements Plugin { * TODO: Make this modular so that apps can opt in/out of search collection, or even provide * their own search collector instances */ - const searchInterceptor = new SearchInterceptor( + this.searchInterceptor = new SearchInterceptor( + core.notifications.toasts, + core.application, core.injectedMetadata.getInjectedVar('esRequestTimeout') as number ); @@ -114,16 +117,17 @@ export class SearchService implements Plugin { }, types: aggTypesStart, }, - cancel: () => searchInterceptor.cancelPending(), - getPendingCount$: () => searchInterceptor.getPendingCount$(), - runBeyondTimeout: () => searchInterceptor.runBeyondTimeout(), search: (request, options, strategyName) => { const strategyProvider = this.getSearchStrategy(strategyName || DEFAULT_SEARCH_STRATEGY); const { search } = strategyProvider({ core, getSearchStrategy: this.getSearchStrategy, }); - return searchInterceptor.search(search as any, request, options); + return this.searchInterceptor.search(search as any, request, options); + }, + setInterceptor: (searchInterceptor: SearchInterceptor) => { + // TODO: should an intercepror have a destroy method? + this.searchInterceptor = searchInterceptor; }, __LEGACY: { esClient: this.esClient!, diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 1b551f978b971..03cbfa9f8ed84 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -17,12 +17,12 @@ * under the License. */ -import { Observable } from 'rxjs'; import { CoreStart } from 'kibana/public'; import { SearchAggsSetup, SearchAggsStart, SearchAggsStartLegacy } from './aggs'; import { ISearch, ISearchGeneric } from './i_search'; import { TStrategyTypes } from './strategy_types'; import { LegacyApiCaller } from './es_client'; +import { SearchInterceptor } from './search_interceptor'; export interface ISearchContext { core: CoreStart; @@ -87,9 +87,7 @@ export interface ISearchSetup { export interface ISearchStart { aggs: SearchAggsStart; - cancel: () => void; - getPendingCount$: () => Observable; - runBeyondTimeout: () => void; + setInterceptor: (searchInterceptor: SearchInterceptor) => void; search: ISearchGeneric; __LEGACY: ISearchStartLegacy & SearchAggsStartLegacy; } diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 6316d87c50519..72e0817eea8df 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -17,6 +17,7 @@ import { asyncSearchStrategyProvider, enhancedEsSearchStrategyProvider, } from './search'; +import { EnhancedSearchInterceptor } from './search/search_interceptor'; export interface DataEnhancedSetupDependencies { data: DataPublicPluginSetup; @@ -45,5 +46,11 @@ export class DataEnhancedPlugin implements Plugin { public start(core: CoreStart, plugins: DataEnhancedStartDependencies) { setAutocompleteService(plugins.data.autocomplete); + const enhancedSearchInterceptor = new EnhancedSearchInterceptor( + core.notifications.toasts, + core.application, + core.injectedMetadata.getInjectedVar('esRequestTimeout') as number + ); + plugins.data.search.setInterceptor(enhancedSearchInterceptor); } } diff --git a/x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx b/x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx new file mode 100644 index 0000000000000..325cf1145fa5f --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; + +interface Props { + cancel: () => void; + runBeyondTimeout: () => void; +} + +export function getLongQueryNotification(props: Props) { + return toMountPoint( + + ); +} + +export function LongQueryNotification(props: Props) { + return ( +
+ + + + + + + + + + + + + +
+ ); +} diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts new file mode 100644 index 0000000000000..1e554d3ff2d86 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable, Subject } from 'rxjs'; +import { coreMock } from '../../../../../src/core/public/mocks'; +import { EnhancedSearchInterceptor } from './search_interceptor'; +import { CoreStart } from 'kibana/public'; + +jest.useFakeTimers(); + +const flushPromises = () => new Promise(resolve => setImmediate(resolve)); +const mockSearch = jest.fn(); +let searchInterceptor: EnhancedSearchInterceptor; +let mockCoreStart: MockedKeys; + +describe('EnhancedSearchInterceptor', () => { + beforeEach(() => { + mockCoreStart = coreMock.createStart(); + mockSearch.mockClear(); + searchInterceptor = new EnhancedSearchInterceptor( + mockCoreStart.notifications.toasts, + mockCoreStart.application, + 1000 + ); + }); + + describe('cancelPending', () => { + test('should abort all pending requests', async () => { + mockSearch.mockReturnValue(new Observable()); + + searchInterceptor.search(mockSearch, {}); + searchInterceptor.search(mockSearch, {}); + searchInterceptor.cancelPending(); + + await flushPromises(); + + const areAllRequestsAborted = mockSearch.mock.calls.every(([, { signal }]) => signal.aborted); + expect(areAllRequestsAborted).toBe(true); + }); + }); + + describe('runBeyondTimeout', () => { + test('should prevent the request from timing out', () => { + const mockResponse = new Subject(); + mockSearch.mockReturnValue(mockResponse.asObservable()); + const response = searchInterceptor.search(mockSearch, {}); + + setTimeout(searchInterceptor.runBeyondTimeout, 500); + setTimeout(() => mockResponse.next('hi'), 250); + setTimeout(() => mockResponse.complete(), 2000); + + const next = jest.fn(); + const complete = jest.fn(); + const error = jest.fn(); + response.subscribe({ next, error, complete }); + + jest.advanceTimersByTime(2000); + + expect(next).toHaveBeenCalledWith('hi'); + expect(error).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts new file mode 100644 index 0000000000000..38452dee9a2da --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -0,0 +1,56 @@ +/* + * 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 { ApplicationStart, ToastsStart } from 'kibana/public'; +import { getLongQueryNotification } from './long_query_notification'; +import { SearchInterceptor } from '../../../../../src/plugins/data/public'; + +export class EnhancedSearchInterceptor extends SearchInterceptor { + /** + * This class should be instantiated with a `requestTimeout` corresponding with how many ms after + * requests are initiated that they should automatically cancel. + * @param toasts The `core.notifications.toasts` service + * @param application The `core.application` service + * @param requestTimeout Usually config value `elasticsearch.requestTimeout` + */ + constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number) { + super(toasts, application, requestTimeout); + } + + /** + * Abort our `AbortController`, which in turn aborts any intercepted searches. + */ + public cancelPending = () => { + this.hideToast(); + this.abortController.abort(); + this.abortController = new AbortController(); + }; + + /** + * Un-schedule timing out all of the searches intercepted. + */ + public runBeyondTimeout = () => { + this.hideToast(); + this.timeoutSubscriptions.forEach(subscription => subscription.unsubscribe()); + this.timeoutSubscriptions.clear(); + }; + + protected showToast = () => { + if (this.longRunningToast) return; + this.longRunningToast = this.toasts.addInfo( + { + title: 'Your query is taking awhile', + text: getLongQueryNotification({ + cancel: this.cancelPending, + runBeyondTimeout: this.runBeyondTimeout, + }), + }, + { + toastLifeTimeMs: Infinity, + } + ); + }; +} From 9dab555787159cab326a8b22f173cd5dbd8eaaad Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Tue, 24 Mar 2020 11:58:52 -0400 Subject: [PATCH 12/56] Move extract/inject dashboard references to NP, use NP start contract (#60290) * move some types and functions to np * make saved dashboard loader a singleton * fix bad merge Co-authored-by: Elastic Machine --- .../kibana/public/dashboard/index.ts | 1 - .../dashboard/migrations/is_dashboard_doc.ts | 2 +- .../migrations/migrate_to_730_panels.test.ts | 2 - .../migrations/migrate_to_730_panels.ts | 6 +-- .../migrations/migrations_730.test.ts | 2 +- .../dashboard/migrations/migrations_730.ts | 5 ++- .../dashboard/np_ready/dashboard_app.tsx | 2 +- .../np_ready/dashboard_state_manager.ts | 7 ++-- .../lib/embeddable_saved_object_converters.ts | 2 +- .../np_ready/lib/get_app_state_defaults.ts | 2 +- .../np_ready/lib/update_saved_dashboard.ts | 2 +- .../test_utils/get_saved_dashboard_mock.ts | 2 +- .../kibana/public/dashboard/np_ready/types.ts | 10 +---- .../kibana/public/dashboard/plugin.ts | 14 +++---- .../management/saved_object_registry.ts | 3 +- .../new_platform/new_platform.karma_mock.js | 3 ++ .../ui/public/new_platform/new_platform.ts | 2 + src/plugins/dashboard/kibana.json | 3 +- src/plugins/dashboard/public/bwc/index.ts | 20 +++++++++ .../dashboard/public/bwc}/types.ts | 23 ++++++++++- .../dashboard/public/dashboard_constants.ts | 32 +++++++++++++++ .../dashboard/public/embeddable/index.ts | 2 +- src/plugins/dashboard/public/index.ts | 41 ++++++++++++++++--- src/plugins/dashboard/public/plugin.tsx | 37 +++++++++++++---- .../public/saved_dashboards/index.ts | 21 ++++++++++ .../saved_dashboards}/saved_dashboard.ts | 19 ++++----- .../saved_dashboard_references.test.ts | 0 .../saved_dashboard_references.ts | 0 .../saved_dashboards}/saved_dashboards.ts | 19 ++++++--- 29 files changed, 216 insertions(+), 68 deletions(-) create mode 100644 src/plugins/dashboard/public/bwc/index.ts rename src/{legacy/core_plugins/kibana/public/dashboard/migrations => plugins/dashboard/public/bwc}/types.ts (91%) create mode 100644 src/plugins/dashboard/public/dashboard_constants.ts create mode 100644 src/plugins/dashboard/public/saved_dashboards/index.ts rename src/{legacy/core_plugins/kibana/public/dashboard/saved_dashboard => plugins/dashboard/public/saved_dashboards}/saved_dashboard.ts (86%) rename src/{legacy/core_plugins/kibana/public/dashboard/saved_dashboard => plugins/dashboard/public/saved_dashboards}/saved_dashboard_references.test.ts (100%) rename src/{legacy/core_plugins/kibana/public/dashboard/saved_dashboard => plugins/dashboard/public/saved_dashboards}/saved_dashboard_references.ts (100%) rename src/{legacy/core_plugins/kibana/public/dashboard/saved_dashboard => plugins/dashboard/public/saved_dashboards}/saved_dashboards.ts (67%) diff --git a/src/legacy/core_plugins/kibana/public/dashboard/index.ts b/src/legacy/core_plugins/kibana/public/dashboard/index.ts index 5b9fb8c0b6360..8900d017ef81a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/index.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/index.ts @@ -21,7 +21,6 @@ import { PluginInitializerContext } from 'kibana/public'; import { DashboardPlugin } from './plugin'; export * from './np_ready/dashboard_constants'; -export { createSavedDashboardLoader } from './saved_dashboard/saved_dashboards'; // Core will be looking for this when loading our plugin in the new platform export const plugin = (context: PluginInitializerContext) => { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/is_dashboard_doc.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/is_dashboard_doc.ts index f3bc2a4a4e155..d8f8882a218dd 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/is_dashboard_doc.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/is_dashboard_doc.ts @@ -17,7 +17,7 @@ * under the License. */ -import { DashboardDoc730ToLatest } from './types'; +import { DashboardDoc730ToLatest } from '../../../../../../plugins/dashboard/public'; import { isDoc } from '../../../migrations/is_doc'; export function isDashboardDoc( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts index 2189b53ac81ee..e37c8de08fec4 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts @@ -44,8 +44,6 @@ import { RawSavedDashboardPanel620, RawSavedDashboardPanel630, RawSavedDashboardPanel640To720, -} from './types'; -import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT, } from '../../../../../../plugins/dashboard/public'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.ts index 6b037fa63cf68..047ec15f9a5d6 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.ts @@ -18,17 +18,17 @@ */ import { i18n } from '@kbn/i18n'; import semver from 'semver'; -import { GridData } from 'src/plugins/dashboard/public'; - import uuid from 'uuid'; import { + GridData, RawSavedDashboardPanelTo60, RawSavedDashboardPanel630, RawSavedDashboardPanel640To720, RawSavedDashboardPanel730ToLatest, RawSavedDashboardPanel610, RawSavedDashboardPanel620, -} from './types'; +} from '../../../../../../plugins/dashboard/public'; + import { SavedDashboardPanelTo60, SavedDashboardPanel620, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.test.ts index 86d399d219a26..34bb46ce5d407 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.test.ts @@ -24,7 +24,7 @@ import { DashboardDoc730ToLatest, RawSavedDashboardPanel730ToLatest, DashboardDocPre700, -} from './types'; +} from '../../../../../../plugins/dashboard/public'; const mockLogger = { warning: () => {}, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.ts index 1ab5738cf4752..56856f7b21303 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.ts @@ -20,7 +20,10 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { SavedObjectsMigrationLogger } from 'src/core/server'; import { inspect } from 'util'; -import { DashboardDoc730ToLatest, DashboardDoc700To720 } from './types'; +import { + DashboardDoc730ToLatest, + DashboardDoc700To720, +} from '../../../../../../plugins/dashboard/public'; import { isDashboardDoc } from './is_dashboard_doc'; import { moveFiltersToQuery } from './move_filters_to_query'; import { migratePanelsTo730 } from './migrate_to_730_panels'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx index 4e9942767186e..e21033ffe10ec 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx @@ -22,7 +22,7 @@ import { Subscription } from 'rxjs'; import { History } from 'history'; import { ViewMode } from '../../../../embeddable_api/public/np_ready/public'; -import { SavedObjectDashboard } from '../saved_dashboard/saved_dashboard'; +import { SavedObjectDashboard } from '../../../../../../plugins/dashboard/public'; import { DashboardAppState, SavedDashboardPanel } from './types'; import { IIndexPattern, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts index f29721e3c3d5c..171f08b45cf8d 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts @@ -23,7 +23,10 @@ import { Observable, Subscription } from 'rxjs'; import { Moment } from 'moment'; import { History } from 'history'; -import { DashboardContainer } from 'src/plugins/dashboard/public'; +import { + DashboardContainer, + SavedObjectDashboard, +} from '../../../../../../plugins/dashboard/public'; import { ViewMode } from '../../../../../../plugins/embeddable/public'; import { migrateLegacyQuery } from '../legacy_imports'; import { @@ -35,8 +38,6 @@ import { import { getAppStateDefaults, migrateAppState } from './lib'; import { convertPanelStateToSavedDashboardPanel } from './lib/embeddable_saved_object_converters'; import { FilterUtils } from './lib/filter_utils'; -import { SavedObjectDashboard } from '../saved_dashboard/saved_dashboard'; - import { DashboardAppState, DashboardAppStateDefaults, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/embeddable_saved_object_converters.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/embeddable_saved_object_converters.ts index 7d5a378885470..500ee7e28daa6 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/embeddable_saved_object_converters.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/embeddable_saved_object_converters.ts @@ -17,7 +17,7 @@ * under the License. */ import { omit } from 'lodash'; -import { DashboardPanelState } from 'src/plugins/dashboard/public'; +import { DashboardPanelState } from '../../../../../../../plugins/dashboard/public'; import { SavedDashboardPanel } from '../types'; export function convertSavedDashboardPanelToPanelState( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/get_app_state_defaults.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/get_app_state_defaults.ts index eceb51f17d164..b3acefeba0146 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/get_app_state_defaults.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/get_app_state_defaults.ts @@ -18,7 +18,7 @@ */ import { ViewMode } from '../../../../../../../plugins/embeddable/public'; -import { SavedObjectDashboard } from '../../saved_dashboard/saved_dashboard'; +import { SavedObjectDashboard } from '../../../../../../../plugins/dashboard/public'; import { DashboardAppStateDefaults } from '../types'; export function getAppStateDefaults( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/update_saved_dashboard.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/update_saved_dashboard.ts index ec8073c0f72f7..dee279550aa6a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/update_saved_dashboard.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/update_saved_dashboard.ts @@ -20,7 +20,7 @@ import _ from 'lodash'; import { RefreshInterval, TimefilterContract } from 'src/plugins/data/public'; import { FilterUtils } from './filter_utils'; -import { SavedObjectDashboard } from '../../saved_dashboard/saved_dashboard'; +import { SavedObjectDashboard } from '../../../../../../../plugins/dashboard/public'; import { DashboardAppState } from '../types'; export function updateSavedDashboard( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/test_utils/get_saved_dashboard_mock.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/test_utils/get_saved_dashboard_mock.ts index 60b2a33f720ec..53618f1cfe5fa 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/test_utils/get_saved_dashboard_mock.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/test_utils/get_saved_dashboard_mock.ts @@ -18,7 +18,7 @@ */ import { searchSourceMock } from '../../../../../../../plugins/data/public/mocks'; -import { SavedObjectDashboard } from '../../saved_dashboard/saved_dashboard'; +import { SavedObjectDashboard } from '../../../../../../../plugins/dashboard/public/'; export function getSavedDashboardMock( config?: Partial diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/types.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/types.ts index 0f3a7e322ebf3..9f8682f13d811 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/types.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/types.ts @@ -25,19 +25,11 @@ import { RawSavedDashboardPanel630, RawSavedDashboardPanel640To720, RawSavedDashboardPanel730ToLatest, -} from '../migrations/types'; +} from '../../../../../../plugins/dashboard/public'; import { Query, Filter } from '../../../../../../plugins/data/public'; export type NavAction = (anchorElement?: any) => void; -export interface GridData { - w: number; - h: number; - x: number; - y: number; - i: string; -} - /** * This should always represent the latest dashboard panel shape, after all possible migrations. */ diff --git a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts index a9ee77921ed4a..7452807454fe7 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts @@ -49,8 +49,8 @@ import { KibanaLegacySetup, KibanaLegacyStart, } from '../../../../../plugins/kibana_legacy/public'; -import { createSavedDashboardLoader } from './saved_dashboard/saved_dashboards'; import { createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; +import { DashboardStart } from '../../../../../plugins/dashboard/public'; export interface DashboardPluginStartDependencies { data: DataPublicPluginStart; @@ -58,6 +58,7 @@ export interface DashboardPluginStartDependencies { navigation: NavigationStart; share: SharePluginStart; kibanaLegacy: KibanaLegacyStart; + dashboard: DashboardStart; } export interface DashboardPluginSetupDependencies { @@ -74,6 +75,7 @@ export class DashboardPlugin implements Plugin { navigation: NavigationStart; share: SharePluginStart; dashboardConfig: KibanaLegacyStart['dashboardConfig']; + dashboard: DashboardStart; } | null = null; private appStateUpdater = new BehaviorSubject(() => ({})); @@ -129,13 +131,9 @@ export class DashboardPlugin implements Plugin { share, data: dataStart, dashboardConfig, + dashboard: { getSavedDashboardLoader }, } = this.startDependencies; - const savedDashboards = createSavedDashboardLoader({ - savedObjectsClient, - indexPatterns: dataStart.indexPatterns, - chrome: coreStart.chrome, - overlays: coreStart.overlays, - }); + const savedDashboards = getSavedDashboardLoader(); const deps: RenderDeps = { pluginInitializerContext: this.initializerContext, @@ -199,6 +197,7 @@ export class DashboardPlugin implements Plugin { data, share, kibanaLegacy: { dashboardConfig }, + dashboard, }: DashboardPluginStartDependencies ) { this.startDependencies = { @@ -208,6 +207,7 @@ export class DashboardPlugin implements Plugin { navigation, share, dashboardConfig, + dashboard, }; } diff --git a/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts b/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts index cb9ac0e01bb7f..f3a37e2b7348f 100644 --- a/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts +++ b/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts @@ -21,7 +21,6 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { npStart } from 'ui/new_platform'; import { SavedObjectLoader } from '../../../../../plugins/saved_objects/public'; -import { createSavedDashboardLoader } from '../dashboard'; import { start as visualizations } from '../../../visualizations/public/np_ready/public/legacy'; import { createSavedSearchesLoader } from '../../../../../plugins/discover/public'; @@ -70,7 +69,7 @@ savedObjectManagementRegistry.register({ savedObjectManagementRegistry.register({ id: 'savedDashboards', - service: createSavedDashboardLoader(services), + service: npStart.plugins.dashboard.getSavedDashboardLoader(), title: i18n.translate('kbn.dashboard.savedDashboardsTitle', { defaultMessage: 'dashboards', }), diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index 809022620e69d..67877c5382633 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -330,6 +330,9 @@ export const npStart = { getHideWriteControls: sinon.fake(), }, }, + dashboard: { + getSavedDashboardLoader: sinon.fake(), + }, data: { actions: { createFiltersFromEvent: Promise.resolve(['yes']), diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index ee14f192a2149..b315abec1a64b 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -22,6 +22,7 @@ import { IScope } from 'angular'; import { UiActionsStart, UiActionsSetup } from 'src/plugins/ui_actions/public'; import { EmbeddableStart, EmbeddableSetup } from 'src/plugins/embeddable/public'; import { createBrowserHistory } from 'history'; +import { DashboardStart } from '../../../../plugins/dashboard/public'; import { LegacyCoreSetup, LegacyCoreStart, @@ -104,6 +105,7 @@ export interface PluginsStart { advancedSettings: AdvancedSettingsStart; discover: DiscoverStart; telemetry?: TelemetryPluginStart; + dashboard: DashboardStart; } export const npSetup = { diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index e5a657555819a..e35599a5f0b66 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -5,7 +5,8 @@ "data", "embeddable", "inspector", - "uiActions" + "uiActions", + "savedObjects" ], "optionalPlugins": [ "share" diff --git a/src/plugins/dashboard/public/bwc/index.ts b/src/plugins/dashboard/public/bwc/index.ts new file mode 100644 index 0000000000000..d8f7b5091eb8f --- /dev/null +++ b/src/plugins/dashboard/public/bwc/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './types'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/types.ts b/src/plugins/dashboard/public/bwc/types.ts similarity index 91% rename from src/legacy/core_plugins/kibana/public/dashboard/migrations/types.ts rename to src/plugins/dashboard/public/bwc/types.ts index c264358a8f81f..e9b9d392e9b7d 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/types.ts +++ b/src/plugins/dashboard/public/bwc/types.ts @@ -17,8 +17,27 @@ * under the License. */ -import { GridData } from '../np_ready/types'; -import { Doc, DocPre700 } from '../../../migrations/types'; +import { SavedObjectReference } from 'kibana/public'; +import { GridData } from '../../../../plugins/dashboard/public'; + +export interface SavedObjectAttributes { + kibanaSavedObjectMeta: { + searchSourceJSON: string; + }; +} + +export interface Doc { + references: SavedObjectReference[]; + attributes: Attributes; + id: string; + type: string; +} + +export interface DocPre700 { + attributes: Attributes; + id: string; + type: string; +} export interface SavedObjectAttributes { kibanaSavedObjectMeta: { diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts new file mode 100644 index 0000000000000..0820ebd371004 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const DashboardConstants = { + ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM: 'addToDashboard', + LANDING_PAGE_PATH: '/dashboards', + CREATE_NEW_DASHBOARD_URL: '/dashboard', + ADD_EMBEDDABLE_ID: 'addEmbeddableId', + ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', + DASHBOARDS_ID: 'dashboards', + DASHBOARD_ID: 'dashboard', +}; + +export function createDashboardEditUrl(id: string) { + return `/dashboard/${id}`; +} diff --git a/src/plugins/dashboard/public/embeddable/index.ts b/src/plugins/dashboard/public/embeddable/index.ts index 58bfd5eedefcb..fcc5fe5202bd2 100644 --- a/src/plugins/dashboard/public/embeddable/index.ts +++ b/src/plugins/dashboard/public/embeddable/index.ts @@ -21,7 +21,7 @@ export { DashboardContainerFactory } from './dashboard_container_factory'; export { DashboardContainer, DashboardContainerInput } from './dashboard_container'; export { createPanelState } from './panel'; -export { DashboardPanelState, GridData } from './types'; +export * from './types'; export { DASHBOARD_GRID_COLUMN_COUNT, diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index c6846346b64ef..070e437ce52ef 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -22,14 +22,43 @@ import './index.scss'; import { PluginInitializerContext } from '../../../core/public'; import { DashboardEmbeddableContainerPublicPlugin } from './plugin'; -export * from './types'; -export * from './actions'; -export * from './embeddable'; +/** + * These types can probably be internal once all of dashboard app is migrated into this plugin. Right + * now, migrations are still in legacy land. + */ +export { + DashboardDoc730ToLatest, + DashboardDoc700To720, + RawSavedDashboardPanelTo60, + RawSavedDashboardPanel610, + RawSavedDashboardPanel620, + RawSavedDashboardPanel630, + RawSavedDashboardPanel640To720, + RawSavedDashboardPanel730ToLatest, + DashboardDocPre700, +} from './bwc'; -export function plugin(initializerContext: PluginInitializerContext) { - return new DashboardEmbeddableContainerPublicPlugin(initializerContext); -} +export {} from './types'; +export {} from './actions'; +export { + DashboardContainer, + DashboardContainerInput, + DashboardContainerFactory, + DASHBOARD_CONTAINER_TYPE, + DashboardPanelState, + // Types below here can likely be made private when dashboard app moved into this NP plugin. + DEFAULT_PANEL_WIDTH, + DEFAULT_PANEL_HEIGHT, + GridData, +} from './embeddable'; + +export { SavedObjectDashboard } from './saved_dashboards'; +export { DashboardStart } from './plugin'; export { DashboardEmbeddableContainerPublicPlugin as Plugin }; export { DASHBOARD_APP_URL_GENERATOR } from './url_generator'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new DashboardEmbeddableContainerPublicPlugin(initializerContext); +} diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index d663c736e5aed..3d67435e6d8f7 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -21,13 +21,18 @@ import * as React from 'react'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { SharePluginSetup } from 'src/plugins/share/public'; +import { + CONTEXT_MENU_TRIGGER, + EmbeddableSetup, + EmbeddableStart, +} from '../../../plugins/embeddable/public'; +import { DataPublicPluginStart } from '../../../plugins/data/public'; +import { SharePluginSetup } from '../../../plugins/share/public'; import { UiActionsSetup, UiActionsStart } from '../../../plugins/ui_actions/public'; -import { CONTEXT_MENU_TRIGGER, EmbeddableSetup, EmbeddableStart } from './embeddable_plugin'; -import { ExpandPanelAction, ReplacePanelAction } from '.'; +import { ExpandPanelAction, ReplacePanelAction } from './actions'; import { DashboardContainerFactory } from './embeddable/dashboard_container_factory'; import { Start as InspectorStartContract } from '../../../plugins/inspector/public'; -import { getSavedObjectFinder } from '../../../plugins/saved_objects/public'; +import { getSavedObjectFinder, SavedObjectLoader } from '../../../plugins/saved_objects/public'; import { ExitFullScreenButton as ExitFullScreenButtonUi, ExitFullScreenButtonProps, @@ -39,6 +44,7 @@ import { DASHBOARD_APP_URL_GENERATOR, createDirectAccessDashboardLinkGenerator, } from './url_generator'; +import { createSavedDashboardLoader } from './saved_dashboards'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -56,10 +62,13 @@ interface StartDependencies { embeddable: EmbeddableStart; inspector: InspectorStartContract; uiActions: UiActionsStart; + data: DataPublicPluginStart; } export type Setup = void; -export type Start = void; +export interface DashboardStart { + getSavedDashboardLoader: () => SavedObjectLoader; +} declare module '../../../plugins/ui_actions/public' { export interface ActionContextMapping { @@ -69,7 +78,7 @@ declare module '../../../plugins/ui_actions/public' { } export class DashboardEmbeddableContainerPublicPlugin - implements Plugin { + implements Plugin { constructor(initializerContext: PluginInitializerContext) {} public setup( @@ -121,9 +130,12 @@ export class DashboardEmbeddableContainerPublicPlugin embeddable.registerEmbeddableFactory(factory.type, factory); } - public start(core: CoreStart, plugins: StartDependencies): Start { + public start(core: CoreStart, plugins: StartDependencies): DashboardStart { const { notifications } = core; - const { uiActions } = plugins; + const { + uiActions, + data: { indexPatterns }, + } = plugins; const SavedObjectFinder = getSavedObjectFinder(core.savedObjects, core.uiSettings); @@ -135,6 +147,15 @@ export class DashboardEmbeddableContainerPublicPlugin ); uiActions.registerAction(changeViewAction); uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, changeViewAction); + const savedDashboardLoader = createSavedDashboardLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns, + chrome: core.chrome, + overlays: core.overlays, + }); + return { + getSavedDashboardLoader: () => savedDashboardLoader, + }; } public stop() {} diff --git a/src/plugins/dashboard/public/saved_dashboards/index.ts b/src/plugins/dashboard/public/saved_dashboards/index.ts new file mode 100644 index 0000000000000..9b7745bd884f7 --- /dev/null +++ b/src/plugins/dashboard/public/saved_dashboards/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export * from './saved_dashboard_references'; +export * from './saved_dashboard'; +export * from './saved_dashboards'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts similarity index 86% rename from src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.ts rename to src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index c5ac05b5a77eb..c4ebf4f07a5db 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -20,16 +20,11 @@ import { createSavedObjectClass, SavedObject, SavedObjectKibanaServices, -} from '../../../../../../plugins/saved_objects/public'; +} from '../../../../plugins/saved_objects/public'; import { extractReferences, injectReferences } from './saved_dashboard_references'; -import { - Filter, - ISearchSource, - Query, - RefreshInterval, -} from '../../../../../../plugins/data/public'; -import { createDashboardEditUrl } from '..'; +import { Filter, ISearchSource, Query, RefreshInterval } from '../../../../plugins/data/public'; +import { createDashboardEditUrl } from '../dashboard_constants'; export interface SavedObjectDashboard extends SavedObject { id?: string; @@ -49,7 +44,9 @@ export interface SavedObjectDashboard extends SavedObject { } // Used only by the savedDashboards service, usually no reason to change this -export function createSavedDashboardClass(services: SavedObjectKibanaServices) { +export function createSavedDashboardClass( + services: SavedObjectKibanaServices +): new (id: string) => SavedObjectDashboard { const SavedObjectClass = createSavedObjectClass(services); class SavedDashboard extends SavedObjectClass { // save these objects with the 'dashboard' type @@ -121,5 +118,7 @@ export function createSavedDashboardClass(services: SavedObjectKibanaServices) { } } - return SavedDashboard; + // Unfortunately this throws a typescript error without the casting. I think it's due to the + // convoluted way SavedObjects are created. + return (SavedDashboard as unknown) as new (id: string) => SavedObjectDashboard; } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.test.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard_references.test.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.test.ts rename to src/plugins/dashboard/public/saved_dashboards/saved_dashboard_references.test.ts diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard_references.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.ts rename to src/plugins/dashboard/public/saved_dashboards/saved_dashboard_references.ts diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboards.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts similarity index 67% rename from src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboards.ts rename to src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts index 2ff76da9c5ca6..2a1e64fa88a02 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboards.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts @@ -17,13 +17,22 @@ * under the License. */ -import { - SavedObjectLoader, - SavedObjectKibanaServices, -} from '../../../../../../plugins/saved_objects/public'; +import { SavedObjectsClientContract, ChromeStart, OverlayStart } from 'kibana/public'; +import { IndexPatternsContract } from '../../../../plugins/data/public'; +import { SavedObjectLoader } from '../../../../plugins/saved_objects/public'; import { createSavedDashboardClass } from './saved_dashboard'; -export function createSavedDashboardLoader(services: SavedObjectKibanaServices) { +interface Services { + savedObjectsClient: SavedObjectsClientContract; + indexPatterns: IndexPatternsContract; + chrome: ChromeStart; + overlays: OverlayStart; +} + +/** + * @param services + */ +export function createSavedDashboardLoader(services: Services) { const SavedDashboard = createSavedDashboardClass(services); return new SavedObjectLoader(SavedDashboard, services.savedObjectsClient, services.chrome); } From 427848c3be63671d337659cca05b1e38dce1cd16 Mon Sep 17 00:00:00 2001 From: Joel Griffith Date: Tue, 24 Mar 2020 09:07:39 -0700 Subject: [PATCH 13/56] Revert "[Reporting/New Platform Migration] Use a new config service on server-side (#55882)" (#61075) This reverts commit 5755b2ac522483bd71ad0e1b31459338ff69cf93. --- .../__snapshots__/index.test.js.snap | 383 ++++++++++++++++++ x-pack/legacy/plugins/reporting/config.ts | 182 +++++++++ .../execute_job/decrypt_job_headers.test.ts | 22 +- .../common/execute_job/decrypt_job_headers.ts | 8 +- .../get_conditional_headers.test.ts | 173 ++++++-- .../execute_job/get_conditional_headers.ts | 20 +- .../execute_job/get_custom_logo.test.ts | 14 +- .../common/execute_job/get_custom_logo.ts | 11 +- .../common/execute_job/get_full_urls.test.ts | 80 ++-- .../common/execute_job/get_full_urls.ts | 22 +- .../common/layouts/create_layout.ts | 7 +- .../common/layouts/print_layout.ts | 9 +- .../lib/screenshots/get_number_of_items.ts | 7 +- .../common/lib/screenshots/observable.test.ts | 19 +- .../common/lib/screenshots/observable.ts | 18 +- .../common/lib/screenshots/open_url.ts | 11 +- .../common/lib/screenshots/types.ts | 2 +- .../common/lib/screenshots/wait_for_render.ts | 4 +- .../screenshots/wait_for_visualizations.ts | 7 +- .../export_types/csv/server/create_job.ts | 6 +- .../csv/server/execute_job.test.js | 344 +++++++++++++--- .../export_types/csv/server/execute_job.ts | 30 +- .../csv/server/lib/hit_iterator.test.ts | 3 +- .../csv/server/lib/hit_iterator.ts | 5 +- .../reporting/export_types/csv/types.d.ts | 5 +- .../server/create_job/create_job.ts | 19 +- .../server/execute_job.ts | 23 +- .../server/lib/generate_csv.ts | 16 +- .../server/lib/generate_csv_search.ts | 20 +- .../csv_from_savedobject/types.d.ts | 5 +- .../png/server/create_job/index.ts | 6 +- .../png/server/execute_job/index.test.js | 94 +++-- .../png/server/execute_job/index.ts | 23 +- .../png/server/lib/generate_png.ts | 7 +- .../printable_pdf/server/create_job/index.ts | 6 +- .../server/execute_job/index.test.js | 80 ++-- .../printable_pdf/server/execute_job/index.ts | 25 +- .../printable_pdf/server/lib/generate_pdf.ts | 9 +- .../export_types/printable_pdf/types.d.ts | 2 +- x-pack/legacy/plugins/reporting/index.test.js | 34 ++ x-pack/legacy/plugins/reporting/index.ts | 16 +- .../plugins/reporting/log_configuration.ts | 23 +- .../browsers/chromium/driver_factory/args.ts | 7 +- .../browsers/chromium/driver_factory/index.ts | 19 +- .../server/browsers/chromium/index.ts | 5 +- .../browsers/create_browser_driver_factory.ts | 22 +- .../browsers/download/ensure_downloaded.ts | 13 +- .../server/browsers/network_policy.ts | 9 +- .../reporting/server/browsers/types.d.ts | 2 + .../plugins/reporting/server/config/config.js | 21 + .../legacy/plugins/reporting/server/core.ts | 72 +--- .../legacy/plugins/reporting/server/index.ts | 2 +- .../legacy/plugins/reporting/server/legacy.ts | 73 +--- .../reporting/server/lib/create_queue.ts | 20 +- .../server/lib/create_worker.test.ts | 39 +- .../reporting/server/lib/create_worker.ts | 24 +- .../plugins/reporting/server/lib/crypto.ts | 7 +- .../reporting/server/lib/enqueue_job.ts | 31 +- .../lib/esqueue/helpers/index_timestamp.js | 1 - .../plugins/reporting/server/lib/get_user.ts | 4 +- .../plugins/reporting/server/lib/index.ts | 9 +- .../reporting/server/lib/jobs_query.ts | 10 +- .../reporting/server/lib/once_per_server.ts | 43 ++ .../__tests__/validate_encryption_key.js | 34 ++ .../__tests__/validate_server_host.ts | 30 ++ .../reporting/server/lib/validate/index.ts | 13 +- .../server/lib/validate/validate_browser.ts | 4 +- .../lib/validate/validate_encryption_key.ts | 31 ++ .../validate_max_content_length.test.js | 16 +- .../validate/validate_max_content_length.ts | 14 +- .../lib/validate/validate_server_host.ts | 27 ++ .../legacy/plugins/reporting/server/plugin.ts | 24 +- .../server/routes/generate_from_jobparams.ts | 5 +- .../routes/generate_from_savedobject.ts | 5 +- .../generate_from_savedobject_immediate.ts | 18 +- .../server/routes/generation.test.ts | 11 +- .../reporting/server/routes/generation.ts | 15 +- .../plugins/reporting/server/routes/index.ts | 7 +- .../reporting/server/routes/jobs.test.js | 46 +-- .../plugins/reporting/server/routes/jobs.ts | 15 +- .../lib/authorized_user_pre_routing.test.js | 131 +++--- .../routes/lib/authorized_user_pre_routing.ts | 16 +- .../server/routes/lib/get_document_payload.ts | 31 +- .../server/routes/lib/job_response_handler.ts | 15 +- .../lib/reporting_feature_pre_routing.ts | 8 +- .../routes/lib/route_config_factories.ts | 28 +- .../plugins/reporting/server/types.d.ts | 11 +- .../server/usage/get_reporting_usage.ts | 28 +- .../usage/reporting_usage_collector.test.js | 152 ++++--- .../server/usage/reporting_usage_collector.ts | 23 +- .../create_mock_browserdriverfactory.ts | 45 +- .../create_mock_layoutinstance.ts | 8 +- .../create_mock_reportingplugin.ts | 22 +- .../test_helpers/create_mock_server.ts | 34 +- x-pack/legacy/plugins/reporting/types.d.ts | 62 ++- x-pack/plugins/reporting/config.ts | 10 + x-pack/plugins/reporting/kibana.json | 6 +- .../reporting/server/config/index.test.ts | 122 ------ .../plugins/reporting/server/config/index.ts | 85 ---- .../reporting/server/config/schema.test.ts | 103 ----- .../plugins/reporting/server/config/schema.ts | 174 -------- x-pack/plugins/reporting/server/index.ts | 14 - x-pack/plugins/reporting/server/plugin.ts | 38 -- 103 files changed, 2192 insertions(+), 1522 deletions(-) create mode 100644 x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap create mode 100644 x-pack/legacy/plugins/reporting/config.ts create mode 100644 x-pack/legacy/plugins/reporting/index.test.js create mode 100644 x-pack/legacy/plugins/reporting/server/config/config.js create mode 100644 x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts create mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js create mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts create mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts create mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts create mode 100644 x-pack/plugins/reporting/config.ts delete mode 100644 x-pack/plugins/reporting/server/config/index.test.ts delete mode 100644 x-pack/plugins/reporting/server/config/index.ts delete mode 100644 x-pack/plugins/reporting/server/config/schema.test.ts delete mode 100644 x-pack/plugins/reporting/server/config/schema.ts delete mode 100644 x-pack/plugins/reporting/server/index.ts delete mode 100644 x-pack/plugins/reporting/server/plugin.ts diff --git a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000000..757677f1d4f82 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap @@ -0,0 +1,383 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`config schema with context {"dev":false,"dist":false} produces correct config 1`] = ` +Object { + "capture": Object { + "browser": Object { + "autoDownload": true, + "chromium": Object { + "disableSandbox": "", + "maxScreenshotDimension": 1950, + "proxy": Object { + "enabled": false, + }, + }, + "type": "chromium", + }, + "concurrency": 4, + "loadDelay": 3000, + "maxAttempts": 1, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "protocol": "http:", + }, + Object { + "allow": true, + "protocol": "https:", + }, + Object { + "allow": true, + "protocol": "ws:", + }, + Object { + "allow": true, + "protocol": "wss:", + }, + Object { + "allow": true, + "protocol": "data:", + }, + Object { + "allow": false, + }, + ], + }, + "settleTime": 1000, + "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, + "viewport": Object { + "height": 1200, + "width": 1950, + }, + "zoom": 2, + }, + "csv": Object { + "checkForFormulas": true, + "enablePanelActionDownload": true, + "maxSizeBytes": 10485760, + "scroll": Object { + "duration": "30s", + "size": 500, + }, + }, + "enabled": true, + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "index": ".reporting", + "kibanaServer": Object {}, + "poll": Object { + "jobCompletionNotifier": Object { + "interval": 10000, + "intervalErrorMultiplier": 5, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 5, + }, + }, + "queue": Object { + "indexInterval": "week", + "pollEnabled": true, + "pollInterval": 3000, + "pollIntervalErrorMultiplier": 10, + "timeout": 120000, + }, + "roles": Object { + "allow": Array [ + "reporting_user", + ], + }, +} +`; + +exports[`config schema with context {"dev":false,"dist":true} produces correct config 1`] = ` +Object { + "capture": Object { + "browser": Object { + "autoDownload": false, + "chromium": Object { + "disableSandbox": "", + "maxScreenshotDimension": 1950, + "proxy": Object { + "enabled": false, + }, + }, + "type": "chromium", + }, + "concurrency": 4, + "loadDelay": 3000, + "maxAttempts": 3, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "protocol": "http:", + }, + Object { + "allow": true, + "protocol": "https:", + }, + Object { + "allow": true, + "protocol": "ws:", + }, + Object { + "allow": true, + "protocol": "wss:", + }, + Object { + "allow": true, + "protocol": "data:", + }, + Object { + "allow": false, + }, + ], + }, + "settleTime": 1000, + "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, + "viewport": Object { + "height": 1200, + "width": 1950, + }, + "zoom": 2, + }, + "csv": Object { + "checkForFormulas": true, + "enablePanelActionDownload": true, + "maxSizeBytes": 10485760, + "scroll": Object { + "duration": "30s", + "size": 500, + }, + }, + "enabled": true, + "index": ".reporting", + "kibanaServer": Object {}, + "poll": Object { + "jobCompletionNotifier": Object { + "interval": 10000, + "intervalErrorMultiplier": 5, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 5, + }, + }, + "queue": Object { + "indexInterval": "week", + "pollEnabled": true, + "pollInterval": 3000, + "pollIntervalErrorMultiplier": 10, + "timeout": 120000, + }, + "roles": Object { + "allow": Array [ + "reporting_user", + ], + }, +} +`; + +exports[`config schema with context {"dev":true,"dist":false} produces correct config 1`] = ` +Object { + "capture": Object { + "browser": Object { + "autoDownload": true, + "chromium": Object { + "disableSandbox": "", + "maxScreenshotDimension": 1950, + "proxy": Object { + "enabled": false, + }, + }, + "type": "chromium", + }, + "concurrency": 4, + "loadDelay": 3000, + "maxAttempts": 1, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "protocol": "http:", + }, + Object { + "allow": true, + "protocol": "https:", + }, + Object { + "allow": true, + "protocol": "ws:", + }, + Object { + "allow": true, + "protocol": "wss:", + }, + Object { + "allow": true, + "protocol": "data:", + }, + Object { + "allow": false, + }, + ], + }, + "settleTime": 1000, + "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, + "viewport": Object { + "height": 1200, + "width": 1950, + }, + "zoom": 2, + }, + "csv": Object { + "checkForFormulas": true, + "enablePanelActionDownload": true, + "maxSizeBytes": 10485760, + "scroll": Object { + "duration": "30s", + "size": 500, + }, + }, + "enabled": true, + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "index": ".reporting", + "kibanaServer": Object {}, + "poll": Object { + "jobCompletionNotifier": Object { + "interval": 10000, + "intervalErrorMultiplier": 5, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 5, + }, + }, + "queue": Object { + "indexInterval": "week", + "pollEnabled": true, + "pollInterval": 3000, + "pollIntervalErrorMultiplier": 10, + "timeout": 120000, + }, + "roles": Object { + "allow": Array [ + "reporting_user", + ], + }, +} +`; + +exports[`config schema with context {"dev":true,"dist":true} produces correct config 1`] = ` +Object { + "capture": Object { + "browser": Object { + "autoDownload": false, + "chromium": Object { + "disableSandbox": "", + "maxScreenshotDimension": 1950, + "proxy": Object { + "enabled": false, + }, + }, + "type": "chromium", + }, + "concurrency": 4, + "loadDelay": 3000, + "maxAttempts": 3, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "protocol": "http:", + }, + Object { + "allow": true, + "protocol": "https:", + }, + Object { + "allow": true, + "protocol": "ws:", + }, + Object { + "allow": true, + "protocol": "wss:", + }, + Object { + "allow": true, + "protocol": "data:", + }, + Object { + "allow": false, + }, + ], + }, + "settleTime": 1000, + "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, + "viewport": Object { + "height": 1200, + "width": 1950, + }, + "zoom": 2, + }, + "csv": Object { + "checkForFormulas": true, + "enablePanelActionDownload": true, + "maxSizeBytes": 10485760, + "scroll": Object { + "duration": "30s", + "size": 500, + }, + }, + "enabled": true, + "index": ".reporting", + "kibanaServer": Object {}, + "poll": Object { + "jobCompletionNotifier": Object { + "interval": 10000, + "intervalErrorMultiplier": 5, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 5, + }, + }, + "queue": Object { + "indexInterval": "week", + "pollEnabled": true, + "pollInterval": 3000, + "pollIntervalErrorMultiplier": 10, + "timeout": 120000, + }, + "roles": Object { + "allow": Array [ + "reporting_user", + ], + }, +} +`; diff --git a/x-pack/legacy/plugins/reporting/config.ts b/x-pack/legacy/plugins/reporting/config.ts new file mode 100644 index 0000000000000..211fa70301bbf --- /dev/null +++ b/x-pack/legacy/plugins/reporting/config.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BROWSER_TYPE } from './common/constants'; +// @ts-ignore untyped module +import { config as appConfig } from './server/config/config'; +import { getDefaultChromiumSandboxDisabled } from './server/browsers'; + +export async function config(Joi: any) { + return Joi.object({ + enabled: Joi.boolean().default(true), + kibanaServer: Joi.object({ + protocol: Joi.string().valid(['http', 'https']), + hostname: Joi.string().invalid('0'), + port: Joi.number().integer(), + }).default(), + queue: Joi.object({ + indexInterval: Joi.string().default('week'), + pollEnabled: Joi.boolean().default(true), + pollInterval: Joi.number() + .integer() + .default(3000), + pollIntervalErrorMultiplier: Joi.number() + .integer() + .default(10), + timeout: Joi.number() + .integer() + .default(120000), + }).default(), + capture: Joi.object({ + timeouts: Joi.object({ + openUrl: Joi.number() + .integer() + .default(30000), + waitForElements: Joi.number() + .integer() + .default(30000), + renderComplete: Joi.number() + .integer() + .default(30000), + }).default(), + networkPolicy: Joi.object({ + enabled: Joi.boolean().default(true), + rules: Joi.array() + .items( + Joi.object({ + allow: Joi.boolean().required(), + protocol: Joi.string(), + host: Joi.string(), + }) + ) + .default([ + { allow: true, protocol: 'http:' }, + { allow: true, protocol: 'https:' }, + { allow: true, protocol: 'ws:' }, + { allow: true, protocol: 'wss:' }, + { allow: true, protocol: 'data:' }, + { allow: false }, // Default action is to deny! + ]), + }).default(), + zoom: Joi.number() + .integer() + .default(2), + viewport: Joi.object({ + width: Joi.number() + .integer() + .default(1950), + height: Joi.number() + .integer() + .default(1200), + }).default(), + timeout: Joi.number() + .integer() + .default(20000), // deprecated + loadDelay: Joi.number() + .integer() + .default(3000), + settleTime: Joi.number() + .integer() + .default(1000), // deprecated + concurrency: Joi.number() + .integer() + .default(appConfig.concurrency), // deprecated + browser: Joi.object({ + type: Joi.any() + .valid(BROWSER_TYPE) + .default(BROWSER_TYPE), + autoDownload: Joi.boolean().when('$dist', { + is: true, + then: Joi.default(false), + otherwise: Joi.default(true), + }), + chromium: Joi.object({ + inspect: Joi.boolean() + .when('$dev', { + is: false, + then: Joi.valid(false), + else: Joi.default(false), + }) + .default(), + disableSandbox: Joi.boolean().default(await getDefaultChromiumSandboxDisabled()), + proxy: Joi.object({ + enabled: Joi.boolean().default(false), + server: Joi.string() + .uri({ scheme: ['http', 'https'] }) + .when('enabled', { + is: Joi.valid(false), + then: Joi.valid(null), + else: Joi.required(), + }), + bypass: Joi.array() + .items(Joi.string().regex(/^[^\s]+$/)) + .when('enabled', { + is: Joi.valid(false), + then: Joi.valid(null), + else: Joi.default([]), + }), + }).default(), + maxScreenshotDimension: Joi.number() + .integer() + .default(1950), + }).default(), + }).default(), + maxAttempts: Joi.number() + .integer() + .greater(0) + .when('$dist', { + is: true, + then: Joi.default(3), + otherwise: Joi.default(1), + }) + .default(), + }).default(), + csv: Joi.object({ + checkForFormulas: Joi.boolean().default(true), + enablePanelActionDownload: Joi.boolean().default(true), + maxSizeBytes: Joi.number() + .integer() + .default(1024 * 1024 * 10), // bytes in a kB * kB in a mB * 10 + scroll: Joi.object({ + duration: Joi.string() + .regex(/^[0-9]+(d|h|m|s|ms|micros|nanos)$/, { name: 'DurationString' }) + .default('30s'), + size: Joi.number() + .integer() + .default(500), + }).default(), + }).default(), + encryptionKey: Joi.when(Joi.ref('$dist'), { + is: true, + then: Joi.string(), + otherwise: Joi.string().default('a'.repeat(32)), + }), + roles: Joi.object({ + allow: Joi.array() + .items(Joi.string()) + .default(['reporting_user']), + }).default(), + index: Joi.string().default('.reporting'), + poll: Joi.object({ + jobCompletionNotifier: Joi.object({ + interval: Joi.number() + .integer() + .default(10000), + intervalErrorMultiplier: Joi.number() + .integer() + .default(5), + }).default(), + jobsRefresh: Joi.object({ + interval: Joi.number() + .integer() + .default(5000), + intervalErrorMultiplier: Joi.number() + .integer() + .default(5), + }).default(), + }).default(), + }).default(); +} diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts index 9085fb3cbc876..468caf93ec5dd 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts @@ -5,27 +5,33 @@ */ import { cryptoFactory } from '../../../server/lib/crypto'; +import { createMockServer } from '../../../test_helpers'; import { Logger } from '../../../types'; import { decryptJobHeaders } from './decrypt_job_headers'; -const encryptHeaders = async (encryptionKey: string, headers: Record) => { - const crypto = cryptoFactory(encryptionKey); +let mockServer: any; +beforeEach(() => { + mockServer = createMockServer(''); +}); + +const encryptHeaders = async (headers: Record) => { + const crypto = cryptoFactory(mockServer); return await crypto.encrypt(headers); }; describe('headers', () => { test(`fails if it can't decrypt headers`, async () => { - const getDecryptedHeaders = () => + await expect( decryptJobHeaders({ - encryptionKey: 'abcsecretsauce', job: { headers: 'Q53+9A+zf+Xe+ceR/uB/aR/Sw/8e+M+qR+WiG+8z+EY+mo+HiU/zQL+Xn', }, logger: ({ error: jest.fn(), } as unknown) as Logger, - }); - await expect(getDecryptedHeaders()).rejects.toMatchInlineSnapshot( + server: mockServer, + }) + ).rejects.toMatchInlineSnapshot( `[Error: Failed to decrypt report job data. Please ensure that xpack.reporting.encryptionKey is set and re-generate this report. Error: Invalid IV length]` ); }); @@ -36,15 +42,15 @@ describe('headers', () => { baz: 'quix', }; - const encryptedHeaders = await encryptHeaders('abcsecretsauce', headers); + const encryptedHeaders = await encryptHeaders(headers); const decryptedHeaders = await decryptJobHeaders({ - encryptionKey: 'abcsecretsauce', job: { title: 'cool-job-bro', type: 'csv', headers: encryptedHeaders, }, logger: {} as Logger, + server: mockServer, }); expect(decryptedHeaders).toEqual(headers); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts index 6f415d7ee5ea9..436b2c2dab1ad 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { cryptoFactory } from '../../../server/lib/crypto'; -import { CryptoFactory, Logger } from '../../../types'; +import { CryptoFactory, ServerFacade, Logger } from '../../../types'; interface HasEncryptedHeaders { headers?: string; @@ -17,15 +17,15 @@ export const decryptJobHeaders = async < JobParamsType, JobDocPayloadType extends HasEncryptedHeaders >({ - encryptionKey, + server, job, logger, }: { - encryptionKey?: string; + server: ServerFacade; job: JobDocPayloadType; logger: Logger; }): Promise> => { - const crypto: CryptoFactory = cryptoFactory(encryptionKey); + const crypto: CryptoFactory = cryptoFactory(server); try { const decryptedHeaders: Record = await crypto.decrypt(job.headers); return decryptedHeaders; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts index 09527621fa49f..eedb742ad7597 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts @@ -4,33 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import sinon from 'sinon'; -import { createMockReportingCore } from '../../../test_helpers'; -import { ReportingConfig, ReportingCore } from '../../../server/types'; +import { createMockReportingCore, createMockServer } from '../../../test_helpers'; +import { ReportingCore } from '../../../server'; import { JobDocPayload } from '../../../types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getConditionalHeaders, getCustomLogo } from './index'; -let mockConfig: ReportingConfig; let mockReportingPlugin: ReportingCore; - -const getMockConfig = (mockConfigGet: sinon.SinonStub) => ({ - get: mockConfigGet, - kbnConfig: { get: mockConfigGet }, -}); - +let mockServer: any; beforeEach(async () => { mockReportingPlugin = await createMockReportingCore(); - - const mockConfigGet = sinon - .stub() - .withArgs('kibanaServer', 'hostname') - .returns('custom-hostname'); - mockConfig = getMockConfig(mockConfigGet); + mockServer = createMockServer(''); }); describe('conditions', () => { test(`uses hostname from reporting config if set`, async () => { + const settings: any = { + 'xpack.reporting.kibanaServer.hostname': 'custom-hostname', + }; + + mockServer = createMockServer({ settings }); + const permittedHeaders = { foo: 'bar', baz: 'quix', @@ -39,20 +33,121 @@ describe('conditions', () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - config: mockConfig, + server: mockServer, }); expect(conditionalHeaders.conditions.hostname).toEqual( - mockConfig.get('kibanaServer', 'hostname') + mockServer.config().get('xpack.reporting.kibanaServer.hostname') ); - expect(conditionalHeaders.conditions.port).toEqual(mockConfig.get('kibanaServer', 'port')); - expect(conditionalHeaders.conditions.protocol).toEqual( - mockConfig.get('kibanaServer', 'protocol') + }); + + test(`uses hostname from server.config if reporting config not set`, async () => { + const permittedHeaders = { + foo: 'bar', + baz: 'quix', + }; + + const conditionalHeaders = await getConditionalHeaders({ + job: {} as JobDocPayload, + filteredHeaders: permittedHeaders, + server: mockServer, + }); + + expect(conditionalHeaders.conditions.hostname).toEqual(mockServer.config().get('server.host')); + }); + + test(`uses port from reporting config if set`, async () => { + const settings = { + 'xpack.reporting.kibanaServer.port': 443, + }; + + mockServer = createMockServer({ settings }); + + const permittedHeaders = { + foo: 'bar', + baz: 'quix', + }; + + const conditionalHeaders = await getConditionalHeaders({ + job: {} as JobDocPayload, + filteredHeaders: permittedHeaders, + server: mockServer, + }); + + expect(conditionalHeaders.conditions.port).toEqual( + mockServer.config().get('xpack.reporting.kibanaServer.port') ); + }); + + test(`uses port from server if reporting config not set`, async () => { + const permittedHeaders = { + foo: 'bar', + baz: 'quix', + }; + + const conditionalHeaders = await getConditionalHeaders({ + job: {} as JobDocPayload, + filteredHeaders: permittedHeaders, + server: mockServer, + }); + + expect(conditionalHeaders.conditions.port).toEqual(mockServer.config().get('server.port')); + }); + + test(`uses basePath from server config`, async () => { + const permittedHeaders = { + foo: 'bar', + baz: 'quix', + }; + + const conditionalHeaders = await getConditionalHeaders({ + job: {} as JobDocPayload, + filteredHeaders: permittedHeaders, + server: mockServer, + }); + expect(conditionalHeaders.conditions.basePath).toEqual( - mockConfig.kbnConfig.get('server', 'basePath') + mockServer.config().get('server.basePath') ); }); + + test(`uses protocol from reporting config if set`, async () => { + const settings = { + 'xpack.reporting.kibanaServer.protocol': 'https', + }; + + mockServer = createMockServer({ settings }); + + const permittedHeaders = { + foo: 'bar', + baz: 'quix', + }; + + const conditionalHeaders = await getConditionalHeaders({ + job: {} as JobDocPayload, + filteredHeaders: permittedHeaders, + server: mockServer, + }); + + expect(conditionalHeaders.conditions.protocol).toEqual( + mockServer.config().get('xpack.reporting.kibanaServer.protocol') + ); + }); + + test(`uses protocol from server.info`, async () => { + const permittedHeaders = { + foo: 'bar', + baz: 'quix', + }; + + const conditionalHeaders = await getConditionalHeaders({ + job: {} as JobDocPayload, + filteredHeaders: permittedHeaders, + server: mockServer, + }); + + expect(conditionalHeaders.conditions.protocol).toEqual(mockServer.info.protocol); + }); }); test('uses basePath from job when creating saved object service', async () => { @@ -66,14 +161,14 @@ test('uses basePath from job when creating saved object service', async () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - config: mockConfig, + server: mockServer, }); const jobBasePath = '/sbp/s/marketing'; await getCustomLogo({ reporting: mockReportingPlugin, job: { basePath: jobBasePath } as JobDocPayloadPDF, conditionalHeaders, - config: mockConfig, + server: mockServer, }); const getBasePath = mockGetSavedObjectsClient.mock.calls[0][0].getBasePath; @@ -84,11 +179,6 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav const mockGetSavedObjectsClient = jest.fn(); mockReportingPlugin.getSavedObjectsClient = mockGetSavedObjectsClient; - const mockConfigGet = sinon.stub(); - mockConfigGet.withArgs('kibanaServer', 'hostname').returns('localhost'); - mockConfigGet.withArgs('server', 'basePath').returns('/sbp'); - mockConfig = getMockConfig(mockConfigGet); - const permittedHeaders = { foo: 'bar', baz: 'quix', @@ -96,14 +186,14 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - config: mockConfig, + server: mockServer, }); await getCustomLogo({ reporting: mockReportingPlugin, job: {} as JobDocPayloadPDF, conditionalHeaders, - config: mockConfig, + server: mockServer, }); const getBasePath = mockGetSavedObjectsClient.mock.calls[0][0].getBasePath; @@ -135,26 +225,19 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav describe('config formatting', () => { test(`lowercases server.host`, async () => { - const mockConfigGet = sinon - .stub() - .withArgs('server', 'host') - .returns('COOL-HOSTNAME'); - mockConfig = getMockConfig(mockConfigGet); - + mockServer = createMockServer({ settings: { 'server.host': 'COOL-HOSTNAME' } }); const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: {}, - config: mockConfig, + server: mockServer, }); expect(conditionalHeaders.conditions.hostname).toEqual('cool-hostname'); }); - test(`lowercases kibanaServer.hostname`, async () => { - const mockConfigGet = sinon - .stub() - .withArgs('kibanaServer', 'hostname') - .returns('GREAT-HOSTNAME'); - mockConfig = getMockConfig(mockConfigGet); + test(`lowercases xpack.reporting.kibanaServer.hostname`, async () => { + mockServer = createMockServer({ + settings: { 'xpack.reporting.kibanaServer.hostname': 'GREAT-HOSTNAME' }, + }); const conditionalHeaders = await getConditionalHeaders({ job: { title: 'cool-job-bro', @@ -166,7 +249,7 @@ describe('config formatting', () => { }, }, filteredHeaders: {}, - config: mockConfig, + server: mockServer, }); expect(conditionalHeaders.conditions.hostname).toEqual('great-hostname'); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts index bd7999d697ca9..975060a8052f0 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts @@ -3,31 +3,29 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { ReportingConfig } from '../../../server/types'; -import { ConditionalHeaders } from '../../../types'; +import { ConditionalHeaders, ServerFacade } from '../../../types'; export const getConditionalHeaders = ({ - config, + server, job, filteredHeaders, }: { - config: ReportingConfig; + server: ServerFacade; job: JobDocPayloadType; filteredHeaders: Record; }) => { - const { kbnConfig } = config; + const config = server.config(); const [hostname, port, basePath, protocol] = [ - config.get('kibanaServer', 'hostname'), - config.get('kibanaServer', 'port'), - kbnConfig.get('server', 'basePath'), - config.get('kibanaServer', 'protocol'), + config.get('xpack.reporting.kibanaServer.hostname') || config.get('server.host'), + config.get('xpack.reporting.kibanaServer.port') || config.get('server.port'), + config.get('server.basePath'), + config.get('xpack.reporting.kibanaServer.protocol') || server.info.protocol, ] as [string, number, string, string]; const conditionalHeaders: ConditionalHeaders = { headers: filteredHeaders, conditions: { - hostname: hostname ? hostname.toLowerCase() : hostname, + hostname: hostname.toLowerCase(), port, basePath, protocol, diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts index 7c4c889e3e14f..fa53f474dfba7 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts @@ -5,18 +5,16 @@ */ import { ReportingCore } from '../../../server'; -import { createMockReportingCore } from '../../../test_helpers'; +import { createMockReportingCore, createMockServer } from '../../../test_helpers'; +import { ServerFacade } from '../../../types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getConditionalHeaders, getCustomLogo } from './index'; -const mockConfigGet = jest.fn().mockImplementation((key: string) => { - return 'localhost'; -}); -const mockConfig = { get: mockConfigGet, kbnConfig: { get: mockConfigGet } }; - let mockReportingPlugin: ReportingCore; +let mockServer: ServerFacade; beforeEach(async () => { mockReportingPlugin = await createMockReportingCore(); + mockServer = createMockServer(''); }); test(`gets logo from uiSettings`, async () => { @@ -39,14 +37,14 @@ test(`gets logo from uiSettings`, async () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayloadPDF, filteredHeaders: permittedHeaders, - config: mockConfig, + server: mockServer, }); const { logo } = await getCustomLogo({ reporting: mockReportingPlugin, - config: mockConfig, job: {} as JobDocPayloadPDF, conditionalHeaders, + server: mockServer, }); expect(mockGet).toBeCalledWith('xpackReporting:customPdfLogo'); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts index a13f992e7867c..7af5edab41ab7 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts @@ -5,22 +5,23 @@ */ import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../common/constants'; -import { ReportingConfig, ReportingCore } from '../../../server/types'; -import { ConditionalHeaders } from '../../../types'; +import { ReportingCore } from '../../../server'; +import { ConditionalHeaders, ServerFacade } from '../../../types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; // Logo is PDF only export const getCustomLogo = async ({ reporting, - config, + server, job, conditionalHeaders, }: { reporting: ReportingCore; - config: ReportingConfig; + server: ServerFacade; job: JobDocPayloadPDF; conditionalHeaders: ConditionalHeaders; }) => { - const serverBasePath: string = config.kbnConfig.get('server', 'basePath'); + const serverBasePath: string = server.config().get('server.basePath'); + const fakeRequest: any = { headers: conditionalHeaders.headers, // This is used by the spaces SavedObjectClientWrapper to determine the existing space. diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts index 5f55617724ff6..27e772195f726 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts @@ -4,41 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ReportingConfig } from '../../../server'; +import { createMockServer } from '../../../test_helpers'; +import { ServerFacade } from '../../../types'; import { JobDocPayloadPNG } from '../../png/types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getFullUrls } from './get_full_urls'; interface FullUrlsOpts { job: JobDocPayloadPNG & JobDocPayloadPDF; - config: ReportingConfig; + server: ServerFacade; + conditionalHeaders: any; } -let mockConfig: ReportingConfig; -const getMockConfig = (mockConfigGet: jest.Mock) => { - return { - get: mockConfigGet, - kbnConfig: { get: mockConfigGet }, - }; -}; - +let mockServer: any; beforeEach(() => { - const reportingConfig: Record = { - 'kibanaServer.hostname': 'localhost', - 'kibanaServer.port': 5601, - 'kibanaServer.protocol': 'http', - 'server.basePath': '/sbp', - }; - const mockConfigGet = jest.fn().mockImplementation((...keys: string[]) => { - return reportingConfig[keys.join('.') as string]; - }); - mockConfig = getMockConfig(mockConfigGet); + mockServer = createMockServer(''); }); -const getMockJob = (base: object) => base as JobDocPayloadPNG & JobDocPayloadPDF; - test(`fails if no URL is passed`, async () => { - const fn = () => getFullUrls({ job: getMockJob({}), config: mockConfig } as FullUrlsOpts); + const fn = () => + getFullUrls({ + job: {}, + server: mockServer, + } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid URL fields found in Job Params! Expected \`job.relativeUrl: string\` or \`job.relativeUrls: string[]\`"` ); @@ -49,8 +37,8 @@ test(`fails if URLs are file-protocols for PNGs`, async () => { const relativeUrl = 'file://etc/passwd/#/something'; const fn = () => getFullUrls({ - job: getMockJob({ relativeUrl, forceNow }), - config: mockConfig, + job: { relativeUrl, forceNow }, + server: mockServer, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` @@ -63,8 +51,8 @@ test(`fails if URLs are absolute for PNGs`, async () => { 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; const fn = () => getFullUrls({ - job: getMockJob({ relativeUrl, forceNow }), - config: mockConfig, + job: { relativeUrl, forceNow }, + server: mockServer, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` @@ -76,11 +64,11 @@ test(`fails if URLs are file-protocols for PDF`, async () => { const relativeUrl = 'file://etc/passwd/#/something'; const fn = () => getFullUrls({ - job: getMockJob({ + job: { relativeUrls: [relativeUrl], forceNow, - }), - config: mockConfig, + }, + server: mockServer, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` @@ -93,11 +81,11 @@ test(`fails if URLs are absolute for PDF`, async () => { 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; const fn = () => getFullUrls({ - job: getMockJob({ + job: { relativeUrls: [relativeUrl], forceNow, - }), - config: mockConfig, + }, + server: mockServer, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` @@ -114,8 +102,8 @@ test(`fails if any URLs are absolute or file's for PDF`, async () => { const fn = () => getFullUrls({ - job: getMockJob({ relativeUrls, forceNow }), - config: mockConfig, + job: { relativeUrls, forceNow }, + server: mockServer, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something file://etc/passwd/#/something"` @@ -125,8 +113,8 @@ test(`fails if any URLs are absolute or file's for PDF`, async () => { test(`fails if URL does not route to a visualization`, async () => { const fn = () => getFullUrls({ - job: getMockJob({ relativeUrl: '/app/phoney' }), - config: mockConfig, + job: { relativeUrl: '/app/phoney' }, + server: mockServer, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid hash in the URL! A hash is expected for the application to route to the intended visualization."` @@ -136,8 +124,8 @@ test(`fails if URL does not route to a visualization`, async () => { test(`adds forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: getMockJob({ relativeUrl: '/app/kibana#/something', forceNow }), - config: mockConfig, + job: { relativeUrl: '/app/kibana#/something', forceNow }, + server: mockServer, } as FullUrlsOpts); expect(urls[0]).toEqual( @@ -149,8 +137,8 @@ test(`appends forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: getMockJob({ relativeUrl: '/app/kibana#/something?_g=something', forceNow }), - config: mockConfig, + job: { relativeUrl: '/app/kibana#/something?_g=something', forceNow }, + server: mockServer, } as FullUrlsOpts); expect(urls[0]).toEqual( @@ -160,8 +148,8 @@ test(`appends forceNow to hash's query, if it exists`, async () => { test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { const urls = await getFullUrls({ - job: getMockJob({ relativeUrl: '/app/kibana#/something' }), - config: mockConfig, + job: { relativeUrl: '/app/kibana#/something' }, + server: mockServer, } as FullUrlsOpts); expect(urls[0]).toEqual('http://localhost:5601/sbp/app/kibana#/something'); @@ -170,7 +158,7 @@ test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { test(`adds forceNow to each of multiple urls`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: getMockJob({ + job: { relativeUrls: [ '/app/kibana#/something_aaa', '/app/kibana#/something_bbb', @@ -178,8 +166,8 @@ test(`adds forceNow to each of multiple urls`, async () => { '/app/kibana#/something_ddd', ], forceNow, - }), - config: mockConfig, + }, + server: mockServer, } as FullUrlsOpts); expect(urls).toEqual([ diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts index c4b6f31019fdf..ca64d8632dbfe 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts @@ -12,7 +12,7 @@ import { } from 'url'; import { getAbsoluteUrlFactory } from '../../../common/get_absolute_url'; import { validateUrls } from '../../../common/validate_urls'; -import { ReportingConfig } from '../../../server/types'; +import { ServerFacade } from '../../../types'; import { JobDocPayloadPNG } from '../../png/types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; @@ -24,23 +24,19 @@ function isPdfJob(job: JobDocPayloadPNG | JobDocPayloadPDF): job is JobDocPayloa } export function getFullUrls({ - config, + server, job, }: { - config: ReportingConfig; + server: ServerFacade; job: JobDocPayloadPDF | JobDocPayloadPNG; }) { - const [basePath, protocol, hostname, port] = [ - config.kbnConfig.get('server', 'basePath'), - config.get('kibanaServer', 'protocol'), - config.get('kibanaServer', 'hostname'), - config.get('kibanaServer', 'port'), - ] as string[]; + const config = server.config(); + const getAbsoluteUrl = getAbsoluteUrlFactory({ - defaultBasePath: basePath, - protocol, - hostname, - port, + defaultBasePath: config.get('server.basePath'), + protocol: config.get('xpack.reporting.kibanaServer.protocol') || server.info.protocol, + hostname: config.get('xpack.reporting.kibanaServer.hostname') || config.get('server.host'), + port: config.get('xpack.reporting.kibanaServer.port') || config.get('server.port'), }); // PDF and PNG job params put in the url differently diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts index 07fceb603e451..0cb83352d4606 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts @@ -3,18 +3,17 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { CaptureConfig } from '../../../server/types'; +import { ServerFacade } from '../../../types'; import { LayoutTypes } from '../constants'; import { Layout, LayoutParams } from './layout'; import { PreserveLayout } from './preserve_layout'; import { PrintLayout } from './print_layout'; -export function createLayout(captureConfig: CaptureConfig, layoutParams?: LayoutParams): Layout { +export function createLayout(server: ServerFacade, layoutParams?: LayoutParams): Layout { if (layoutParams && layoutParams.id === LayoutTypes.PRESERVE_LAYOUT) { return new PreserveLayout(layoutParams.dimensions); } // this is the default because some jobs won't have anything specified - return new PrintLayout(captureConfig); + return new PrintLayout(server); } diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts index 98d8dc2983653..6007c2960057a 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts @@ -3,12 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import path from 'path'; import { EvaluateFn, SerializableOrJSHandle } from 'puppeteer'; -import { HeadlessChromiumDriver } from '../../../server/browsers'; import { LevelLogger } from '../../../server/lib'; -import { ReportingConfigType } from '../../../server/core'; +import { HeadlessChromiumDriver } from '../../../server/browsers'; +import { ServerFacade } from '../../../types'; import { LayoutTypes } from '../constants'; import { getDefaultLayoutSelectors, Layout, LayoutSelectorDictionary, Size } from './layout'; import { CaptureConfig } from './types'; @@ -21,9 +20,9 @@ export class PrintLayout extends Layout { public readonly groupCount = 2; private captureConfig: CaptureConfig; - constructor(captureConfig: ReportingConfigType['capture']) { + constructor(server: ServerFacade) { super(LayoutTypes.PRINT); - this.captureConfig = captureConfig; + this.captureConfig = server.config().get('xpack.reporting.capture'); } public getCssOverridesPath() { diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts index 57d025890d3e2..16eb433e8a75e 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts @@ -7,16 +7,17 @@ import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; -import { CaptureConfig } from '../../../../server/types'; +import { ServerFacade } from '../../../../types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; export const getNumberOfItems = async ( - captureConfig: CaptureConfig, + server: ServerFacade, browser: HeadlessBrowser, layout: LayoutInstance, logger: LevelLogger ): Promise => { + const config = server.config(); const { renderComplete: renderCompleteSelector, itemsCountAttribute } = layout.selectors; let itemsCount: number; @@ -32,7 +33,7 @@ export const getNumberOfItems = async ( // we have to use this hint to wait for all of them await browser.waitForSelector( `${renderCompleteSelector},[${itemsCountAttribute}]`, - { timeout: captureConfig.timeouts.waitForElements }, + { timeout: config.get('xpack.reporting.capture.timeouts.waitForElements') }, { context: CONTEXT_READMETADATA }, logger ); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts index 75ac3dca4ffa0..13d07bcdd6baf 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts @@ -19,9 +19,12 @@ import * as Rx from 'rxjs'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { loggingServiceMock } from '../../../../../../../../src/core/server/mocks'; import { LevelLogger } from '../../../../server/lib'; -import { createMockBrowserDriverFactory, createMockLayoutInstance } from '../../../../test_helpers'; +import { + createMockBrowserDriverFactory, + createMockLayoutInstance, + createMockServer, +} from '../../../../test_helpers'; import { ConditionalHeaders, HeadlessChromiumDriver } from '../../../../types'; -import { CaptureConfig } from '../../../../server/types'; import { screenshotsObservableFactory } from './observable'; import { ElementsPositionAndAttribute } from './types'; @@ -31,8 +34,8 @@ import { ElementsPositionAndAttribute } from './types'; const mockLogger = jest.fn(loggingServiceMock.create); const logger = new LevelLogger(mockLogger()); -const mockConfig = { timeouts: { openUrl: 13 } } as CaptureConfig; -const mockLayout = createMockLayoutInstance(mockConfig); +const __LEGACY = createMockServer({ settings: { 'xpack.reporting.capture': { loadDelay: 13 } } }); +const mockLayout = createMockLayoutInstance(__LEGACY); /* * Tests @@ -45,7 +48,7 @@ describe('Screenshot Observable Pipeline', () => { }); it('pipelines a single url into screenshot and timeRange', async () => { - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index.htm'], @@ -83,7 +86,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index2.htm', '/welcome/home/start/index.php3?page=./home.php'], @@ -133,7 +136,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, @@ -194,7 +197,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts index 53a11c18abd79..44c04c763f840 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts @@ -6,22 +6,24 @@ import * as Rx from 'rxjs'; import { catchError, concatMap, first, mergeMap, take, takeUntil, toArray } from 'rxjs/operators'; -import { CaptureConfig } from '../../../../server/types'; -import { HeadlessChromiumDriverFactory } from '../../../../types'; +import { CaptureConfig, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; import { getElementPositionAndAttributes } from './get_element_position_data'; import { getNumberOfItems } from './get_number_of_items'; import { getScreenshots } from './get_screenshots'; import { getTimeRange } from './get_time_range'; -import { injectCustomCss } from './inject_css'; import { openUrl } from './open_url'; import { ScreenSetupData, ScreenshotObservableOpts, ScreenshotResults } from './types'; import { waitForRenderComplete } from './wait_for_render'; import { waitForVisualizations } from './wait_for_visualizations'; +import { injectCustomCss } from './inject_css'; export function screenshotsObservableFactory( - captureConfig: CaptureConfig, + server: ServerFacade, browserDriverFactory: HeadlessChromiumDriverFactory ) { + const config = server.config(); + const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); + return function screenshotsObservable({ logger, urls, @@ -39,13 +41,13 @@ export function screenshotsObservableFactory( mergeMap(({ driver, exit$ }) => { const setup$: Rx.Observable = Rx.of(1).pipe( takeUntil(exit$), - mergeMap(() => openUrl(captureConfig, driver, url, conditionalHeaders, logger)), - mergeMap(() => getNumberOfItems(captureConfig, driver, layout, logger)), + mergeMap(() => openUrl(server, driver, url, conditionalHeaders, logger)), + mergeMap(() => getNumberOfItems(server, driver, layout, logger)), mergeMap(async itemsCount => { const viewport = layout.getViewport(itemsCount); await Promise.all([ driver.setViewport(viewport, logger), - waitForVisualizations(captureConfig, driver, itemsCount, layout, logger), + waitForVisualizations(server, driver, itemsCount, layout, logger), ]); }), mergeMap(async () => { @@ -58,7 +60,7 @@ export function screenshotsObservableFactory( await layout.positionElements(driver, logger); } - await waitForRenderComplete(captureConfig, driver, layout, logger); + await waitForRenderComplete(driver, layout, captureConfig, logger); }), mergeMap(async () => { return await Promise.all([ diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts index a484dfb243563..fbae1f91a7a6a 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts @@ -5,26 +5,27 @@ */ import { i18n } from '@kbn/i18n'; -import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; +import { ConditionalHeaders, ServerFacade } from '../../../../types'; import { LevelLogger } from '../../../../server/lib'; -import { CaptureConfig } from '../../../../server/types'; -import { ConditionalHeaders } from '../../../../types'; +import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { PAGELOAD_SELECTOR } from '../../constants'; export const openUrl = async ( - captureConfig: CaptureConfig, + server: ServerFacade, browser: HeadlessBrowser, url: string, conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise => { + const config = server.config(); + try { await browser.open( url, { conditionalHeaders, waitForSelector: PAGELOAD_SELECTOR, - timeout: captureConfig.timeouts.openUrl, + timeout: config.get('xpack.reporting.capture.timeouts.openUrl'), }, logger ); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts index 76613c2d631d6..ab81a952f345c 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ElementPosition, ConditionalHeaders } from '../../../../types'; import { LevelLogger } from '../../../../server/lib'; -import { ConditionalHeaders, ElementPosition } from '../../../../types'; import { LayoutInstance } from '../../layouts/layout'; export interface ScreenshotObservableOpts { diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts index 069896c8d9e90..2f6dc2829dfd8 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts @@ -5,16 +5,16 @@ */ import { i18n } from '@kbn/i18n'; +import { CaptureConfig } from '../../../../types'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; -import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORRENDER } from './constants'; export const waitForRenderComplete = async ( - captureConfig: CaptureConfig, browser: HeadlessBrowser, layout: LayoutInstance, + captureConfig: CaptureConfig, logger: LevelLogger ) => { logger.debug( diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts index 7960e1552e559..93ad40026dff8 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts @@ -5,9 +5,9 @@ */ import { i18n } from '@kbn/i18n'; +import { ServerFacade } from '../../../../types'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; -import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; @@ -23,12 +23,13 @@ const getCompletedItemsCount = ({ renderCompleteSelector }: SelectorArgs) => { * 3. Wait for the render complete event to be fired once for each item */ export const waitForVisualizations = async ( - captureConfig: CaptureConfig, + server: ServerFacade, browser: HeadlessBrowser, itemsCount: number, layout: LayoutInstance, logger: LevelLogger ): Promise => { + const config = server.config(); const { renderComplete: renderCompleteSelector } = layout.selectors; logger.debug( @@ -44,7 +45,7 @@ export const waitForVisualizations = async ( fn: getCompletedItemsCount, args: [{ renderCompleteSelector }], toEqual: itemsCount, - timeout: captureConfig.timeouts.renderComplete, + timeout: config.get('xpack.reporting.capture.timeouts.renderComplete'), }, { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, logger diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts index b87403ac74f89..7ea67277015ab 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts @@ -11,14 +11,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, + ServerFacade, } from '../../../types'; import { JobParamsDiscoverCsv } from '../types'; export const createJobFactory: CreateJobFactory> = async function createJobFactoryFn(reporting: ReportingCore) { - const config = await reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); +>> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { + const crypto = cryptoFactory(server); return async function createJob( jobParams: JobParamsDiscoverCsv, diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js index 7dfa705901fbe..f12916b734dbf 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js @@ -36,12 +36,11 @@ describe('CSV Execute Job', function() { let defaultElasticsearchResponse; let encryptedHeaders; - let clusterStub; - let configGetStub; - let mockReportingConfig; + let cancellationToken; let mockReportingPlugin; + let mockServer; + let clusterStub; let callAsCurrentUserStub; - let cancellationToken; const mockElasticsearch = { dataClient: { @@ -59,17 +58,7 @@ describe('CSV Execute Job', function() { beforeEach(async function() { mockReportingPlugin = await createMockReportingCore(); - - configGetStub = sinon.stub(); - configGetStub.withArgs('encryptionKey').returns(encryptionKey); - configGetStub.withArgs('csv', 'maxSizeBytes').returns(1024 * 1000); // 1mB - configGetStub.withArgs('csv', 'scroll').returns({}); - mockReportingConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; - - mockReportingPlugin.getConfig = () => Promise.resolve(mockReportingConfig); - mockReportingPlugin.getUiSettingsServiceFactory = () => Promise.resolve(mockUiSettingsClient); - mockReportingPlugin.getElasticsearchService = () => Promise.resolve(mockElasticsearch); - + mockReportingPlugin.getUiSettingsServiceFactory = () => mockUiSettingsClient; cancellationToken = new CancellationToken(); defaultElasticsearchResponse = { @@ -86,6 +75,7 @@ describe('CSV Execute Job', function() { .stub(clusterStub, 'callAsCurrentUser') .resolves(defaultElasticsearchResponse); + const configGetStub = sinon.stub(); mockUiSettingsClient.get.withArgs('csv:separator').returns(','); mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); @@ -103,11 +93,36 @@ describe('CSV Execute Job', function() { return fieldFormatsRegistry; }, }); + + mockServer = { + config: function() { + return { + get: configGetStub, + }; + }, + }; + mockServer + .config() + .get.withArgs('xpack.reporting.encryptionKey') + .returns(encryptionKey); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.maxSizeBytes') + .returns(1024 * 1000); // 1mB + mockServer + .config() + .get.withArgs('xpack.reporting.csv.scroll') + .returns({}); }); describe('basic Elasticsearch call behavior', function() { it('should decrypt encrypted headers and pass to callAsCurrentUser', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -123,7 +138,12 @@ describe('CSV Execute Job', function() { testBody: true, }; - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const job = { headers: encryptedHeaders, fields: [], @@ -150,7 +170,12 @@ describe('CSV Execute Job', function() { _scroll_id: scrollId, }); callAsCurrentUserStub.onSecondCall().resolves(defaultElasticsearchResponse); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -164,7 +189,12 @@ describe('CSV Execute Job', function() { }); it('should not execute scroll if there are no hits from the search', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -194,7 +224,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -229,7 +264,12 @@ describe('CSV Execute Job', function() { _scroll_id: lastScrollId, }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -257,7 +297,12 @@ describe('CSV Execute Job', function() { _scroll_id: lastScrollId, }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -276,7 +321,10 @@ describe('CSV Execute Job', function() { describe('Cells with formula values', () => { it('returns `csv_contains_formulas` when cells contain formulas', async function() { - configGetStub.withArgs('csv', 'checkForFormulas').returns(true); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.checkForFormulas') + .returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], @@ -284,7 +332,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -301,7 +354,10 @@ describe('CSV Execute Job', function() { }); it('returns warnings when headings contain formulas', async function() { - configGetStub.withArgs('csv', 'checkForFormulas').returns(true); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.checkForFormulas') + .returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { '=SUM(A1:A2)': 'foo', two: 'bar' } }], @@ -309,7 +365,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['=SUM(A1:A2)', 'two'], @@ -326,7 +387,10 @@ describe('CSV Execute Job', function() { }); it('returns no warnings when cells have no formulas', async function() { - configGetStub.withArgs('csv', 'checkForFormulas').returns(true); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.checkForFormulas') + .returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -334,7 +398,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -351,7 +420,10 @@ describe('CSV Execute Job', function() { }); it('returns no warnings when configured not to', async () => { - configGetStub.withArgs('csv', 'checkForFormulas').returns(false); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.checkForFormulas') + .returns(false); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], @@ -359,7 +431,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -379,7 +456,12 @@ describe('CSV Execute Job', function() { describe('Elasticsearch call errors', function() { it('should reject Promise if search call errors out', async function() { callAsCurrentUserStub.rejects(new Error()); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: [], @@ -398,7 +480,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); callAsCurrentUserStub.onSecondCall().rejects(new Error()); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: [], @@ -419,7 +506,12 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: [], @@ -440,7 +532,12 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: [], @@ -468,7 +565,12 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: [], @@ -496,7 +598,12 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: [], @@ -532,7 +639,12 @@ describe('CSV Execute Job', function() { }); it('should stop calling Elasticsearch when cancellationToken.cancel is called', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -547,7 +659,12 @@ describe('CSV Execute Job', function() { }); it(`shouldn't call clearScroll if it never got a scrollId`, async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -561,7 +678,12 @@ describe('CSV Execute Job', function() { }); it('should call clearScroll if it got a scrollId', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -579,7 +701,12 @@ describe('CSV Execute Job', function() { describe('csv content', function() { it('should write column headers to output, even if there are no results', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -591,7 +718,12 @@ describe('CSV Execute Job', function() { it('should use custom uiSettings csv:separator for header', async function() { mockUiSettingsClient.get.withArgs('csv:separator').returns(';'); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -603,7 +735,12 @@ describe('CSV Execute Job', function() { it('should escape column headers if uiSettings csv:quoteValues is true', async function() { mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one and a half', 'two', 'three-and-four', 'five & six'], @@ -615,7 +752,12 @@ describe('CSV Execute Job', function() { it(`shouldn't escape column headers if uiSettings csv:quoteValues is false`, async function() { mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(false); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one and a half', 'two', 'three-and-four', 'five & six'], @@ -626,7 +768,12 @@ describe('CSV Execute Job', function() { }); it('should write column headers to output, when there are results', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ one: '1', two: '2' }], @@ -646,7 +793,12 @@ describe('CSV Execute Job', function() { }); it('should use comma separated values of non-nested fields from _source', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -667,7 +819,12 @@ describe('CSV Execute Job', function() { }); it('should concatenate the hits from multiple responses', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -695,7 +852,12 @@ describe('CSV Execute Job', function() { }); it('should use field formatters to format fields', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -735,9 +897,17 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - configGetStub.withArgs('csv', 'maxSizeBytes').returns(1); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.maxSizeBytes') + .returns(1); + + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -765,9 +935,17 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.maxSizeBytes') + .returns(9); + + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -795,7 +973,10 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.maxSizeBytes') + .returns(9); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -804,7 +985,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -834,7 +1020,10 @@ describe('CSV Execute Job', function() { beforeEach(async function() { mockReportingPlugin.getUiSettingsServiceFactory = () => mockUiSettingsClient; - configGetStub.withArgs('csv', 'maxSizeBytes').returns(18); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.maxSizeBytes') + .returns(18); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -843,7 +1032,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -871,7 +1065,10 @@ describe('CSV Execute Job', function() { describe('scroll settings', function() { it('passes scroll duration to initial search call', async function() { const scrollDuration = 'test'; - configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.scroll') + .returns({ duration: scrollDuration }); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -880,7 +1077,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -897,7 +1099,10 @@ describe('CSV Execute Job', function() { it('passes scroll size to initial search call', async function() { const scrollSize = 100; - configGetStub.withArgs('csv', 'scroll').returns({ size: scrollSize }); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.scroll') + .returns({ size: scrollSize }); callAsCurrentUserStub.onFirstCall().resolves({ hits: { @@ -906,7 +1111,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -923,7 +1133,10 @@ describe('CSV Execute Job', function() { it('passes scroll duration to subsequent scroll call', async function() { const scrollDuration = 'test'; - configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.scroll') + .returns({ duration: scrollDuration }); callAsCurrentUserStub.onFirstCall().resolves({ hits: { @@ -932,7 +1145,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts index a8249e5810d3c..1579985891053 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts @@ -6,26 +6,32 @@ import { i18n } from '@kbn/i18n'; import Hapi from 'hapi'; -import { IUiSettingsClient, KibanaRequest } from '../../../../../../../src/core/server'; +import { + ElasticsearchServiceSetup, + IUiSettingsClient, + KibanaRequest, +} from '../../../../../../../src/core/server'; import { CSV_JOB_TYPE } from '../../../common/constants'; import { ReportingCore } from '../../../server'; import { cryptoFactory } from '../../../server/lib'; import { getFieldFormats } from '../../../server/services'; -import { ESQueueWorkerExecuteFn, ExecuteJobFactory, Logger } from '../../../types'; +import { ESQueueWorkerExecuteFn, ExecuteJobFactory, Logger, ServerFacade } from '../../../types'; import { JobDocPayloadDiscoverCsv } from '../types'; import { fieldFormatMapFactory } from './lib/field_format_map'; import { createGenerateCsv } from './lib/generate_csv'; export const executeJobFactory: ExecuteJobFactory> = async function executeJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { - const [config, elasticsearch] = await Promise.all([ - reporting.getConfig(), - reporting.getElasticsearchService(), - ]); - const crypto = cryptoFactory(config.get('encryptionKey')); +>> = async function executeJobFactoryFn( + reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, + parentLogger: Logger +) { + const crypto = cryptoFactory(server); + const config = server.config(); const logger = parentLogger.clone([CSV_JOB_TYPE, 'execute-job']); - const serverBasePath = config.kbnConfig.get('server', 'basePath'); + const serverBasePath = config.get('server.basePath'); return async function executeJob( jobId: string, @@ -125,9 +131,9 @@ export const executeJobFactory: ExecuteJobFactory) { const response = await request; diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts index 529c195486bc6..842330fa7c93f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts @@ -5,8 +5,7 @@ */ import { CancellationToken } from '../../common/cancellation_token'; -import { ScrollConfig } from '../../server/types'; -import { JobDocPayload, JobParamPostPayload } from '../../types'; +import { JobDocPayload, JobParamPostPayload, ConditionalHeaders, RequestFacade } from '../../types'; interface DocValueField { field: string; @@ -107,7 +106,7 @@ export interface GenerateCsvParams { quoteValues: boolean; timezone: string | null; maxSizeBytes: number; - scroll: ScrollConfig; + scroll: { duration: string; size: number }; checkForFormulas?: boolean; }; } diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts index 15a1c3e0a9fad..17072d311b35f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts @@ -5,11 +5,18 @@ */ import { notFound, notImplemented } from 'boom'; +import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; import { cryptoFactory } from '../../../../server/lib'; -import { CreateJobFactory, ImmediateCreateJobFn, Logger, RequestFacade } from '../../../../types'; +import { + CreateJobFactory, + ImmediateCreateJobFn, + Logger, + RequestFacade, + ServerFacade, +} from '../../../../types'; import { JobDocPayloadPanelCsv, JobParamsPanelCsv, @@ -30,9 +37,13 @@ interface VisData { export const createJobFactory: CreateJobFactory> = async function createJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { - const config = await reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); +>> = function createJobFactoryFn( + reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, + parentLogger: Logger +) { + const crypto = cryptoFactory(server); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'create-job']); return async function createJob( diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts index debcdb47919f1..6bb3e73fcfe84 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ElasticsearchServiceSetup } from 'kibana/server'; import { CONTENT_TYPE_CSV, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; import { ReportingCore } from '../../../server'; import { cryptoFactory } from '../../../server/lib'; @@ -14,6 +15,7 @@ import { JobDocOutput, Logger, RequestFacade, + ServerFacade, } from '../../../types'; import { CsvResultFromSearch } from '../../csv/types'; import { FakeRequest, JobDocPayloadPanelCsv, JobParamsPanelCsv, SearchPanel } from '../types'; @@ -21,11 +23,15 @@ import { createGenerateCsv } from './lib'; export const executeJobFactory: ExecuteJobFactory> = async function executeJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { - const config = await reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); +>> = async function executeJobFactoryFn( + reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, + parentLogger: Logger +) { + const crypto = cryptoFactory(server); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'execute-job']); - const generateCsv = await createGenerateCsv(reporting, parentLogger); + const generateCsv = createGenerateCsv(reporting, server, elasticsearch, parentLogger); return async function executeJob( jobId: string | null, @@ -51,11 +57,11 @@ export const executeJobFactory: ExecuteJobFactory; + let decryptedHeaders; const serializedEncryptedHeaders = job.headers; try { decryptedHeaders = await crypto.decrypt(serializedEncryptedHeaders); @@ -73,7 +79,10 @@ export const executeJobFactory: ExecuteJobFactory { export async function generateCsvSearch( req: RequestFacade, reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, logger: Logger, searchPanel: SearchPanel, jobParams: JobParamsDiscoverCsv @@ -153,15 +159,11 @@ export async function generateCsvSearch( }, }; - const [elasticsearch, config] = await Promise.all([ - reporting.getElasticsearchService(), - reporting.getConfig(), - ]); - const { callAsCurrentUser } = elasticsearch.dataClient.asScoped( KibanaRequest.from(req.getRawRequest()) ); const callCluster = (...params: [string, object]) => callAsCurrentUser(...params); + const config = server.config(); const uiSettings = await getUiSettings(uiConfig); const generateCsvParams: GenerateCsvParams = { @@ -174,8 +176,8 @@ export async function generateCsvSearch( cancellationToken: new CancellationToken(), settings: { ...uiSettings, - maxSizeBytes: config.get('csv', 'maxSizeBytes'), - scroll: config.get('csv', 'scroll'), + maxSizeBytes: config.get('xpack.reporting.csv.maxSizeBytes'), + scroll: config.get('xpack.reporting.csv.scroll'), timezone, }, }; diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts index ab14d2dd8a660..6a7d5f336e238 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { JobDocPayload, JobParamPostPayload } from '../../types'; +import { JobParamPostPayload, JobDocPayload, ServerFacade } from '../../types'; export interface FakeRequest { - headers: Record; + headers: any; + server: ServerFacade; } export interface JobParamsPostPayloadPanelCsv extends JobParamPostPayload { diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts index 9aac612677094..a6911e1f14704 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts @@ -12,14 +12,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, + ServerFacade, } from '../../../../types'; import { JobParamsPNG } from '../../types'; export const createJobFactory: CreateJobFactory> = async function createJobFactoryFn(reporting: ReportingCore) { - const config = await reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); +>> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { + const crypto = cryptoFactory(server); return async function createJob( { objectType, title, relativeUrl, browserTimezone, layout }: JobParamsPNG, diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js index 267321d33809d..e2e6ba1b89096 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js @@ -5,6 +5,7 @@ */ import * as Rx from 'rxjs'; +import { memoize } from 'lodash'; import { createMockReportingCore } from '../../../../test_helpers'; import { cryptoFactory } from '../../../../server/lib/crypto'; import { executeJobFactory } from './index'; @@ -13,70 +14,63 @@ import { LevelLogger } from '../../../../server/lib'; jest.mock('../lib/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); -let mockReporting; -let mockReportingConfig; - const cancellationToken = { on: jest.fn(), }; -const mockLoggerFactory = { - get: jest.fn().mockImplementation(() => ({ - error: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - })), -}; -const getMockLogger = () => new LevelLogger(mockLoggerFactory); - -const mockEncryptionKey = 'abcabcsecuresecret'; -const encryptHeaders = async headers => { - const crypto = cryptoFactory(mockEncryptionKey); - return await crypto.encrypt(headers); -}; +let config; +let mockServer; +let mockReporting; beforeEach(async () => { mockReporting = await createMockReportingCore(); - const kbnConfig = { + config = { + 'xpack.reporting.encryptionKey': 'testencryptionkey', 'server.basePath': '/sbp', + 'server.host': 'localhost', + 'server.port': 5601, }; - const reportingConfig = { - encryptionKey: mockEncryptionKey, - 'kibanaServer.hostname': 'localhost', - 'kibanaServer.port': 5601, - 'kibanaServer.protocol': 'http', - }; - - const mockGetConfig = jest.fn(); - mockReportingConfig = { - get: (...keys) => reportingConfig[keys.join('.')], - kbnConfig: { get: (...keys) => kbnConfig[keys.join('.')] }, - }; - mockGetConfig.mockImplementation(() => Promise.resolve(mockReportingConfig)); - mockReporting.getConfig = mockGetConfig; - - const mockElasticsearch = { - dataClient: { - asScoped: () => ({ callAsCurrentUser: jest.fn() }), + mockServer = { + config: memoize(() => ({ get: jest.fn() })), + info: { + protocol: 'http', }, }; - const mockGetElasticsearch = jest.fn(); - mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); - mockReporting.getElasticsearchService = mockGetElasticsearch; + mockServer.config().get.mockImplementation(key => { + return config[key]; + }); generatePngObservableFactory.mockReturnValue(jest.fn()); }); afterEach(() => generatePngObservableFactory.mockReset()); +const mockElasticsearch = { + dataClient: { + asScoped: () => ({ callAsCurrentUser: jest.fn() }), + }, +}; + +const getMockLogger = () => new LevelLogger(); + +const encryptHeaders = async headers => { + const crypto = cryptoFactory(mockServer); + return await crypto.encrypt(headers); +}; + test(`passes browserTimezone to generatePng`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = generatePngObservableFactory(); generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const executeJob = await executeJobFactory( + mockReporting, + mockServer, + mockElasticsearch, + getMockLogger() + ); const browserTimezone = 'UTC'; await executeJob( 'pngJobId', @@ -94,7 +88,15 @@ test(`passes browserTimezone to generatePng`, async () => { }); test(`returns content_type of application/png`, async () => { - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const executeJob = await executeJobFactory( + mockReporting, + mockServer, + mockElasticsearch, + getMockLogger(), + { + browserDriverFactory: {}, + } + ); const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = generatePngObservableFactory(); @@ -114,7 +116,15 @@ test(`returns content of generatePng getBuffer base64 encoded`, async () => { const generatePngObservable = generatePngObservableFactory(); generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const executeJob = await executeJobFactory( + mockReporting, + mockServer, + mockElasticsearch, + getMockLogger(), + { + browserDriverFactory: {}, + } + ); const encryptedHeaders = await encryptHeaders({}); const { content } = await executeJob( 'pngJobId', diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts index c53c20efec247..8670f0027af89 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts @@ -4,11 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ElasticsearchServiceSetup } from 'kibana/server'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PNG_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; -import { ESQueueWorkerExecuteFn, ExecuteJobFactory, JobDocOutput, Logger } from '../../../../types'; +import { + ESQueueWorkerExecuteFn, + ExecuteJobFactory, + JobDocOutput, + Logger, + ServerFacade, +} from '../../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -22,24 +29,22 @@ type QueuedPngExecutorFactory = ExecuteJobFactory = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), + mergeMap(() => decryptJobHeaders({ server, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), - map(filteredHeaders => getConditionalHeaders({ config, job, filteredHeaders })), + map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), mergeMap(conditionalHeaders => { - const urls = getFullUrls({ config, job }); + const urls = getFullUrls({ server, job }); const hashUrl = urls[0]; return generatePngObservable( jobLogger, diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts index a15541d99f6fb..88e91982adc63 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts @@ -7,18 +7,17 @@ import * as Rx from 'rxjs'; import { map } from 'rxjs/operators'; import { LevelLogger } from '../../../../server/lib'; -import { CaptureConfig } from '../../../../server/types'; -import { ConditionalHeaders, HeadlessChromiumDriverFactory } from '../../../../types'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; import { LayoutParams } from '../../../common/layouts/layout'; import { PreserveLayout } from '../../../common/layouts/preserve_layout'; import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; import { ScreenshotResults } from '../../../common/lib/screenshots/types'; export function generatePngObservableFactory( - captureConfig: CaptureConfig, + server: ServerFacade, browserDriverFactory: HeadlessChromiumDriverFactory ) { - const screenshotsObservable = screenshotsObservableFactory(captureConfig, browserDriverFactory); + const screenshotsObservable = screenshotsObservableFactory(server, browserDriverFactory); return function generatePngObservable( logger: LevelLogger, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts index 8e1d5404a5984..656c99991e1f6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts @@ -12,14 +12,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, + ServerFacade, } from '../../../../types'; import { JobParamsPDF } from '../../types'; export const createJobFactory: CreateJobFactory> = async function createJobFactoryFn(reporting: ReportingCore) { - const config = await reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); +>> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { + const crypto = cryptoFactory(server); return async function createJobFn( { title, relativeUrls, browserTimezone, layout, objectType }: JobParamsPDF, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js index 29769108bf4ac..484842ba18f2a 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js @@ -5,6 +5,7 @@ */ import * as Rx from 'rxjs'; +import { memoize } from 'lodash'; import { createMockReportingCore } from '../../../../test_helpers'; import { cryptoFactory } from '../../../../server/lib/crypto'; import { executeJobFactory } from './index'; @@ -13,65 +14,57 @@ import { LevelLogger } from '../../../../server/lib'; jest.mock('../lib/generate_pdf', () => ({ generatePdfObservableFactory: jest.fn() })); -let mockReporting; -let mockReportingConfig; - const cancellationToken = { on: jest.fn(), }; -const mockLoggerFactory = { - get: jest.fn().mockImplementation(() => ({ - error: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - })), -}; -const getMockLogger = () => new LevelLogger(mockLoggerFactory); - -const mockEncryptionKey = 'testencryptionkey'; -const encryptHeaders = async headers => { - const crypto = cryptoFactory(mockEncryptionKey); - return await crypto.encrypt(headers); -}; +let config; +let mockServer; +let mockReporting; beforeEach(async () => { mockReporting = await createMockReportingCore(); - const kbnConfig = { + config = { + 'xpack.reporting.encryptionKey': 'testencryptionkey', 'server.basePath': '/sbp', + 'server.host': 'localhost', + 'server.port': 5601, }; - const reportingConfig = { - encryptionKey: mockEncryptionKey, - 'kibanaServer.hostname': 'localhost', - 'kibanaServer.port': 5601, - 'kibanaServer.protocol': 'http', - }; - - const mockGetConfig = jest.fn(); - mockReportingConfig = { - get: (...keys) => reportingConfig[keys.join('.')], - kbnConfig: { get: (...keys) => kbnConfig[keys.join('.')] }, - }; - mockGetConfig.mockImplementation(() => Promise.resolve(mockReportingConfig)); - mockReporting.getConfig = mockGetConfig; - - const mockElasticsearch = { - dataClient: { - asScoped: () => ({ callAsCurrentUser: jest.fn() }), + mockServer = { + config: memoize(() => ({ get: jest.fn() })), + info: { + protocol: 'http', }, }; - const mockGetElasticsearch = jest.fn(); - mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); - mockReporting.getElasticsearchService = mockGetElasticsearch; + mockServer.config().get.mockImplementation(key => { + return config[key]; + }); generatePdfObservableFactory.mockReturnValue(jest.fn()); }); afterEach(() => generatePdfObservableFactory.mockReset()); +const getMockLogger = () => new LevelLogger(); +const mockElasticsearch = { + dataClient: { + asScoped: () => ({ callAsCurrentUser: jest.fn() }), + }, +}; + +const encryptHeaders = async headers => { + const crypto = cryptoFactory(mockServer); + return await crypto.encrypt(headers); +}; + test(`returns content_type of application/pdf`, async () => { - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const executeJob = await executeJobFactory( + mockReporting, + mockServer, + mockElasticsearch, + getMockLogger() + ); const encryptedHeaders = await encryptHeaders({}); const generatePdfObservable = generatePdfObservableFactory(); @@ -91,7 +84,12 @@ test(`returns content of generatePdf getBuffer base64 encoded`, async () => { const generatePdfObservable = generatePdfObservableFactory(); generatePdfObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const executeJob = await executeJobFactory( + mockReporting, + mockServer, + mockElasticsearch, + getMockLogger() + ); const encryptedHeaders = await encryptHeaders({}); const { content } = await executeJob( 'pdfJobId', diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts index e614db46c5730..535c2dcd439a7 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts @@ -4,11 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ElasticsearchServiceSetup } from 'kibana/server'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PDF_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; -import { ESQueueWorkerExecuteFn, ExecuteJobFactory, JobDocOutput, Logger } from '../../../../types'; +import { + ESQueueWorkerExecuteFn, + ExecuteJobFactory, + JobDocOutput, + Logger, + ServerFacade, +} from '../../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -23,25 +30,23 @@ type QueuedPdfExecutorFactory = ExecuteJobFactory = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), + mergeMap(() => decryptJobHeaders({ server, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), - map(filteredHeaders => getConditionalHeaders({ config, job, filteredHeaders })), - mergeMap(conditionalHeaders => getCustomLogo({ reporting, config, job, conditionalHeaders })), + map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), + mergeMap(conditionalHeaders => getCustomLogo({ reporting, server, job, conditionalHeaders })), mergeMap(({ logo, conditionalHeaders }) => { - const urls = getFullUrls({ config, job }); + const urls = getFullUrls({ server, job }); const { browserTimezone, layout, title } = job; return generatePdfObservable( diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts index 7021fae983aa2..d78effaa1fc2f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts @@ -8,8 +8,7 @@ import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { LevelLogger } from '../../../../server/lib'; -import { ReportingConfigType } from '../../../../server/core'; -import { ConditionalHeaders, HeadlessChromiumDriverFactory } from '../../../../types'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; import { createLayout } from '../../../common/layouts'; import { LayoutInstance, LayoutParams } from '../../../common/layouts/layout'; import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; @@ -28,10 +27,10 @@ const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { }; export function generatePdfObservableFactory( - captureConfig: ReportingConfigType['capture'], + server: ServerFacade, browserDriverFactory: HeadlessChromiumDriverFactory ) { - const screenshotsObservable = screenshotsObservableFactory(captureConfig, browserDriverFactory); + const screenshotsObservable = screenshotsObservableFactory(server, browserDriverFactory); return function generatePdfObservable( logger: LevelLogger, @@ -42,7 +41,7 @@ export function generatePdfObservableFactory( layoutParams: LayoutParams, logo?: string ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { - const layout = createLayout(captureConfig, layoutParams) as LayoutInstance; + const layout = createLayout(server, layoutParams) as LayoutInstance; const screenshots$ = screenshotsObservable({ logger, urls, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts index e8dd3c5207d92..0a9dcfe986ca6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { JobDocPayload } from '../../types'; import { LayoutInstance, LayoutParams } from '../common/layouts/layout'; +import { JobDocPayload, ServerFacade, RequestFacade } from '../../types'; // Job params: structure of incoming user request data, after being parsed from RISON export interface JobParamsPDF { diff --git a/x-pack/legacy/plugins/reporting/index.test.js b/x-pack/legacy/plugins/reporting/index.test.js new file mode 100644 index 0000000000000..0d9a717bd7d81 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/index.test.js @@ -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 { reporting } from './index'; +import { getConfigSchema } from '../../../test_utils'; + +// The snapshot records the number of cpus available +// to make the snapshot deterministic `os.cpus` needs to be mocked +// but the other members on `os` must remain untouched +jest.mock('os', () => { + const os = jest.requireActual('os'); + os.cpus = () => [{}, {}, {}, {}]; + return os; +}); + +// eslint-disable-next-line jest/valid-describe +const describeWithContext = describe.each([ + [{ dev: false, dist: false }], + [{ dev: true, dist: false }], + [{ dev: false, dist: true }], + [{ dev: true, dist: true }], +]); + +describeWithContext('config schema with context %j', context => { + it('produces correct config', async () => { + const schema = await getConfigSchema(reporting); + const value = await schema.validate({}, { context }); + value.capture.browser.chromium.disableSandbox = ''; + await expect(value).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/reporting/index.ts b/x-pack/legacy/plugins/reporting/index.ts index fb95e2c2edc24..89e98302cddc9 100644 --- a/x-pack/legacy/plugins/reporting/index.ts +++ b/x-pack/legacy/plugins/reporting/index.ts @@ -8,16 +8,21 @@ import { i18n } from '@kbn/i18n'; import { Legacy } from 'kibana'; import { resolve } from 'path'; import { PLUGIN_ID, UI_SETTINGS_CUSTOM_PDF_LOGO } from './common/constants'; +import { config as reportingConfig } from './config'; import { legacyInit } from './server/legacy'; import { ReportingPluginSpecOptions } from './types'; -const kbToBase64Length = (kb: number) => Math.floor((kb * 1024 * 8) / 6); +const kbToBase64Length = (kb: number) => { + return Math.floor((kb * 1024 * 8) / 6); +}; export const reporting = (kibana: any) => { return new kibana.Plugin({ id: PLUGIN_ID, + configPrefix: 'xpack.reporting', publicDir: resolve(__dirname, 'public'), require: ['kibana', 'elasticsearch', 'xpack_main'], + config: reportingConfig, uiExports: { uiSettingDefaults: { @@ -44,5 +49,14 @@ export const reporting = (kibana: any) => { async init(server: Legacy.Server) { return legacyInit(server, this); }, + + deprecations({ unused }: any) { + return [ + unused('capture.concurrency'), + unused('capture.timeout'), + unused('capture.settleTime'), + unused('kibanaApp'), + ]; + }, } as ReportingPluginSpecOptions); }; diff --git a/x-pack/legacy/plugins/reporting/log_configuration.ts b/x-pack/legacy/plugins/reporting/log_configuration.ts index 7aaed2038bd52..b07475df6304f 100644 --- a/x-pack/legacy/plugins/reporting/log_configuration.ts +++ b/x-pack/legacy/plugins/reporting/log_configuration.ts @@ -6,23 +6,22 @@ import getosSync, { LinuxOs } from 'getos'; import { promisify } from 'util'; -import { BROWSER_TYPE } from './common/constants'; -import { CaptureConfig } from './server/types'; -import { Logger } from './types'; +import { ServerFacade, Logger } from './types'; const getos = promisify(getosSync); -export async function logConfiguration(captureConfig: CaptureConfig, logger: Logger) { - const { - browser: { - type: browserType, - chromium: { disableSandbox }, - }, - } = captureConfig; +export async function logConfiguration(server: ServerFacade, logger: Logger) { + const config = server.config(); + const browserType = config.get('xpack.reporting.capture.browser.type'); logger.debug(`Browser type: ${browserType}`); - if (browserType === BROWSER_TYPE) { - logger.debug(`Chromium sandbox disabled: ${disableSandbox}`); + + if (browserType === 'chromium') { + logger.debug( + `Chromium sandbox disabled: ${config.get( + 'xpack.reporting.capture.browser.chromium.disableSandbox' + )}` + ); } const os = await getos(); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts index a2f7a1f3ad0da..dc79a6b9db2c1 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts @@ -4,14 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CaptureConfig } from '../../../../server/types'; - -type ViewportConfig = CaptureConfig['viewport']; -type BrowserConfig = CaptureConfig['browser']['chromium']; +import { BrowserConfig } from '../../../../types'; interface LaunchArgs { userDataDir: BrowserConfig['userDataDir']; - viewport: ViewportConfig; + viewport: BrowserConfig['viewport']; disableSandbox: BrowserConfig['disableSandbox']; proxy: BrowserConfig['proxy']; } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index cb228150efbcd..f90f2c7aee395 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -19,8 +19,7 @@ import { import * as Rx from 'rxjs'; import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; -import { BROWSER_TYPE } from '../../../../common/constants'; -import { CaptureConfig } from '../../../../server/types'; +import { BrowserConfig, CaptureConfig } from '../../../../types'; import { LevelLogger as Logger } from '../../../lib/level_logger'; import { safeChildProcess } from '../../safe_child_process'; import { HeadlessChromiumDriver } from '../driver'; @@ -29,8 +28,7 @@ import { puppeteerLaunch } from '../puppeteer'; import { args } from './args'; type binaryPath = string; -type BrowserConfig = CaptureConfig['browser']['chromium']; -type ViewportConfig = CaptureConfig['viewport']; +type ViewportConfig = BrowserConfig['viewport']; export class HeadlessChromiumDriverFactory { private binaryPath: binaryPath; @@ -39,10 +37,15 @@ export class HeadlessChromiumDriverFactory { private userDataDir: string; private getChromiumArgs: (viewport: ViewportConfig) => string[]; - constructor(binaryPath: binaryPath, logger: Logger, captureConfig: CaptureConfig) { + constructor( + binaryPath: binaryPath, + logger: Logger, + browserConfig: BrowserConfig, + captureConfig: CaptureConfig + ) { this.binaryPath = binaryPath; + this.browserConfig = browserConfig; this.captureConfig = captureConfig; - this.browserConfig = captureConfig.browser.chromium; this.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chromium-')); this.getChromiumArgs = (viewport: ViewportConfig) => @@ -54,7 +57,7 @@ export class HeadlessChromiumDriverFactory { }); } - type = BROWSER_TYPE; + type = 'chromium'; test(logger: Logger) { const chromiumArgs = args({ @@ -150,7 +153,7 @@ export class HeadlessChromiumDriverFactory { // HeadlessChromiumDriver: object to "drive" a browser page const driver = new HeadlessChromiumDriver(page, { - inspect: !!this.browserConfig.inspect, + inspect: this.browserConfig.inspect, networkPolicy: this.captureConfig.networkPolicy, }); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts index 5f89662c94da2..d32338ae3e311 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CaptureConfig } from '../../../server/types'; +import { BrowserConfig, CaptureConfig } from '../../../types'; import { LevelLogger } from '../../lib'; import { HeadlessChromiumDriverFactory } from './driver_factory'; @@ -13,7 +13,8 @@ export { paths } from './paths'; export async function createDriverFactory( binaryPath: string, logger: LevelLogger, + browserConfig: BrowserConfig, captureConfig: CaptureConfig ): Promise { - return new HeadlessChromiumDriverFactory(binaryPath, logger, captureConfig); + return new HeadlessChromiumDriverFactory(binaryPath, logger, browserConfig, captureConfig); } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts index af3b86919dc50..49c6222c9f276 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts @@ -4,22 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger } from '../../types'; -import { ReportingConfig } from '../types'; -import { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; import { ensureBrowserDownloaded } from './download'; -import { chromium } from './index'; import { installBrowser } from './install'; +import { ServerFacade, CaptureConfig, Logger } from '../../types'; +import { BROWSER_TYPE } from '../../common/constants'; +import { chromium } from './index'; +import { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; export async function createBrowserDriverFactory( - config: ReportingConfig, + server: ServerFacade, logger: Logger ): Promise { - const captureConfig = config.get('capture'); - const browserConfig = captureConfig.browser.chromium; - const browserAutoDownload = captureConfig.browser.autoDownload; + const config = server.config(); + + const dataDir: string = config.get('path.data'); + const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); const browserType = captureConfig.browser.type; - const dataDir = config.kbnConfig.get('path', 'data'); + const browserAutoDownload = captureConfig.browser.autoDownload; + const browserConfig = captureConfig.browser[BROWSER_TYPE]; if (browserConfig.disableSandbox) { logger.warning(`Enabling the Chromium sandbox provides an additional layer of protection.`); @@ -30,7 +32,7 @@ export async function createBrowserDriverFactory( try { const { binaryPath } = await installBrowser(logger, chromium, dataDir); - return chromium.createDriverFactory(binaryPath, logger, captureConfig); + return chromium.createDriverFactory(binaryPath, logger, browserConfig, captureConfig); } catch (error) { if (error.cause && ['EACCES', 'EEXIST'].includes(error.cause.code)) { logger.error( diff --git a/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts b/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts index 3697c4b86ce3c..73186966e3d2f 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts @@ -4,15 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { existsSync } from 'fs'; import { resolve as resolvePath } from 'path'; -import { BROWSER_TYPE } from '../../../common/constants'; +import { existsSync } from 'fs'; + import { chromium } from '../index'; -import { BrowserDownload } from '../types'; +import { BrowserDownload, BrowserType } from '../types'; + import { md5 } from './checksum'; -import { clean } from './clean'; -import { download } from './download'; import { asyncMap } from './util'; +import { download } from './download'; +import { clean } from './clean'; /** * Check for the downloaded archive of each requested browser type and @@ -20,7 +21,7 @@ import { asyncMap } from './util'; * @param {String} browserType * @return {Promise} */ -export async function ensureBrowserDownloaded(browserType = BROWSER_TYPE) { +export async function ensureBrowserDownloaded(browserType: BrowserType) { await ensureDownloaded([chromium]); } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts b/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts index 9714c5965a5db..b36345c08bfee 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts @@ -6,7 +6,12 @@ import * as _ from 'lodash'; import { parse } from 'url'; -import { NetworkPolicyRule } from '../../types'; + +interface FirewallRule { + allow: boolean; + host?: string; + protocol?: string; +} const isHostMatch = (actualHost: string, ruleHost: string) => { const hostParts = actualHost.split('.').reverse(); @@ -15,7 +20,7 @@ const isHostMatch = (actualHost: string, ruleHost: string) => { return _.every(ruleParts, (part, idx) => part === hostParts[idx]); }; -export const allowRequest = (url: string, rules: NetworkPolicyRule[]) => { +export const allowRequest = (url: string, rules: FirewallRule[]) => { const parsed = parse(url); if (!rules.length) { diff --git a/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts b/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts index f096073ec2f5f..0c480fc82752b 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +export type BrowserType = 'chromium'; + export interface BrowserDownload { paths: { archivesPath: string; diff --git a/x-pack/legacy/plugins/reporting/server/config/config.js b/x-pack/legacy/plugins/reporting/server/config/config.js new file mode 100644 index 0000000000000..08e4db464b003 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/config/config.js @@ -0,0 +1,21 @@ +/* + * 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 { cpus } from 'os'; + +const defaultCPUCount = 2; + +function cpuCount() { + try { + return cpus().length; + } catch (e) { + return defaultCPUCount; + } +} + +export const config = { + concurrency: cpuCount(), +}; diff --git a/x-pack/legacy/plugins/reporting/server/core.ts b/x-pack/legacy/plugins/reporting/server/core.ts index c233a63833950..4506d41e4f5c3 100644 --- a/x-pack/legacy/plugins/reporting/server/core.ts +++ b/x-pack/legacy/plugins/reporting/server/core.ts @@ -7,14 +7,12 @@ import * as Rx from 'rxjs'; import { first, mapTo } from 'rxjs/operators'; import { - ElasticsearchServiceSetup, IUiSettingsClient, KibanaRequest, SavedObjectsClient, SavedObjectsServiceStart, UiSettingsServiceStart, } from 'src/core/server'; -import { ConfigType as ReportingConfigType } from '../../../../plugins/reporting/server'; // @ts-ignore no module definition import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; @@ -27,63 +25,14 @@ import { ReportingSetupDeps } from './types'; interface ReportingInternalSetup { browserDriverFactory: HeadlessChromiumDriverFactory; - config: ReportingConfig; - elasticsearch: ElasticsearchServiceSetup; } interface ReportingInternalStart { - enqueueJob: EnqueueJobFn; - esqueue: ESQueueInstance; savedObjects: SavedObjectsServiceStart; uiSettings: UiSettingsServiceStart; + esqueue: ESQueueInstance; + enqueueJob: EnqueueJobFn; } -// make config.get() aware of the value type it returns -interface Config { - get(key1: Key1): BaseType[Key1]; - get( - key1: Key1, - key2: Key2 - ): BaseType[Key1][Key2]; - get< - Key1 extends keyof BaseType, - Key2 extends keyof BaseType[Key1], - Key3 extends keyof BaseType[Key1][Key2] - >( - key1: Key1, - key2: Key2, - key3: Key3 - ): BaseType[Key1][Key2][Key3]; - get< - Key1 extends keyof BaseType, - Key2 extends keyof BaseType[Key1], - Key3 extends keyof BaseType[Key1][Key2], - Key4 extends keyof BaseType[Key1][Key2][Key3] - >( - key1: Key1, - key2: Key2, - key3: Key3, - key4: Key4 - ): BaseType[Key1][Key2][Key3][Key4]; -} - -interface KbnServerConfigType { - path: { data: string }; - server: { - basePath: string; - host: string; - name: string; - port: number; - protocol: string; - uuid: string; - }; -} - -export interface ReportingConfig extends Config { - kbnConfig: Config; -} - -export { ReportingConfigType }; - export class ReportingCore { private pluginSetupDeps?: ReportingInternalSetup; private pluginStartDeps?: ReportingInternalStart; @@ -96,7 +45,6 @@ export class ReportingCore { legacySetup( xpackMainPlugin: XPackMainPlugin, reporting: ReportingPluginSpecOptions, - config: ReportingConfig, __LEGACY: ServerFacade, plugins: ReportingSetupDeps ) { @@ -108,7 +56,7 @@ export class ReportingCore { xpackMainPlugin.info.feature(PLUGIN_ID).registerLicenseCheckResultsGenerator(checkLicense); }); // Reporting routes - registerRoutes(this, config, __LEGACY, plugins, this.logger); + registerRoutes(this, __LEGACY, plugins, this.logger); } public pluginSetup(reportingSetupDeps: ReportingInternalSetup) { @@ -142,31 +90,23 @@ export class ReportingCore { return (await this.getPluginSetupDeps()).browserDriverFactory; } - public async getConfig(): Promise { - return (await this.getPluginSetupDeps()).config; - } - /* - * Outside dependencies + * Kibana core module dependencies */ - private async getPluginSetupDeps(): Promise { + private async getPluginSetupDeps() { if (this.pluginSetupDeps) { return this.pluginSetupDeps; } return await this.pluginSetup$.pipe(first()).toPromise(); } - private async getPluginStartDeps(): Promise { + private async getPluginStartDeps() { if (this.pluginStartDeps) { return this.pluginStartDeps; } return await this.pluginStart$.pipe(first()).toPromise(); } - public async getElasticsearchService(): Promise { - return (await this.getPluginSetupDeps()).elasticsearch; - } - public async getSavedObjectsClient(fakeRequest: KibanaRequest): Promise { const { savedObjects } = await this.getPluginStartDeps(); return savedObjects.getScopedClient(fakeRequest) as SavedObjectsClient; diff --git a/x-pack/legacy/plugins/reporting/server/index.ts b/x-pack/legacy/plugins/reporting/server/index.ts index efcfd6b7f783d..24e2a954415d9 100644 --- a/x-pack/legacy/plugins/reporting/server/index.ts +++ b/x-pack/legacy/plugins/reporting/server/index.ts @@ -11,5 +11,5 @@ export const plugin = (context: PluginInitializerContext) => { return new Plugin(context); }; +export { ReportingCore } from './core'; export { ReportingPlugin } from './plugin'; -export { ReportingConfig, ReportingCore } from './core'; diff --git a/x-pack/legacy/plugins/reporting/server/legacy.ts b/x-pack/legacy/plugins/reporting/server/legacy.ts index 29e5af529767e..336ff5f4d2ee7 100644 --- a/x-pack/legacy/plugins/reporting/server/legacy.ts +++ b/x-pack/legacy/plugins/reporting/server/legacy.ts @@ -4,75 +4,35 @@ * you may not use this file except in compliance with the Elastic License. */ import { Legacy } from 'kibana'; -import { get } from 'lodash'; -import { take } from 'rxjs/operators'; -import { CoreSetup, PluginInitializerContext } from 'src/core/server'; -import { ConfigType, PluginsSetup } from '../../../../plugins/reporting/server'; +import { PluginInitializerContext } from 'src/core/server'; import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { ReportingPluginSpecOptions } from '../types'; import { plugin } from './index'; -import { LegacySetup, ReportingConfig, ReportingStartDeps } from './types'; +import { LegacySetup, ReportingStartDeps } from './types'; const buildLegacyDependencies = ( - coreSetup: CoreSetup, server: Legacy.Server, reportingPlugin: ReportingPluginSpecOptions -): LegacySetup => { - return { - route: server.route.bind(server), - plugins: { - xpack_main: server.plugins.xpack_main, - reporting: reportingPlugin, - }, - }; -}; - -const buildConfig = ( - coreSetup: CoreSetup, - server: Legacy.Server, - reportingConfig: ConfigType -): ReportingConfig => { - const config = server.config(); - const { http } = coreSetup; - const serverInfo = http.getServerInfo(); - - const kbnConfig = { - path: { - data: config.get('path.data'), // FIXME: get from the real PluginInitializerContext - }, - server: { - basePath: coreSetup.http.basePath.serverBasePath, - host: serverInfo.host, - name: serverInfo.name, - port: serverInfo.port, - uuid: coreSetup.uuid.getInstanceUuid(), - protocol: serverInfo.protocol, - }, - }; - - // spreading arguments as an array allows the return type to be known by the compiler - return { - get: (...keys: string[]) => get(reportingConfig, keys.join('.'), null), - kbnConfig: { - get: (...keys: string[]) => get(kbnConfig, keys.join('.'), null), - }, - }; -}; +): LegacySetup => ({ + config: server.config, + info: server.info, + route: server.route.bind(server), + plugins: { + elasticsearch: server.plugins.elasticsearch, + xpack_main: server.plugins.xpack_main, + reporting: reportingPlugin, + }, +}); export const legacyInit = async ( server: Legacy.Server, - reportingLegacyPlugin: ReportingPluginSpecOptions + reportingPlugin: ReportingPluginSpecOptions ) => { - const { core: coreSetup } = server.newPlatform.setup; - const { config$ } = (server.newPlatform.setup.plugins.reporting as PluginsSetup).__legacy; - const reportingConfig = await config$.pipe(take(1)).toPromise(); - const reporting = { config: buildConfig(coreSetup, server, reportingConfig) }; - - const __LEGACY = buildLegacyDependencies(coreSetup, server, reportingLegacyPlugin); + const coreSetup = server.newPlatform.setup.core; + const pluginInstance = plugin(server.newPlatform.coreContext as PluginInitializerContext); - const pluginInstance = plugin(server.newPlatform.coreContext as PluginInitializerContext); // NOTE: mocked-out PluginInitializerContext + const __LEGACY = buildLegacyDependencies(server, reportingPlugin); await pluginInstance.setup(coreSetup, { - reporting, elasticsearch: coreSetup.elasticsearch, security: server.newPlatform.setup.plugins.security as SecurityPluginSetup, usageCollection: server.newPlatform.setup.plugins.usageCollection, @@ -82,6 +42,7 @@ export const legacyInit = async ( // Schedule to call the "start" hook only after start dependencies are ready coreSetup.getStartServices().then(([core, plugins]) => pluginInstance.start(core, { + elasticsearch: coreSetup.elasticsearch, data: (plugins as ReportingStartDeps).data, __LEGACY, }) diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts b/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts index a05205526dd3e..d593e4625cdf4 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts @@ -4,24 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ESQueueInstance, Logger } from '../../types'; +import { ElasticsearchServiceSetup } from 'kibana/server'; +import { ESQueueInstance, ServerFacade, QueueConfig, Logger } from '../../types'; import { ReportingCore } from '../core'; -import { createTaggedLogger } from './create_tagged_logger'; // TODO remove createTaggedLogger once esqueue is removed -import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; +import { createWorkerFactory } from './create_worker'; +import { createTaggedLogger } from './create_tagged_logger'; // TODO remove createTaggedLogger once esqueue is removed export async function createQueueFactory( reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, logger: Logger ): Promise { - const [config, elasticsearch] = await Promise.all([ - reporting.getConfig(), - reporting.getElasticsearchService(), - ]); - - const queueConfig = config.get('queue'); - const index = config.get('index'); + const queueConfig: QueueConfig = server.config().get('xpack.reporting.queue'); + const index = server.config().get('xpack.reporting.index'); const queueOptions = { interval: queueConfig.indexInterval, @@ -35,7 +33,7 @@ export async function createQueueFactory( if (queueConfig.pollEnabled) { // create workers to poll the index for idle jobs waiting to be claimed and executed - const createWorker = await createWorkerFactory(reporting, config, logger); + const createWorker = createWorkerFactory(reporting, server, elasticsearch, logger); await createWorker(queue); } else { logger.info( diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts b/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts index 01a937a49873a..d4d913243e18d 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ElasticsearchServiceSetup } from 'kibana/server'; import * as sinon from 'sinon'; -import { ReportingConfig, ReportingCore } from '../../server/types'; +import { ReportingCore } from '../../server'; import { createMockReportingCore } from '../../test_helpers'; +import { ServerFacade } from '../../types'; import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; @@ -15,15 +17,21 @@ import { ClientMock } from './esqueue/__tests__/fixtures/legacy_elasticsearch'; import { ExportTypesRegistry } from './export_types_registry'; const configGetStub = sinon.stub(); -configGetStub.withArgs('queue').returns({ +configGetStub.withArgs('xpack.reporting.queue').returns({ pollInterval: 3300, pollIntervalErrorMultiplier: 10, }); -configGetStub.withArgs('server', 'name').returns('test-server-123'); -configGetStub.withArgs('server', 'uuid').returns('g9ymiujthvy6v8yrh7567g6fwzgzftzfr'); +configGetStub.withArgs('server.name').returns('test-server-123'); +configGetStub.withArgs('server.uuid').returns('g9ymiujthvy6v8yrh7567g6fwzgzftzfr'); const executeJobFactoryStub = sinon.stub(); -const getMockLogger = sinon.stub(); + +const getMockServer = (): ServerFacade => { + return ({ + config: () => ({ get: configGetStub }), + } as unknown) as ServerFacade; +}; +const getMockLogger = jest.fn(); const getMockExportTypesRegistry = ( exportTypes: any[] = [{ executeJobFactory: executeJobFactoryStub }] @@ -33,23 +41,25 @@ const getMockExportTypesRegistry = ( } as ExportTypesRegistry); describe('Create Worker', () => { - let mockReporting: ReportingCore; - let mockConfig: ReportingConfig; let queue: Esqueue; let client: ClientMock; + let mockReporting: ReportingCore; beforeEach(async () => { mockReporting = await createMockReportingCore(); - mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); - mockConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; - mockReporting.getConfig = () => Promise.resolve(mockConfig); client = new ClientMock(); queue = new Esqueue('reporting-queue', { client }); executeJobFactoryStub.reset(); }); test('Creates a single Esqueue worker for Reporting', async () => { - const createWorker = await createWorkerFactory(mockReporting, mockConfig, getMockLogger()); + mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); + const createWorker = createWorkerFactory( + mockReporting, + getMockServer(), + {} as ElasticsearchServiceSetup, + getMockLogger() + ); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); @@ -81,7 +91,12 @@ Object { { executeJobFactory: executeJobFactoryStub }, ]); mockReporting.getExportTypesRegistry = () => exportTypesRegistry; - const createWorker = await createWorkerFactory(mockReporting, mockConfig, getMockLogger()); + const createWorker = createWorkerFactory( + mockReporting, + getMockServer(), + {} as ElasticsearchServiceSetup, + getMockLogger() + ); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts b/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts index e9d0acf29c721..3567712367608 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ElasticsearchServiceSetup } from 'kibana/server'; import { CancellationToken } from '../../common/cancellation_token'; import { PLUGIN_ID } from '../../common/constants'; -import { ReportingConfig } from '../../server/types'; import { ESQueueInstance, ESQueueWorkerExecuteFn, @@ -15,22 +15,25 @@ import { JobDocPayload, JobSource, Logger, + QueueConfig, RequestFacade, + ServerFacade, } from '../../types'; import { ReportingCore } from '../core'; // @ts-ignore untyped dependency import { events as esqueueEvents } from './esqueue'; -export async function createWorkerFactory( +export function createWorkerFactory( reporting: ReportingCore, - config: ReportingConfig, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, logger: Logger ) { type JobDocPayloadType = JobDocPayload; - - const queueConfig = config.get('queue'); - const kibanaName = config.kbnConfig.get('server', 'name'); - const kibanaId = config.kbnConfig.get('server', 'uuid'); + const config = server.config(); + const queueConfig: QueueConfig = config.get('xpack.reporting.queue'); + const kibanaName: string = config.get('server.name'); + const kibanaId: string = config.get('server.uuid'); // Once more document types are added, this will need to be passed in return async function createWorker(queue: ESQueueInstance) { @@ -44,7 +47,12 @@ export async function createWorkerFactory( ExportTypeDefinition >) { // TODO: the executeJobFn should be unwrapped in the register method of the export types registry - const jobExecutor = await exportType.executeJobFactory(reporting, logger); + const jobExecutor = await exportType.executeJobFactory( + reporting, + server, + elasticsearch, + logger + ); jobExecutors.set(exportType.jobType, jobExecutor); } diff --git a/x-pack/legacy/plugins/reporting/server/lib/crypto.ts b/x-pack/legacy/plugins/reporting/server/lib/crypto.ts index 97876529ecfa7..dbc01fc947f8b 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/crypto.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/crypto.ts @@ -5,7 +5,12 @@ */ import nodeCrypto from '@elastic/node-crypto'; +import { oncePerServer } from './once_per_server'; +import { ServerFacade } from '../../types'; -export function cryptoFactory(encryptionKey: string | undefined) { +function cryptoFn(server: ServerFacade) { + const encryptionKey = server.config().get('xpack.reporting.encryptionKey'); return nodeCrypto({ encryptionKey }); } + +export const cryptoFactory = oncePerServer(cryptoFn); diff --git a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts index bc4754b02ed57..c215bdc398904 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts @@ -5,18 +5,22 @@ */ import { get } from 'lodash'; +import { ElasticsearchServiceSetup } from 'kibana/server'; +// @ts-ignore +import { events as esqueueEvents } from './esqueue'; import { - ConditionalHeaders, EnqueueJobFn, ESQueueCreateJobFn, ImmediateCreateJobFn, Job, - Logger, + ServerFacade, RequestFacade, + Logger, + CaptureConfig, + QueueConfig, + ConditionalHeaders, } from '../../types'; import { ReportingCore } from '../core'; -// @ts-ignore -import { events as esqueueEvents } from './esqueue'; interface ConfirmedJob { id: string; @@ -25,16 +29,18 @@ interface ConfirmedJob { _primary_term: number; } -export async function enqueueJobFactory( +export function enqueueJobFactory( reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, parentLogger: Logger -): Promise { - const config = await reporting.getConfig(); +): EnqueueJobFn { const logger = parentLogger.clone(['queue-job']); - const captureConfig = config.get('capture'); + const config = server.config(); + const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); const browserType = captureConfig.browser.type; const maxAttempts = captureConfig.maxAttempts; - const queueConfig = config.get('queue'); + const queueConfig: QueueConfig = config.get('xpack.reporting.queue'); return async function enqueueJob( exportTypeId: string, @@ -53,7 +59,12 @@ export async function enqueueJobFactory( } // TODO: the createJobFn should be unwrapped in the register method of the export types registry - const createJob = (await exportType.createJobFactory(reporting, logger)) as CreateJobFn; + const createJob = exportType.createJobFactory( + reporting, + server, + elasticsearch, + logger + ) as CreateJobFn; const payload = await createJob(jobParams, headers, request); const options = { diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js b/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js index 8e4047e2f22e5..6cdbe8f968f75 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js +++ b/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js @@ -8,7 +8,6 @@ import moment from 'moment'; export const intervals = ['year', 'month', 'week', 'day', 'hour', 'minute']; -// TODO: remove this helper by using `schema.duration` objects in the reporting config schema export function indexTimestamp(intervalStr, separator = '-') { if (separator.match(/[a-z]/i)) throw new Error('Interval separator can not be a letter'); diff --git a/x-pack/legacy/plugins/reporting/server/lib/get_user.ts b/x-pack/legacy/plugins/reporting/server/lib/get_user.ts index 5e73fe77ecb79..49d5c568c3981 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/get_user.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/get_user.ts @@ -6,10 +6,10 @@ import { Legacy } from 'kibana'; import { KibanaRequest } from '../../../../../../src/core/server'; -import { Logger } from '../../types'; +import { ServerFacade } from '../../types'; import { ReportingSetupDeps } from '../types'; -export function getUserFactory(security: ReportingSetupDeps['security'], logger: Logger) { +export function getUserFactory(server: ServerFacade, security: ReportingSetupDeps['security']) { /* * Legacy.Request because this is called from routing middleware */ diff --git a/x-pack/legacy/plugins/reporting/server/lib/index.ts b/x-pack/legacy/plugins/reporting/server/lib/index.ts index f5ccbe493a91f..0a2db749cb954 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/index.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -export { checkLicenseFactory } from './check_license'; -export { createQueueFactory } from './create_queue'; -export { cryptoFactory } from './crypto'; -export { enqueueJobFactory } from './enqueue_job'; export { getExportTypesRegistry } from './export_types_registry'; +export { checkLicenseFactory } from './check_license'; export { LevelLogger } from './level_logger'; +export { cryptoFactory } from './crypto'; +export { oncePerServer } from './once_per_server'; export { runValidations } from './validate'; +export { createQueueFactory } from './create_queue'; +export { enqueueJobFactory } from './enqueue_job'; diff --git a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts index 0affc111c1368..c01e6377b039e 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts @@ -9,8 +9,7 @@ import Boom from 'boom'; import { errors as elasticsearchErrors } from 'elasticsearch'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; -import { JobSource } from '../../types'; -import { ReportingConfig } from '../types'; +import { JobSource, ServerFacade } from '../../types'; const esErrors = elasticsearchErrors as Record; const defaultSize = 10; @@ -40,11 +39,8 @@ interface CountAggResult { count: number; } -export function jobsQueryFactory( - config: ReportingConfig, - elasticsearch: ElasticsearchServiceSetup -) { - const index = config.get('index'); +export function jobsQueryFactory(server: ServerFacade, elasticsearch: ElasticsearchServiceSetup) { + const index = server.config().get('xpack.reporting.index'); const { callAsInternalUser } = elasticsearch.adminClient; function getUsername(user: any) { diff --git a/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts b/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts new file mode 100644 index 0000000000000..ae3636079a9bb --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts @@ -0,0 +1,43 @@ +/* + * 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 { memoize, MemoizedFunction } from 'lodash'; +import { ServerFacade } from '../../types'; + +type ServerFn = (server: ServerFacade) => any; +type Memo = ((server: ServerFacade) => any) & MemoizedFunction; + +/** + * allow this function to be called multiple times, but + * ensure that it only received one argument, the server, + * and cache the return value so that subsequent calls get + * the exact same value. + * + * This is intended to be used by service factories like getObjectQueueFactory + * + * @param {Function} fn - the factory function + * @return {any} + */ +export function oncePerServer(fn: ServerFn) { + const memoized: Memo = memoize(function(server: ServerFacade) { + if (arguments.length !== 1) { + throw new TypeError('This function expects to be called with a single argument'); + } + + // @ts-ignore + return fn.call(this, server); + }); + + // @ts-ignore + // Type 'WeakMap' is not assignable to type 'MapCache + + // use a weak map a the cache so that: + // 1. return values mapped to the actual server instance + // 2. return value lifecycle matches that of the server + memoized.cache = new WeakMap(); + + return memoized; +} diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js new file mode 100644 index 0000000000000..10980f702d849 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js @@ -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 expect from '@kbn/expect'; +import sinon from 'sinon'; +import { validateEncryptionKey } from '../validate_encryption_key'; + +describe('Reporting: Validate config', () => { + const logger = { + warning: sinon.spy(), + }; + + beforeEach(() => { + logger.warning.resetHistory(); + }); + + [undefined, null].forEach(value => { + it(`should log a warning and set xpack.reporting.encryptionKey if encryptionKey is ${value}`, () => { + const config = { + get: sinon.stub().returns(value), + set: sinon.stub(), + }; + + expect(() => validateEncryptionKey({ config: () => config }, logger)).not.to.throwError(); + + sinon.assert.calledWith(config.set, 'xpack.reporting.encryptionKey'); + sinon.assert.calledWithMatch(logger.warning, /Generating a random key/); + sinon.assert.calledWithMatch(logger.warning, /please set xpack.reporting.encryptionKey/); + }); + }); +}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts new file mode 100644 index 0000000000000..04f998fd3e5a5 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.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 expect from '@kbn/expect'; +import sinon from 'sinon'; +import { ServerFacade } from '../../../../types'; +import { validateServerHost } from '../validate_server_host'; + +const configKey = 'xpack.reporting.kibanaServer.hostname'; + +describe('Reporting: Validate server host setting', () => { + it(`should log a warning and set ${configKey} if server.host is "0"`, () => { + const getStub = sinon.stub(); + getStub.withArgs('server.host').returns('0'); + getStub.withArgs(configKey).returns(undefined); + const config = { + get: getStub, + set: sinon.stub(), + }; + + expect(() => + validateServerHost(({ config: () => config } as unknown) as ServerFacade) + ).to.throwError(); + + sinon.assert.calledWith(config.set, configKey); + }); +}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts index 85d9f727d7fa7..0fdbd858b8e3c 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts @@ -6,22 +6,25 @@ import { i18n } from '@kbn/i18n'; import { ElasticsearchServiceSetup } from 'kibana/server'; -import { Logger } from '../../../types'; +import { Logger, ServerFacade } from '../../../types'; import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; -import { ReportingConfig } from '../../types'; import { validateBrowser } from './validate_browser'; +import { validateEncryptionKey } from './validate_encryption_key'; import { validateMaxContentLength } from './validate_max_content_length'; +import { validateServerHost } from './validate_server_host'; export async function runValidations( - config: ReportingConfig, + server: ServerFacade, elasticsearch: ElasticsearchServiceSetup, browserFactory: HeadlessChromiumDriverFactory, logger: Logger ) { try { await Promise.all([ - validateBrowser(browserFactory, logger), - validateMaxContentLength(config, elasticsearch, logger), + validateBrowser(server, browserFactory, logger), + validateEncryptionKey(server, logger), + validateMaxContentLength(server, elasticsearch, logger), + validateServerHost(server), ]); logger.debug( i18n.translate('xpack.reporting.selfCheck.ok', { diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts index d6512d5eb718b..89c49123e85bf 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts @@ -3,10 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { Browser } from 'puppeteer'; import { BROWSER_TYPE } from '../../../common/constants'; -import { Logger } from '../../../types'; +import { ServerFacade, Logger } from '../../../types'; import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; /* @@ -14,6 +13,7 @@ import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_fa * to the locally running Kibana instance. */ export const validateBrowser = async ( + server: ServerFacade, browserFactory: HeadlessChromiumDriverFactory, logger: Logger ) => { diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts new file mode 100644 index 0000000000000..e0af94cbdc29c --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts @@ -0,0 +1,31 @@ +/* + * 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 crypto from 'crypto'; +import { ServerFacade, Logger } from '../../../types'; + +export function validateEncryptionKey(serverFacade: ServerFacade, logger: Logger) { + const config = serverFacade.config(); + + const encryptionKey = config.get('xpack.reporting.encryptionKey'); + if (encryptionKey == null) { + // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. + logger.warning( + i18n.translate('xpack.reporting.selfCheckEncryptionKey.warning', { + defaultMessage: + `Generating a random key for {setting}. To prevent pending reports ` + + `from failing on restart, please set {setting} in kibana.yml`, + values: { + setting: 'xpack.reporting.encryptionKey', + }, + }) + ); + + // @ts-ignore: No set() method on KibanaConfig, just get() and has() + config.set('xpack.reporting.encryptionKey', crypto.randomBytes(16).toString('hex')); // update config in memory to contain a usable encryption key + } +} diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js index 2551fd48b91f3..942dcaf842696 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js @@ -32,7 +32,11 @@ describe('Reporting: Validate Max Content Length', () => { }); it('should log warning messages when reporting has a higher max-size than elasticsearch', async () => { - const config = { get: sinon.stub().returns(FIVE_HUNDRED_MEGABYTES) }; + const server = { + config: () => ({ + get: sinon.stub().returns(FIVE_HUNDRED_MEGABYTES), + }), + }; const elasticsearch = { dataClient: { callAsInternalUser: () => ({ @@ -45,7 +49,7 @@ describe('Reporting: Validate Max Content Length', () => { }, }; - await validateMaxContentLength(config, elasticsearch, logger); + await validateMaxContentLength(server, elasticsearch, logger); sinon.assert.calledWithMatch( logger.warning, @@ -66,10 +70,14 @@ describe('Reporting: Validate Max Content Length', () => { }); it('should do nothing when reporting has the same max-size as elasticsearch', async () => { - const config = { get: sinon.stub().returns(ONE_HUNDRED_MEGABYTES) }; + const server = { + config: () => ({ + get: sinon.stub().returns(ONE_HUNDRED_MEGABYTES), + }), + }; expect( - async () => await validateMaxContentLength(config, elasticsearch, logger.warning) + async () => await validateMaxContentLength(server, elasticsearch, logger.warning) ).not.toThrow(); sinon.assert.notCalled(logger.warning); }); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts index a20905ba093d4..ce4a5b93e7431 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts @@ -7,17 +7,17 @@ import numeral from '@elastic/numeral'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { defaults, get } from 'lodash'; -import { Logger } from '../../../types'; -import { ReportingConfig } from '../../types'; +import { Logger, ServerFacade } from '../../../types'; -const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes'; +const KIBANA_MAX_SIZE_BYTES_PATH = 'xpack.reporting.csv.maxSizeBytes'; const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length'; export async function validateMaxContentLength( - config: ReportingConfig, + server: ServerFacade, elasticsearch: ElasticsearchServiceSetup, logger: Logger ) { + const config = server.config(); const { callAsInternalUser } = elasticsearch.dataClient; const elasticClusterSettingsResponse = await callAsInternalUser('cluster.getSettings', { @@ -28,13 +28,13 @@ export async function validateMaxContentLength( const elasticSearchMaxContent = get(elasticClusterSettings, 'http.max_content_length', '100mb'); const elasticSearchMaxContentBytes = numeral().unformat(elasticSearchMaxContent.toUpperCase()); - const kibanaMaxContentBytes = config.get('csv', 'maxSizeBytes'); + const kibanaMaxContentBytes: number = config.get(KIBANA_MAX_SIZE_BYTES_PATH); if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) { // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. logger.warning( - `xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + - `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` + `${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + + `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your ${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` ); } } diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts new file mode 100644 index 0000000000000..f4f4d61246b6a --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.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 { ServerFacade } from '../../../types'; + +const configKey = 'xpack.reporting.kibanaServer.hostname'; + +export function validateServerHost(serverFacade: ServerFacade) { + const config = serverFacade.config(); + + const serverHost = config.get('server.host'); + const reportingKibanaHostName = config.get(configKey); + + if (!reportingKibanaHostName && serverHost === '0') { + // @ts-ignore: No set() method on KibanaConfig, just get() and has() + config.set(configKey, '0.0.0.0'); // update config in memory to allow Reporting to work + + throw new Error( + `Found 'server.host: "0"' in settings. This is incompatible with Reporting. ` + + `To enable Reporting to work, '${configKey}: 0.0.0.0' is being automatically to the configuration. ` + + `You can change to 'server.host: 0.0.0.0' or add '${configKey}: 0.0.0.0' in kibana.yml to prevent this message.` + ); + } +} diff --git a/x-pack/legacy/plugins/reporting/server/plugin.ts b/x-pack/legacy/plugins/reporting/server/plugin.ts index 1d7cc075b690d..4f24cc16b2277 100644 --- a/x-pack/legacy/plugins/reporting/server/plugin.ts +++ b/x-pack/legacy/plugins/reporting/server/plugin.ts @@ -12,6 +12,8 @@ import { createQueueFactory, enqueueJobFactory, LevelLogger, runValidations } fr import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; import { registerReportingUsageCollector } from './usage'; +// @ts-ignore no module definition +import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; export class ReportingPlugin implements Plugin { @@ -24,29 +26,29 @@ export class ReportingPlugin } public async setup(core: CoreSetup, plugins: ReportingSetupDeps) { - const { reporting: reportingNewPlatform, elasticsearch, __LEGACY } = plugins; - const { config } = reportingNewPlatform; + const { elasticsearch, usageCollection, __LEGACY } = plugins; - const browserDriverFactory = await createBrowserDriverFactory(config, this.logger); // required for validations :( - runValidations(config, elasticsearch, browserDriverFactory, this.logger); // this must run early, as it sets up config defaults + const browserDriverFactory = await createBrowserDriverFactory(__LEGACY, this.logger); // required for validations :( + runValidations(__LEGACY, elasticsearch, browserDriverFactory, this.logger); // this must run early, as it sets up config defaults const { xpack_main: xpackMainLegacy, reporting: reportingLegacy } = __LEGACY.plugins; - this.reportingCore.legacySetup(xpackMainLegacy, reportingLegacy, config, __LEGACY, plugins); + this.reportingCore.legacySetup(xpackMainLegacy, reportingLegacy, __LEGACY, plugins); // Register a function with server to manage the collection of usage stats - registerReportingUsageCollector(this.reportingCore, config, plugins); + registerReportingUsageCollector(this.reportingCore, __LEGACY, usageCollection); // regsister setup internals - this.reportingCore.pluginSetup({ browserDriverFactory, config, elasticsearch }); + this.reportingCore.pluginSetup({ browserDriverFactory }); return {}; } public async start(core: CoreStart, plugins: ReportingStartDeps) { const { reportingCore, logger } = this; + const { elasticsearch, __LEGACY } = plugins; - const esqueue = await createQueueFactory(reportingCore, logger); - const enqueueJob = await enqueueJobFactory(reportingCore, logger); + const esqueue = await createQueueFactory(reportingCore, __LEGACY, elasticsearch, logger); + const enqueueJob = enqueueJobFactory(reportingCore, __LEGACY, elasticsearch, logger); this.reportingCore.pluginStart({ savedObjects: core.savedObjects, @@ -56,9 +58,7 @@ export class ReportingPlugin }); setFieldFormats(plugins.data.fieldFormats); - - const config = await reportingCore.getConfig(); - logConfiguration(config.get('capture'), this.logger); + logConfiguration(__LEGACY, this.logger); return {}; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts index dc58e97ff3e41..56622617586f7 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts @@ -10,7 +10,7 @@ import { Legacy } from 'kibana'; import rison from 'rison-node'; import { API_BASE_URL } from '../../common/constants'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingConfig, ReportingSetupDeps } from '../types'; +import { ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { GetRouteConfigFactoryFn, @@ -22,7 +22,6 @@ import { HandlerErrorFunction, HandlerFunction } from './types'; const BASE_GENERATE = `${API_BASE_URL}/generate`; export function registerGenerateFromJobParams( - config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, handler: HandlerFunction, @@ -31,7 +30,7 @@ export function registerGenerateFromJobParams( ) { const getRouteConfig = () => { const getOriginalRouteConfig: GetRouteConfigFactoryFn = getRouteConfigFactoryReportingPre( - config, + server, plugins, logger ); diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts index 23ab7ee0d9e6b..415b6b7d64366 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts @@ -9,7 +9,7 @@ import { get } from 'lodash'; import { API_BASE_GENERATE_V1, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../common/constants'; import { getJobParamsFromRequest } from '../../export_types/csv_from_savedobject/server/lib/get_job_params_from_request'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingConfig, ReportingSetupDeps } from '../types'; +import { ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { getRouteOptionsCsv } from './lib/route_config_factories'; import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types'; @@ -24,14 +24,13 @@ import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types * - local (transient) changes the user made to the saved object */ export function registerGenerateCsvFromSavedObject( - config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, handleRoute: HandlerFunction, handleRouteError: HandlerErrorFunction, logger: Logger ) { - const routeOptions = getRouteOptionsCsv(config, plugins, logger); + const routeOptions = getRouteOptionsCsv(server, plugins, logger); server.route({ path: `${API_BASE_GENERATE_V1}/csv/saved-object/{savedObjectType}:{savedObjectId}`, diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts index 5bd07aa6049ed..5d17fa2e82b8c 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts @@ -16,7 +16,7 @@ import { ResponseFacade, ServerFacade, } from '../../types'; -import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingSetupDeps, ReportingCore } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { getRouteOptionsCsv } from './lib/route_config_factories'; @@ -31,12 +31,12 @@ import { getRouteOptionsCsv } from './lib/route_config_factories'; */ export function registerGenerateCsvFromSavedObjectImmediate( reporting: ReportingCore, - config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, parentLogger: Logger ) { - const routeOptions = getRouteOptionsCsv(config, plugins, parentLogger); + const routeOptions = getRouteOptionsCsv(server, plugins, parentLogger); + const { elasticsearch } = plugins; /* * CSV export with the `immediate` option does not queue a job with Reporting's ESQueue to run the job async. Instead, this does: @@ -52,10 +52,14 @@ export function registerGenerateCsvFromSavedObjectImmediate( const logger = parentLogger.clone(['savedobject-csv']); const jobParams = getJobParamsFromRequest(request, { isImmediate: true }); - const [createJobFn, executeJobFn] = await Promise.all([ - createJobFactory(reporting, logger), - executeJobFactory(reporting, logger), - ]); + /* TODO these functions should be made available in the export types registry: + * + * const { createJobFn, executeJobFn } = exportTypesRegistry.getById(CSV_FROM_SAVEDOBJECT_JOB_TYPE) + * + * Calling an execute job factory requires passing a browserDriverFactory option, so we should not call the factory from here + */ + const createJobFn = createJobFactory(reporting, server, elasticsearch, logger); + const executeJobFn = await executeJobFactory(reporting, server, elasticsearch, logger); const jobDocPayload: JobDocPayloadPanelCsv = await createJobFn( jobParams, request.headers, diff --git a/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts b/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts index 44a98dac2d4a9..54d9671692c5d 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts @@ -7,7 +7,7 @@ import Hapi from 'hapi'; import { createMockReportingCore } from '../../test_helpers'; import { Logger, ServerFacade } from '../../types'; -import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingCore, ReportingSetupDeps } from '../../server/types'; jest.mock('./lib/authorized_user_pre_routing', () => ({ authorizedUserPreRoutingFactory: () => () => ({}), @@ -22,8 +22,6 @@ import { registerJobGenerationRoutes } from './generation'; let mockServer: Hapi.Server; let mockReportingPlugin: ReportingCore; -let mockReportingConfig: ReportingConfig; - const mockLogger = ({ error: jest.fn(), debug: jest.fn(), @@ -35,12 +33,10 @@ beforeEach(async () => { port: 8080, routes: { log: { collect: true } }, }); - + mockServer.config = () => ({ get: jest.fn(), has: jest.fn() }); mockReportingPlugin = await createMockReportingCore(); mockReportingPlugin.getEnqueueJob = async () => jest.fn().mockImplementation(() => ({ toJSON: () => '{ "job": "data" }' })); - - mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; }); const mockPlugins = { @@ -58,7 +54,6 @@ const getErrorsFromRequest = (request: Hapi.Request) => { test(`returns 400 if there are no job params`, async () => { registerJobGenerationRoutes( mockReportingPlugin, - (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger @@ -85,7 +80,6 @@ test(`returns 400 if there are no job params`, async () => { test(`returns 400 if job params is invalid`, async () => { registerJobGenerationRoutes( mockReportingPlugin, - (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger @@ -120,7 +114,6 @@ test(`returns 500 if job handler throws an error`, async () => { registerJobGenerationRoutes( mockReportingPlugin, - (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger diff --git a/x-pack/legacy/plugins/reporting/server/routes/generation.ts b/x-pack/legacy/plugins/reporting/server/routes/generation.ts index 0ac6a34dd75bb..096ba84b63d1a 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generation.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generation.ts @@ -9,7 +9,7 @@ import { errors as elasticsearchErrors } from 'elasticsearch'; import { Legacy } from 'kibana'; import { API_BASE_URL } from '../../common/constants'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingSetupDeps, ReportingCore } from '../types'; import { registerGenerateFromJobParams } from './generate_from_jobparams'; import { registerGenerateCsvFromSavedObject } from './generate_from_savedobject'; import { registerGenerateCsvFromSavedObjectImmediate } from './generate_from_savedobject_immediate'; @@ -19,13 +19,12 @@ const esErrors = elasticsearchErrors as Record; export function registerJobGenerationRoutes( reporting: ReportingCore, - config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - const DOWNLOAD_BASE_URL = - `${config.kbnConfig.get('server', 'basePath')}` + `${API_BASE_URL}/jobs/download`; + const config = server.config(); + const DOWNLOAD_BASE_URL = config.get('server.basePath') + `${API_BASE_URL}/jobs/download`; /* * Generates enqueued job details to use in responses @@ -67,11 +66,11 @@ export function registerJobGenerationRoutes( return err; } - registerGenerateFromJobParams(config, server, plugins, handler, handleError, logger); + registerGenerateFromJobParams(server, plugins, handler, handleError, logger); // Register beta panel-action download-related API's - if (config.get('csv', 'enablePanelActionDownload')) { - registerGenerateCsvFromSavedObject(config, server, plugins, handler, handleError, logger); - registerGenerateCsvFromSavedObjectImmediate(reporting, config, server, plugins, logger); + if (config.get('xpack.reporting.csv.enablePanelActionDownload')) { + registerGenerateCsvFromSavedObject(server, plugins, handler, handleError, logger); + registerGenerateCsvFromSavedObjectImmediate(reporting, server, plugins, logger); } } diff --git a/x-pack/legacy/plugins/reporting/server/routes/index.ts b/x-pack/legacy/plugins/reporting/server/routes/index.ts index 21eeb901d9b96..610ab4907d369 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/index.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/index.ts @@ -5,17 +5,16 @@ */ import { Logger, ServerFacade } from '../../types'; -import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingCore, ReportingSetupDeps } from '../types'; import { registerJobGenerationRoutes } from './generation'; import { registerJobInfoRoutes } from './jobs'; export function registerRoutes( reporting: ReportingCore, - config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - registerJobGenerationRoutes(reporting, config, server, plugins, logger); - registerJobInfoRoutes(reporting, config, server, plugins, logger); + registerJobGenerationRoutes(reporting, server, plugins, logger); + registerJobInfoRoutes(reporting, server, plugins, logger); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js index b12aa44487523..071b401d2321b 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js @@ -5,6 +5,7 @@ */ import Hapi from 'hapi'; +import { memoize } from 'lodash'; import { createMockReportingCore } from '../../test_helpers'; import { ExportTypesRegistry } from '../lib/export_types_registry'; @@ -22,7 +23,6 @@ import { registerJobInfoRoutes } from './jobs'; let mockServer; let exportTypesRegistry; let mockReportingPlugin; -let mockReportingConfig; const mockLogger = { error: jest.fn(), debug: jest.fn(), @@ -30,6 +30,7 @@ const mockLogger = { beforeEach(async () => { mockServer = new Hapi.Server({ debug: false, port: 8080, routes: { log: { collect: true } } }); + mockServer.config = memoize(() => ({ get: jest.fn() })); exportTypesRegistry = new ExportTypesRegistry(); exportTypesRegistry.register({ id: 'unencoded', @@ -42,11 +43,8 @@ beforeEach(async () => { jobContentEncoding: 'base64', jobContentExtension: 'pdf', }); - mockReportingPlugin = await createMockReportingCore(); mockReportingPlugin.getExportTypesRegistry = () => exportTypesRegistry; - - mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; }); const mockPlugins = { @@ -72,13 +70,7 @@ test(`returns 404 if job not found`, async () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(getHits())), }; - registerJobInfoRoutes( - mockReportingPlugin, - mockReportingConfig, - mockServer, - mockPlugins, - mockLogger - ); + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); const request = { method: 'GET', @@ -97,13 +89,7 @@ test(`returns 401 if not valid job type`, async () => { .mockReturnValue(Promise.resolve(getHits({ jobtype: 'invalidJobType' }))), }; - registerJobInfoRoutes( - mockReportingPlugin, - mockReportingConfig, - mockServer, - mockPlugins, - mockLogger - ); + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); const request = { method: 'GET', @@ -124,13 +110,7 @@ describe(`when job is incomplete`, () => { ), }; - registerJobInfoRoutes( - mockReportingPlugin, - mockReportingConfig, - mockServer, - mockPlugins, - mockLogger - ); + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); const request = { method: 'GET', @@ -172,13 +152,7 @@ describe(`when job is failed`, () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), }; - registerJobInfoRoutes( - mockReportingPlugin, - mockReportingConfig, - mockServer, - mockPlugins, - mockLogger - ); + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); const request = { method: 'GET', @@ -223,13 +197,7 @@ describe(`when job is completed`, () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), }; - registerJobInfoRoutes( - mockReportingPlugin, - mockReportingConfig, - mockServer, - mockPlugins, - mockLogger - ); + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); const request = { method: 'GET', diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts index 4f29e561431fa..b9aa75e0ddd00 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts @@ -17,7 +17,7 @@ import { ServerFacade, } from '../../types'; import { jobsQueryFactory } from '../lib/jobs_query'; -import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingSetupDeps, ReportingCore } from '../types'; import { deleteJobResponseHandlerFactory, downloadJobResponseHandlerFactory, @@ -37,14 +37,13 @@ function isResponse(response: Boom | ResponseObject): response is Response export function registerJobInfoRoutes( reporting: ReportingCore, - config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { const { elasticsearch } = plugins; - const jobsQuery = jobsQueryFactory(config, elasticsearch); - const getRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); + const jobsQuery = jobsQueryFactory(server, elasticsearch); + const getRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); // list jobs in the queue, paginated server.route({ @@ -142,8 +141,8 @@ export function registerJobInfoRoutes( // trigger a download of the output from a job const exportTypesRegistry = reporting.getExportTypesRegistry(); - const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(config, plugins, logger); - const downloadResponseHandler = downloadJobResponseHandlerFactory(config, elasticsearch, exportTypesRegistry); // prettier-ignore + const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger); + const downloadResponseHandler = downloadJobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); // prettier-ignore server.route({ path: `${MAIN_ENTRY}/download/{docId}`, method: 'GET', @@ -182,8 +181,8 @@ export function registerJobInfoRoutes( }); // allow a report to be deleted - const getRouteConfigDelete = getRouteConfigFactoryDeletePre(config, plugins, logger); - const deleteResponseHandler = deleteJobResponseHandlerFactory(config, elasticsearch); + const getRouteConfigDelete = getRouteConfigFactoryDeletePre(server, plugins, logger); + const deleteResponseHandler = deleteJobResponseHandlerFactory(server, elasticsearch); server.route({ path: `${MAIN_ENTRY}/delete/{docId}`, method: 'DELETE', diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js index b5d6ae59ce5dd..3460d22592e3d 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js @@ -7,48 +7,56 @@ import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; describe('authorized_user_pre_routing', function() { - const createMockConfig = (mockConfig = {}) => { - return { - get: (...keys) => mockConfig[keys.join('.')], - kbnConfig: { get: (...keys) => mockConfig[keys.join('.')] }, - }; - }; - const createMockPlugins = (function() { + // the getClientShield is using `once` which forces us to use a constant mock + // which makes testing anything that is dependent on `oncePerServer` confusing. + // so createMockServer reuses the same 'instance' of the server and overwrites + // the properties to contain different values + const createMockServer = (function() { const getUserStub = jest.fn(); + let mockConfig; + + const mockServer = { + expose() {}, + config() { + return { + get(key) { + return mockConfig[key]; + }, + }; + }, + log: function() {}, + plugins: { + xpack_main: {}, + security: { getUser: getUserStub }, + }, + }; return function({ securityEnabled = true, xpackInfoUndefined = false, xpackInfoAvailable = true, - getCurrentUser = undefined, user = undefined, + config = {}, }) { - getUserStub.mockReset(); - getUserStub.mockResolvedValue(user); - return { - security: securityEnabled - ? { - authc: { getCurrentUser }, - } - : null, - __LEGACY: { - plugins: { - xpack_main: { - info: !xpackInfoUndefined && { + mockConfig = config; + + mockServer.plugins.xpack_main = { + info: !xpackInfoUndefined && { + isAvailable: () => xpackInfoAvailable, + feature(featureName) { + if (featureName === 'security') { + return { + isEnabled: () => securityEnabled, isAvailable: () => xpackInfoAvailable, - feature(featureName) { - if (featureName === 'security') { - return { - isEnabled: () => securityEnabled, - isAvailable: () => xpackInfoAvailable, - }; - } - }, - }, - }, + }; + } }, }, }; + + getUserStub.mockReset(); + getUserStub.mockResolvedValue(user); + return mockServer; }; })(); @@ -67,6 +75,10 @@ describe('authorized_user_pre_routing', function() { raw: { req: mockRequestRaw }, }); + const getMockPlugins = pluginSet => { + return pluginSet || { security: null }; + }; + const getMockLogger = () => ({ warn: jest.fn(), error: msg => { @@ -75,9 +87,11 @@ describe('authorized_user_pre_routing', function() { }); it('should return with boom notFound when xpackInfo is undefined', async function() { + const mockServer = createMockServer({ xpackInfoUndefined: true }); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - createMockConfig(), - createMockPlugins({ xpackInfoUndefined: true }), + mockServer, + getMockPlugins(), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -86,9 +100,11 @@ describe('authorized_user_pre_routing', function() { }); it(`should return with boom notFound when xpackInfo isn't available`, async function() { + const mockServer = createMockServer({ xpackInfoAvailable: false }); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - createMockConfig(), - createMockPlugins({ xpackInfoAvailable: false }), + mockServer, + getMockPlugins(), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -97,9 +113,11 @@ describe('authorized_user_pre_routing', function() { }); it('should return with null user when security is disabled in Elasticsearch', async function() { + const mockServer = createMockServer({ securityEnabled: false }); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - createMockConfig(), - createMockPlugins({ securityEnabled: false }), + mockServer, + getMockPlugins(), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -107,14 +125,16 @@ describe('authorized_user_pre_routing', function() { }); it('should return with boom unauthenticated when security is enabled but no authenticated user', async function() { - const mockPlugins = createMockPlugins({ + const mockServer = createMockServer({ user: null, config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, }); - mockPlugins.security = { authc: { getCurrentUser: () => null } }; + const mockPlugins = getMockPlugins({ + security: { authc: { getCurrentUser: () => null } }, + }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - createMockConfig(), + mockServer, mockPlugins, getMockLogger() ); @@ -124,14 +144,16 @@ describe('authorized_user_pre_routing', function() { }); it(`should return with boom forbidden when security is enabled but user doesn't have allowed role`, async function() { - const mockConfig = createMockConfig({ 'roles.allow': ['.reporting_user'] }); - const mockPlugins = createMockPlugins({ + const mockServer = createMockServer({ user: { roles: [] }, - getCurrentUser: () => ({ roles: ['something_else'] }), + config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, + }); + const mockPlugins = getMockPlugins({ + security: { authc: { getCurrentUser: () => ({ roles: ['something_else'] }) } }, }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockConfig, + mockServer, mockPlugins, getMockLogger() ); @@ -142,14 +164,18 @@ describe('authorized_user_pre_routing', function() { it('should return with user when security is enabled and user has explicitly allowed role', async function() { const user = { roles: ['.reporting_user', 'something_else'] }; - const mockConfig = createMockConfig({ 'roles.allow': ['.reporting_user'] }); - const mockPlugins = createMockPlugins({ + const mockServer = createMockServer({ user, - getCurrentUser: () => ({ roles: ['.reporting_user', 'something_else'] }), + config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, + }); + const mockPlugins = getMockPlugins({ + security: { + authc: { getCurrentUser: () => ({ roles: ['.reporting_user', 'something_else'] }) }, + }, }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockConfig, + mockServer, mockPlugins, getMockLogger() ); @@ -159,13 +185,16 @@ describe('authorized_user_pre_routing', function() { it('should return with user when security is enabled and user has superuser role', async function() { const user = { roles: ['superuser', 'something_else'] }; - const mockConfig = createMockConfig({ 'roles.allow': [] }); - const mockPlugins = createMockPlugins({ - getCurrentUser: () => ({ roles: ['superuser', 'something_else'] }), + const mockServer = createMockServer({ + user, + config: { 'xpack.reporting.roles.allow': [] }, + }); + const mockPlugins = getMockPlugins({ + security: { authc: { getCurrentUser: () => ({ roles: ['superuser', 'something_else'] }) } }, }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockConfig, + mockServer, mockPlugins, getMockLogger() ); diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts index 1ca28ca62a7f2..c5f8c78016f61 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts @@ -7,8 +7,7 @@ import Boom from 'boom'; import { Legacy } from 'kibana'; import { AuthenticatedUser } from '../../../../../../plugins/security/server'; -import { ReportingConfig } from '../../../server'; -import { Logger } from '../../../types'; +import { Logger, ServerFacade } from '../../../types'; import { getUserFactory } from '../../lib/get_user'; import { ReportingSetupDeps } from '../../types'; @@ -19,14 +18,16 @@ export type PreRoutingFunction = ( ) => Promise | AuthenticatedUser | null>; export const authorizedUserPreRoutingFactory = function authorizedUserPreRoutingFn( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - const getUser = getUserFactory(plugins.security, logger); - const { info: xpackInfo } = plugins.__LEGACY.plugins.xpack_main; + const getUser = getUserFactory(server, plugins.security); + const config = server.config(); return async function authorizedUserPreRouting(request: Legacy.Request) { + const xpackInfo = server.plugins.xpack_main.info; + if (!xpackInfo || !xpackInfo.isAvailable()) { logger.warn('Unable to authorize user before xpack info is available.', [ 'authorizedUserPreRouting', @@ -45,7 +46,10 @@ export const authorizedUserPreRoutingFactory = function authorizedUserPreRouting return Boom.unauthorized(`Sorry, you aren't authenticated`); } - const authorizedRoles = [superuserRole, ...(config.get('roles', 'allow') as string[])]; + const authorizedRoles = [ + superuserRole, + ...(config.get('xpack.reporting.roles.allow') as string[]), + ]; if (!user.roles.find(role => authorizedRoles.includes(role))) { return Boom.forbidden(`Sorry, you don't have access to Reporting`); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts index aef37754681ec..fb3944ea33552 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -8,7 +8,13 @@ import contentDisposition from 'content-disposition'; import * as _ from 'lodash'; import { CSV_JOB_TYPE } from '../../../common/constants'; -import { ExportTypeDefinition, ExportTypesRegistry, JobDocOutput, JobSource } from '../../../types'; +import { + ExportTypeDefinition, + ExportTypesRegistry, + JobDocOutput, + JobSource, + ServerFacade, +} from '../../../types'; interface ICustomHeaders { [x: string]: any; @@ -16,15 +22,9 @@ interface ICustomHeaders { type ExportTypeType = ExportTypeDefinition; -interface ErrorFromPayload { - message: string; - reason: string | null; -} - -// A camelCase version of JobDocOutput interface Payload { statusCode: number; - content: string | Buffer | ErrorFromPayload; + content: any; contentType: string; headers: Record; } @@ -48,17 +48,20 @@ const getReportingHeaders = (output: JobDocOutput, exportType: ExportTypeType) = return metaDataHeaders; }; -export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegistry) { - function encodeContent(content: string | null, exportType: ExportTypeType): Buffer | string { +export function getDocumentPayloadFactory( + server: ServerFacade, + exportTypesRegistry: ExportTypesRegistry +) { + function encodeContent(content: string | null, exportType: ExportTypeType) { switch (exportType.jobContentEncoding) { case 'base64': - return content ? Buffer.from(content, 'base64') : ''; // convert null to empty string + return content ? Buffer.from(content, 'base64') : content; // Buffer.from rejects null default: - return content ? content : ''; // convert null to empty string + return content; } } - function getCompleted(output: JobDocOutput, jobType: string, title: string): Payload { + function getCompleted(output: JobDocOutput, jobType: string, title: string) { const exportType = exportTypesRegistry.get((item: ExportTypeType) => item.jobType === jobType); const filename = getTitle(exportType, title); const headers = getReportingHeaders(output, exportType); @@ -74,7 +77,7 @@ export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegist }; } - function getFailure(output: JobDocOutput): Payload { + function getFailure(output: JobDocOutput) { return { statusCode: 500, content: { diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts index e7e7c866db96a..30627d5b23230 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -5,12 +5,11 @@ */ import Boom from 'boom'; -import { ResponseToolkit } from 'hapi'; import { ElasticsearchServiceSetup } from 'kibana/server'; +import { ResponseToolkit } from 'hapi'; import { WHITELISTED_JOB_CONTENT_TYPES } from '../../../common/constants'; -import { ExportTypesRegistry } from '../../../types'; +import { ExportTypesRegistry, ServerFacade } from '../../../types'; import { jobsQueryFactory } from '../../lib/jobs_query'; -import { ReportingConfig } from '../../types'; import { getDocumentPayloadFactory } from './get_document_payload'; interface JobResponseHandlerParams { @@ -22,12 +21,12 @@ interface JobResponseHandlerOpts { } export function downloadJobResponseHandlerFactory( - config: ReportingConfig, + server: ServerFacade, elasticsearch: ElasticsearchServiceSetup, exportTypesRegistry: ExportTypesRegistry ) { - const jobsQuery = jobsQueryFactory(config, elasticsearch); - const getDocumentPayload = getDocumentPayloadFactory(exportTypesRegistry); + const jobsQuery = jobsQueryFactory(server, elasticsearch); + const getDocumentPayload = getDocumentPayloadFactory(server, exportTypesRegistry); return function jobResponseHandler( validJobTypes: string[], @@ -71,10 +70,10 @@ export function downloadJobResponseHandlerFactory( } export function deleteJobResponseHandlerFactory( - config: ReportingConfig, + server: ServerFacade, elasticsearch: ElasticsearchServiceSetup ) { - const jobsQuery = jobsQueryFactory(config, elasticsearch); + const jobsQuery = jobsQueryFactory(server, elasticsearch); return async function deleteJobResponseHander( validJobTypes: string[], diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts index 8a79566aafae2..9e618ff1fe40a 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts @@ -6,17 +6,17 @@ import Boom from 'boom'; import { Legacy } from 'kibana'; -import { Logger } from '../../../types'; -import { ReportingConfig, ReportingSetupDeps } from '../../types'; +import { Logger, ServerFacade } from '../../../types'; +import { ReportingSetupDeps } from '../../types'; export type GetReportingFeatureIdFn = (request: Legacy.Request) => string; export const reportingFeaturePreRoutingFactory = function reportingFeaturePreRoutingFn( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - const xpackMainPlugin = plugins.__LEGACY.plugins.xpack_main; + const xpackMainPlugin = server.plugins.xpack_main; const pluginId = 'reporting'; // License checking and enable/disable logic diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts index 06f7efaa9dcbb..3d275d34e2f7d 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts @@ -6,8 +6,8 @@ import Joi from 'joi'; import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; -import { Logger } from '../../../types'; -import { ReportingConfig, ReportingSetupDeps } from '../../types'; +import { Logger, ServerFacade } from '../../../types'; +import { ReportingSetupDeps } from '../../types'; import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; import { GetReportingFeatureIdFn, @@ -29,12 +29,12 @@ export type GetRouteConfigFactoryFn = ( ) => RouteConfigFactory; export function getRouteConfigFactoryReportingPre( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(config, plugins, logger); - const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(config, plugins, logger); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(server, plugins, logger); + const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(server, plugins, logger); return (getFeatureId?: GetReportingFeatureIdFn): RouteConfigFactory => { const preRouting: any[] = [{ method: authorizedUserPreRouting, assign: 'user' }]; @@ -50,11 +50,11 @@ export function getRouteConfigFactoryReportingPre( } export function getRouteOptionsCsv( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - const getRouteConfig = getRouteConfigFactoryReportingPre(config, plugins, logger); + const getRouteConfig = getRouteConfigFactoryReportingPre(server, plugins, logger); return { ...getRouteConfig(() => CSV_FROM_SAVEDOBJECT_JOB_TYPE), validate: { @@ -75,12 +75,12 @@ export function getRouteOptionsCsv( } export function getRouteConfigFactoryManagementPre( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(config, plugins, logger); - const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(config, plugins, logger); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(server, plugins, logger); + const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(server, plugins, logger); const managementPreRouting = reportingFeaturePreRouting(() => 'management'); return (): RouteConfigFactory => { @@ -99,11 +99,11 @@ export function getRouteConfigFactoryManagementPre( // Additionally, the range-request doesn't alleviate any performance issues on the server as the entire // download is loaded into memory. export function getRouteConfigFactoryDownloadPre( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const getManagementRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), tags: [API_TAG, 'download'], @@ -114,11 +114,11 @@ export function getRouteConfigFactoryDownloadPre( } export function getRouteConfigFactoryDeletePre( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const getManagementRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), tags: [API_TAG, 'delete'], diff --git a/x-pack/legacy/plugins/reporting/server/types.d.ts b/x-pack/legacy/plugins/reporting/server/types.d.ts index c773e2d556648..59b7bc2020ad9 100644 --- a/x-pack/legacy/plugins/reporting/server/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/types.d.ts @@ -11,17 +11,16 @@ import { PluginStart as DataPluginStart } from '../../../../../src/plugins/data/ import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; import { ReportingPluginSpecOptions } from '../types'; -import { ReportingConfig, ReportingConfigType } from './core'; export interface ReportingSetupDeps { elasticsearch: ElasticsearchServiceSetup; security: SecurityPluginSetup; usageCollection: UsageCollectionSetup; - reporting: { config: ReportingConfig }; __LEGACY: LegacySetup; } export interface ReportingStartDeps { + elasticsearch: ElasticsearchServiceSetup; data: DataPluginStart; __LEGACY: LegacySetup; } @@ -31,7 +30,10 @@ export type ReportingSetup = object; export type ReportingStart = object; export interface LegacySetup { + config: Legacy.Server['config']; + info: Legacy.Server['info']; plugins: { + elasticsearch: Legacy.Server['plugins']['elasticsearch']; xpack_main: XPackMainPlugin & { status?: any; }; @@ -40,7 +42,4 @@ export interface LegacySetup { route: Legacy.Server['route']; } -export { ReportingConfig, ReportingCore } from './core'; - -export type CaptureConfig = ReportingConfigType['capture']; -export type ScrollConfig = ReportingConfigType['csv']['scroll']; +export { ReportingCore } from './core'; diff --git a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts index 5f12f2b7f044d..bd2d0cb835a79 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts @@ -5,10 +5,7 @@ */ import { get } from 'lodash'; -import { ESCallCluster, ExportTypesRegistry } from '../../types'; -import { ReportingConfig, ReportingSetupDeps } from '../types'; -import { decorateRangeStats } from './decorate_range_stats'; -import { getExportTypesHandler } from './get_export_type_handler'; +import { ServerFacade, ExportTypesRegistry, ESCallCluster } from '../../types'; import { AggregationBuckets, AggregationResults, @@ -18,6 +15,8 @@ import { RangeAggregationResults, RangeStats, } from './types'; +import { decorateRangeStats } from './decorate_range_stats'; +import { getExportTypesHandler } from './get_export_type_handler'; const JOB_TYPES_KEY = 'jobTypes'; const JOB_TYPES_FIELD = 'jobtype'; @@ -80,7 +79,10 @@ type RangeStatSets = Partial< last7Days: RangeStats; } >; -async function handleResponse(response: AggregationResults): Promise { +async function handleResponse( + server: ServerFacade, + response: AggregationResults +): Promise { const buckets = get(response, 'aggregations.ranges.buckets'); if (!buckets) { return {}; @@ -99,12 +101,12 @@ async function handleResponse(response: AggregationResults): Promise handleResponse(response)) + .then((response: AggregationResults) => handleResponse(server, response)) .then((usage: RangeStatSets) => { // Allow this to explicitly throw an exception if/when this config is deprecated, // because we shouldn't collect browserType in that case! - const browserType = config.get('capture', 'browser', 'type'); + const browserType = config.get('xpack.reporting.capture.browser.type'); + const xpackInfo = server.plugins.xpack_main.info; const exportTypesHandler = getExportTypesHandler(exportTypesRegistry); - const availability = exportTypesHandler.getAvailability( - xpackMainInfo - ) as FeatureAvailabilityMap; + const availability = exportTypesHandler.getAvailability(xpackInfo) as FeatureAvailabilityMap; const { lastDay, last7Days, ...all } = usage; diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js index 905d2fe9b995c..a6d753f9b107a 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js @@ -24,52 +24,62 @@ function getMockUsageCollection() { makeUsageCollector: options => { return new MockUsageCollector(this, options); }, - registerCollector: sinon.stub(), }; } -function getPluginsMock( - { license, usageCollection = getMockUsageCollection() } = { license: 'platinum' } -) { - const mockXpackMain = { - info: { - isAvailable: sinon.stub().returns(true), - feature: () => ({ - getLicenseCheckResults: sinon.stub(), - }), - license: { - isOneOf: sinon.stub().returns(false), - getType: sinon.stub().returns(license), +function getServerMock(customization) { + const getLicenseCheckResults = sinon.stub().returns({}); + const defaultServerMock = { + plugins: { + security: { + isAuthenticated: sinon.stub().returns(true), }, - toJSON: () => ({ b: 1 }), - }, - }; - return { - usageCollection, - __LEGACY: { - plugins: { - xpack_main: mockXpackMain, + xpack_main: { + info: { + isAvailable: sinon.stub().returns(true), + feature: () => ({ + getLicenseCheckResults, + }), + license: { + isOneOf: sinon.stub().returns(false), + getType: sinon.stub().returns('platinum'), + }, + toJSON: () => ({ b: 1 }), + }, }, }, + log: () => {}, + config: () => ({ + get: key => { + if (key === 'xpack.reporting.enabled') { + return true; + } else if (key === 'xpack.reporting.index') { + return '.reporting-index'; + } + }, + }), }; + return Object.assign(defaultServerMock, customization); } const getResponseMock = (customization = {}) => customization; describe('license checks', () => { - let mockConfig; - beforeAll(async () => { - const mockReporting = await createMockReportingCore(); - mockConfig = await mockReporting.getConfig(); - }); - describe('with a basic license', () => { let usageStats; beforeAll(async () => { - const plugins = getPluginsMock({ license: 'basic' }); + const serverWithBasicLicenseMock = getServerMock(); + serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = sinon + .stub() + .returns('basic'); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); - usageStats = await fetch(callClusterMock, exportTypesRegistry); + const usageCollection = getMockUsageCollection(); + const { fetch: getReportingUsage } = getReportingUsageCollector( + serverWithBasicLicenseMock, + usageCollection, + exportTypesRegistry + ); + usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -88,10 +98,18 @@ describe('license checks', () => { describe('with no license', () => { let usageStats; beforeAll(async () => { - const plugins = getPluginsMock({ license: 'none' }); + const serverWithNoLicenseMock = getServerMock(); + serverWithNoLicenseMock.plugins.xpack_main.info.license.getType = sinon + .stub() + .returns('none'); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); - usageStats = await fetch(callClusterMock, exportTypesRegistry); + const usageCollection = getMockUsageCollection(); + const { fetch: getReportingUsage } = getReportingUsageCollector( + serverWithNoLicenseMock, + usageCollection, + exportTypesRegistry + ); + usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -110,10 +128,18 @@ describe('license checks', () => { describe('with platinum license', () => { let usageStats; beforeAll(async () => { - const plugins = getPluginsMock({ license: 'platinum' }); + const serverWithPlatinumLicenseMock = getServerMock(); + serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = sinon + .stub() + .returns('platinum'); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); - usageStats = await fetch(callClusterMock, exportTypesRegistry); + const usageCollection = getMockUsageCollection(); + const { fetch: getReportingUsage } = getReportingUsageCollector( + serverWithPlatinumLicenseMock, + usageCollection, + exportTypesRegistry + ); + usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -132,10 +158,18 @@ describe('license checks', () => { describe('with no usage data', () => { let usageStats; beforeAll(async () => { - const plugins = getPluginsMock({ license: 'basic' }); + const serverWithBasicLicenseMock = getServerMock(); + serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = sinon + .stub() + .returns('basic'); const callClusterMock = jest.fn(() => Promise.resolve({})); - const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); - usageStats = await fetch(callClusterMock, exportTypesRegistry); + const usageCollection = getMockUsageCollection(); + const { fetch: getReportingUsage } = getReportingUsageCollector( + serverWithBasicLicenseMock, + usageCollection, + exportTypesRegistry + ); + usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -149,11 +183,21 @@ describe('license checks', () => { }); describe('data modeling', () => { + let getReportingUsage; + beforeAll(async () => { + const usageCollection = getMockUsageCollection(); + const serverWithPlatinumLicenseMock = getServerMock(); + serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = sinon + .stub() + .returns('platinum'); + ({ fetch: getReportingUsage } = getReportingUsageCollector( + serverWithPlatinumLicenseMock, + usageCollection, + exportTypesRegistry + )); + }); + test('with normal looking usage data', async () => { - const mockReporting = await createMockReportingCore(); - const mockConfig = await mockReporting.getConfig(); - const plugins = getPluginsMock(); - const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); const callClusterMock = jest.fn(() => Promise.resolve( getResponseMock({ @@ -276,7 +320,7 @@ describe('data modeling', () => { ) ); - const usageStats = await fetch(callClusterMock); + const usageStats = await getReportingUsage(callClusterMock); expect(usageStats).toMatchInlineSnapshot(` Object { "PNG": Object { @@ -371,16 +415,20 @@ describe('data modeling', () => { }); describe('Ready for collection observable', () => { - test('converts observable to promise', async () => { - const mockReporting = await createMockReportingCore(); - const mockConfig = await mockReporting.getConfig(); + let mockReporting; - const usageCollection = getMockUsageCollection(); - const makeCollectorSpy = sinon.spy(); - usageCollection.makeUsageCollector = makeCollectorSpy; + beforeEach(async () => { + mockReporting = await createMockReportingCore(); + }); - const plugins = getPluginsMock({ usageCollection }); - registerReportingUsageCollector(mockReporting, mockConfig, plugins); + test('converts observable to promise', async () => { + const serverWithBasicLicenseMock = getServerMock(); + const makeCollectorSpy = sinon.spy(); + const usageCollection = { + makeUsageCollector: makeCollectorSpy, + registerCollector: sinon.stub(), + }; + registerReportingUsageCollector(mockReporting, serverWithBasicLicenseMock, usageCollection); const [args] = makeCollectorSpy.firstCall.args; expect(args).toMatchInlineSnapshot(` diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts index ab4ec3a0edf57..14202530fb6c7 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { KIBANA_REPORTING_TYPE } from '../../common/constants'; -import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../../server/types'; -import { ESCallCluster, ExportTypesRegistry } from '../../types'; +import { ReportingCore } from '../../server'; +import { ESCallCluster, ExportTypesRegistry, ServerFacade } from '../../types'; import { getReportingUsage } from './get_reporting_usage'; import { RangeStats } from './types'; @@ -14,19 +15,19 @@ import { RangeStats } from './types'; const METATYPE = 'kibana_stats'; /* + * @param {Object} server * @return {Object} kibana usage stats type collection object */ export function getReportingUsageCollector( - config: ReportingConfig, - plugins: ReportingSetupDeps, + server: ServerFacade, + usageCollection: UsageCollectionSetup, exportTypesRegistry: ExportTypesRegistry, isReady: () => Promise ) { - const { usageCollection } = plugins; return usageCollection.makeUsageCollector({ type: KIBANA_REPORTING_TYPE, fetch: (callCluster: ESCallCluster) => - getReportingUsage(config, plugins, callCluster, exportTypesRegistry), + getReportingUsage(server, callCluster, exportTypesRegistry), isReady, /* @@ -51,17 +52,17 @@ export function getReportingUsageCollector( export function registerReportingUsageCollector( reporting: ReportingCore, - config: ReportingConfig, - plugins: ReportingSetupDeps + server: ServerFacade, + usageCollection: UsageCollectionSetup ) { const exportTypesRegistry = reporting.getExportTypesRegistry(); const collectionIsReady = reporting.pluginHasStarted.bind(reporting); const collector = getReportingUsageCollector( - config, - plugins, + server, + usageCollection, exportTypesRegistry, collectionIsReady ); - plugins.usageCollection.registerCollector(collector); + usageCollection.registerCollector(collector); } diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts index 930aa7601b8cb..883276d43e27e 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts @@ -10,8 +10,7 @@ import * as contexts from '../export_types/common/lib/screenshots/constants'; import { ElementsPositionAndAttribute } from '../export_types/common/lib/screenshots/types'; import { HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from '../server/browsers'; import { createDriverFactory } from '../server/browsers/chromium'; -import { CaptureConfig } from '../server/types'; -import { Logger } from '../types'; +import { BrowserConfig, CaptureConfig, Logger } from '../types'; interface CreateMockBrowserDriverFactoryOpts { evaluate: jest.Mock, any[]>; @@ -94,34 +93,24 @@ export const createMockBrowserDriverFactory = async ( logger: Logger, opts: Partial ): Promise => { - const captureConfig = { - timeouts: { openUrl: 30000, waitForElements: 30000, renderComplete: 30000 }, - browser: { - type: 'chromium', - chromium: { - inspect: false, - disableSandbox: false, - userDataDir: '/usr/data/dir', - viewport: { width: 12, height: 12 }, - proxy: { enabled: false, server: undefined, bypass: undefined }, - }, - autoDownload: false, - inspect: true, - userDataDir: '/usr/data/dir', - viewport: { width: 12, height: 12 }, - disableSandbox: false, - proxy: { enabled: false, server: undefined, bypass: undefined }, - maxScreenshotDimension: undefined, - }, - networkPolicy: { enabled: true, rules: [] }, - viewport: { width: 800, height: 600 }, - loadDelay: 2000, - zoom: 1, - maxAttempts: 1, - } as CaptureConfig; + const browserConfig = { + inspect: true, + userDataDir: '/usr/data/dir', + viewport: { width: 12, height: 12 }, + disableSandbox: false, + proxy: { enabled: false }, + } as BrowserConfig; const binaryPath = '/usr/local/share/common/secure/'; - const mockBrowserDriverFactory = await createDriverFactory(binaryPath, logger, captureConfig); + const captureConfig = { networkPolicy: {}, timeouts: {} } as CaptureConfig; + + const mockBrowserDriverFactory = await createDriverFactory( + binaryPath, + logger, + browserConfig, + captureConfig + ); + const mockPage = {} as Page; const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { inspect: true, diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts index be60b56dcc0c1..0250e6c0a9afd 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LayoutTypes } from '../export_types/common/constants'; import { createLayout } from '../export_types/common/layouts'; +import { LayoutTypes } from '../export_types/common/constants'; import { LayoutInstance } from '../export_types/common/layouts/layout'; -import { CaptureConfig } from '../server/types'; +import { ServerFacade } from '../types'; -export const createMockLayoutInstance = (captureConfig: CaptureConfig) => { - const mockLayout = createLayout(captureConfig, { +export const createMockLayoutInstance = (__LEGACY: ServerFacade) => { + const mockLayout = createLayout(__LEGACY, { id: LayoutTypes.PRESERVE_LAYOUT, dimensions: { height: 12, width: 12 }, }) as LayoutInstance; diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts index 332b37b58cb7d..2cd129d47b3f9 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts @@ -19,24 +19,16 @@ import { coreMock } from 'src/core/server/mocks'; import { ReportingPlugin, ReportingCore } from '../server'; import { ReportingSetupDeps, ReportingStartDeps } from '../server/types'; -const createMockSetupDeps = (setupMock?: any): ReportingSetupDeps => { - const configGetStub = jest.fn(); - return { - elasticsearch: setupMock.elasticsearch, - security: setupMock.security, - usageCollection: {} as any, - reporting: { - config: { - get: configGetStub, - kbnConfig: { get: configGetStub }, - }, - }, - __LEGACY: { plugins: { xpack_main: { status: new EventEmitter() } } } as any, - }; -}; +export const createMockSetupDeps = (setupMock?: any): ReportingSetupDeps => ({ + elasticsearch: setupMock.elasticsearch, + security: setupMock.security, + usageCollection: {} as any, + __LEGACY: { plugins: { xpack_main: { status: new EventEmitter() } } } as any, +}); export const createMockStartDeps = (startMock?: any): ReportingStartDeps => ({ data: startMock.data, + elasticsearch: startMock.elasticsearch, __LEGACY: {} as any, }); diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts index 531e1dcaf84e0..bb7851ba036a9 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts @@ -3,10 +3,36 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import { memoize } from 'lodash'; import { ServerFacade } from '../types'; -export const createMockServer = (): ServerFacade => { - const mockServer = {}; - return mockServer as any; +export const createMockServer = ({ settings = {} }: any): ServerFacade => { + const mockServer = { + config: memoize(() => ({ get: jest.fn() })), + info: { + protocol: 'http', + }, + plugins: { + elasticsearch: { + getCluster: memoize(() => { + return { + callWithRequest: jest.fn(), + }; + }), + }, + }, + }; + + const defaultSettings: any = { + 'xpack.reporting.encryptionKey': 'testencryptionkey', + 'server.basePath': '/sbp', + 'server.host': 'localhost', + 'server.port': 5601, + 'xpack.reporting.kibanaServer': {}, + }; + mockServer.config().get.mockImplementation((key: any) => { + return key in settings ? settings[key] : defaultSettings[key]; + }); + + return (mockServer as unknown) as ServerFacade; }; diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts index 76253752be1b7..238079ba92a29 100644 --- a/x-pack/legacy/plugins/reporting/types.d.ts +++ b/x-pack/legacy/plugins/reporting/types.d.ts @@ -7,11 +7,14 @@ import { EventEmitter } from 'events'; import { ResponseObject } from 'hapi'; import { Legacy } from 'kibana'; +import { ElasticsearchServiceSetup } from 'kibana/server'; import { CallCluster } from '../../../../src/legacy/core_plugins/elasticsearch'; import { CancellationToken } from './common/cancellation_token'; -import { ReportingCore } from './server/core'; +import { HeadlessChromiumDriverFactory } from './server/browsers/chromium/driver_factory'; +import { BrowserType } from './server/browsers/types'; import { LevelLogger } from './server/lib/level_logger'; -import { LegacySetup } from './server/types'; +import { ReportingCore } from './server/core'; +import { LegacySetup, ReportingStartDeps, ReportingSetup, ReportingStart } from './server/types'; export type Job = EventEmitter & { id: string; @@ -22,8 +25,8 @@ export type Job = EventEmitter & { export interface NetworkPolicyRule { allow: boolean; - protocol?: string; - host?: string; + protocol: string; + host: string; } export interface NetworkPolicy { @@ -90,6 +93,51 @@ export type ReportingResponseToolkit = Legacy.ResponseToolkit; export type ESCallCluster = CallCluster; +/* + * Reporting Config + */ + +export interface CaptureConfig { + browser: { + type: BrowserType; + autoDownload: boolean; + chromium: BrowserConfig; + }; + maxAttempts: number; + networkPolicy: NetworkPolicy; + loadDelay: number; + timeouts: { + openUrl: number; + waitForElements: number; + renderComplet: number; + }; +} + +export interface BrowserConfig { + inspect: boolean; + userDataDir: string; + viewport: { width: number; height: number }; + disableSandbox: boolean; + proxy: { + enabled: boolean; + server: string; + bypass?: string[]; + }; +} + +export interface QueueConfig { + indexInterval: string; + pollEnabled: boolean; + pollInterval: number; + pollIntervalErrorMultiplier: number; + timeout: number; +} + +export interface ScrollConfig { + duration: string; + size: number; +} + export interface ElementPosition { boundingClientRect: { // modern browsers support x/y, but older ones don't @@ -226,10 +274,14 @@ export interface ESQueueInstance { export type CreateJobFactory = ( reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, logger: LevelLogger -) => Promise; +) => CreateJobFnType; export type ExecuteJobFactory = ( reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, logger: LevelLogger ) => Promise; diff --git a/x-pack/plugins/reporting/config.ts b/x-pack/plugins/reporting/config.ts new file mode 100644 index 0000000000000..f1d6b1a8f248f --- /dev/null +++ b/x-pack/plugins/reporting/config.ts @@ -0,0 +1,10 @@ +/* + * 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 reportingPollConfig = { + jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, + jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, +}; diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index d330eb9b7872a..a7e2bd288f0b1 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -1,11 +1,7 @@ { - "configPath": [ "xpack", "reporting" ], "id": "reporting", "version": "8.0.0", "kibanaVersion": "kibana", - "optionalPlugins": [ - "usageCollection" - ], "requiredPlugins": [ "home", "management", @@ -15,6 +11,6 @@ "share", "kibanaLegacy" ], - "server": true, + "server": false, "ui": true } diff --git a/x-pack/plugins/reporting/server/config/index.test.ts b/x-pack/plugins/reporting/server/config/index.test.ts deleted file mode 100644 index 08fe2c5861311..0000000000000 --- a/x-pack/plugins/reporting/server/config/index.test.ts +++ /dev/null @@ -1,122 +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 * as Rx from 'rxjs'; -import { CoreSetup, Logger, PluginInitializerContext } from '../../../../../src/core/server'; -import { createConfig$ } from './'; - -interface KibanaServer { - host?: string; - port?: number; - protocol?: string; -} -interface ReportingKibanaServer { - hostname?: string; - port?: number; - protocol?: string; -} - -const makeMockInitContext = (config: { - encryptionKey?: string; - kibanaServer: ReportingKibanaServer; -}): PluginInitializerContext => - ({ - config: { create: () => Rx.of(config) }, - } as PluginInitializerContext); - -const makeMockCoreSetup = (serverInfo: KibanaServer): CoreSetup => - ({ http: { getServerInfo: () => serverInfo } } as any); - -describe('Reporting server createConfig$', () => { - let mockCoreSetup: CoreSetup; - let mockInitContext: PluginInitializerContext; - let mockLogger: Logger; - - beforeEach(() => { - mockCoreSetup = makeMockCoreSetup({ host: 'kibanaHost', port: 5601, protocol: 'http' }); - mockInitContext = makeMockInitContext({ - kibanaServer: {}, - }); - mockLogger = ({ warn: jest.fn() } as unknown) as Logger; - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('creates random encryption key and default config using host, protocol, and port from server info', async () => { - const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); - - expect(result.encryptionKey).toMatch(/\S{32,}/); - expect(result.kibanaServer).toMatchInlineSnapshot(` - Object { - "hostname": "kibanaHost", - "port": 5601, - "protocol": "http", - } - `); - expect((mockLogger.warn as any).mock.calls.length).toBe(1); - expect((mockLogger.warn as any).mock.calls[0]).toMatchObject([ - 'Generating a random key for xpack.reporting.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.reporting.encryptionKey in kibana.yml', - ]); - }); - - it('uses the encryption key', async () => { - mockInitContext = makeMockInitContext({ - encryptionKey: 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii', - kibanaServer: {}, - }); - const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); - - expect(result.encryptionKey).toMatch('iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii'); - expect((mockLogger.warn as any).mock.calls.length).toBe(0); - }); - - it('uses the encryption key, reporting kibanaServer settings to override server info', async () => { - mockInitContext = makeMockInitContext({ - encryptionKey: 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii', - kibanaServer: { - hostname: 'reportingHost', - port: 5677, - protocol: 'httpsa', - }, - }); - const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); - - expect(result).toMatchInlineSnapshot(` - Object { - "encryptionKey": "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii", - "kibanaServer": Object { - "hostname": "reportingHost", - "port": 5677, - "protocol": "httpsa", - }, - } - `); - expect((mockLogger.warn as any).mock.calls.length).toBe(0); - }); - - it('show warning when kibanaServer.hostName === "0"', async () => { - mockInitContext = makeMockInitContext({ - encryptionKey: 'aaaaaaaaaaaaabbbbbbbbbbbbaaaaaaaaa', - kibanaServer: { hostname: '0' }, - }); - const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); - - expect(result.kibanaServer).toMatchInlineSnapshot(` - Object { - "hostname": "0.0.0.0", - "port": 5601, - "protocol": "http", - } - `); - expect((mockLogger.warn as any).mock.calls.length).toBe(1); - expect((mockLogger.warn as any).mock.calls[0]).toMatchObject([ - `Found 'server.host: \"0\" in Kibana configuration. This is incompatible with Reporting. To enable Reporting to work, 'xpack.reporting.kibanaServer.hostname: 0.0.0.0' is being automatically ` + - `to the configuration. You can change the setting to 'server.host: 0.0.0.0' or add 'xpack.reporting.kibanaServer.hostname: 0.0.0.0' in kibana.yml to prevent this message.`, - ]); - }); -}); diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts deleted file mode 100644 index ac51b39ae23b4..0000000000000 --- a/x-pack/plugins/reporting/server/config/index.ts +++ /dev/null @@ -1,85 +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 { i18n } from '@kbn/i18n/'; -import { TypeOf } from '@kbn/config-schema'; -import crypto from 'crypto'; -import { map } from 'rxjs/operators'; -import { PluginConfigDescriptor } from 'kibana/server'; -import { CoreSetup, Logger, PluginInitializerContext } from '../../../../../src/core/server'; -import { ConfigSchema, ConfigType } from './schema'; - -export function createConfig$(core: CoreSetup, context: PluginInitializerContext, logger: Logger) { - return context.config.create>().pipe( - map(config => { - // encryption key - let encryptionKey = config.encryptionKey; - if (encryptionKey === undefined) { - logger.warn( - i18n.translate('xpack.reporting.serverConfig.randomEncryptionKey', { - defaultMessage: - 'Generating a random key for xpack.reporting.encryptionKey. To prevent sessions from being invalidated on ' + - 'restart, please set xpack.reporting.encryptionKey in kibana.yml', - }) - ); - encryptionKey = crypto.randomBytes(16).toString('hex'); - } - - const { kibanaServer: reportingServer } = config; - const serverInfo = core.http.getServerInfo(); - - // kibanaServer.hostname, default to server.host, don't allow "0" - let kibanaServerHostname = reportingServer.hostname - ? reportingServer.hostname - : serverInfo.host; - if (kibanaServerHostname === '0') { - logger.warn( - i18n.translate('xpack.reporting.serverConfig.invalidServerHostname', { - defaultMessage: - `Found 'server.host: "0" in Kibana configuration. This is incompatible with Reporting. ` + - `To enable Reporting to work, '{configKey}: 0.0.0.0' is being automatically to the configuration. ` + - `You can change the setting to 'server.host: 0.0.0.0' or add '{configKey}: 0.0.0.0' in kibana.yml to prevent this message.`, - values: { configKey: 'xpack.reporting.kibanaServer.hostname' }, - }) - ); - kibanaServerHostname = '0.0.0.0'; - } - - // kibanaServer.port, default to server.port - const kibanaServerPort = reportingServer.port - ? reportingServer.port - : serverInfo.port; // prettier-ignore - - // kibanaServer.protocol, default to server.protocol - const kibanaServerProtocol = reportingServer.protocol - ? reportingServer.protocol - : serverInfo.protocol; - - return { - ...config, - encryptionKey, - kibanaServer: { - hostname: kibanaServerHostname, - port: kibanaServerPort, - protocol: kibanaServerProtocol, - }, - }; - }) - ); -} - -export const config: PluginConfigDescriptor = { - schema: ConfigSchema, - deprecations: ({ unused }) => [ - unused('capture.browser.chromium.maxScreenshotDimension'), - unused('capture.concurrency'), - unused('capture.settleTime'), - unused('capture.timeout'), - unused('kibanaApp'), - ], -}; - -export { ConfigSchema, ConfigType }; diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts deleted file mode 100644 index d8fe6d1ff084a..0000000000000 --- a/x-pack/plugins/reporting/server/config/schema.test.ts +++ /dev/null @@ -1,103 +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 { ConfigSchema } from './schema'; - -describe('Reporting Config Schema', () => { - it(`context {"dev":false,"dist":false} produces correct config`, () => { - expect(ConfigSchema.validate({}, { dev: false, dist: false })).toMatchObject({ - capture: { - browser: { - autoDownload: true, - chromium: { disableSandbox: false, proxy: { enabled: false } }, - type: 'chromium', - }, - loadDelay: 3000, - maxAttempts: 1, - networkPolicy: { - enabled: true, - rules: [ - { allow: true, host: undefined, protocol: 'http:' }, - { allow: true, host: undefined, protocol: 'https:' }, - { allow: true, host: undefined, protocol: 'ws:' }, - { allow: true, host: undefined, protocol: 'wss:' }, - { allow: true, host: undefined, protocol: 'data:' }, - { allow: false, host: undefined, protocol: undefined }, - ], - }, - viewport: { height: 1200, width: 1950 }, - zoom: 2, - }, - csv: { - checkForFormulas: true, - enablePanelActionDownload: true, - maxSizeBytes: 10485760, - scroll: { duration: '30s', size: 500 }, - }, - encryptionKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - index: '.reporting', - kibanaServer: {}, - poll: { - jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, - jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, - }, - queue: { - indexInterval: 'week', - pollEnabled: true, - pollInterval: 3000, - pollIntervalErrorMultiplier: 10, - timeout: 120000, - }, - roles: { allow: ['reporting_user'] }, - }); - }); - it(`context {"dev":false,"dist":true} produces correct config`, () => { - expect(ConfigSchema.validate({}, { dev: false, dist: true })).toMatchObject({ - capture: { - browser: { - autoDownload: false, - chromium: { disableSandbox: false, inspect: false, proxy: { enabled: false } }, - type: 'chromium', - }, - loadDelay: 3000, - maxAttempts: 3, - networkPolicy: { - enabled: true, - rules: [ - { allow: true, host: undefined, protocol: 'http:' }, - { allow: true, host: undefined, protocol: 'https:' }, - { allow: true, host: undefined, protocol: 'ws:' }, - { allow: true, host: undefined, protocol: 'wss:' }, - { allow: true, host: undefined, protocol: 'data:' }, - { allow: false, host: undefined, protocol: undefined }, - ], - }, - viewport: { height: 1200, width: 1950 }, - zoom: 2, - }, - csv: { - checkForFormulas: true, - enablePanelActionDownload: true, - maxSizeBytes: 10485760, - scroll: { duration: '30s', size: 500 }, - }, - index: '.reporting', - kibanaServer: {}, - poll: { - jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, - jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, - }, - queue: { - indexInterval: 'week', - pollEnabled: true, - pollInterval: 3000, - pollIntervalErrorMultiplier: 10, - timeout: 120000, - }, - roles: { allow: ['reporting_user'] }, - }); - }); -}); diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts deleted file mode 100644 index 0058b7a5096f0..0000000000000 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ /dev/null @@ -1,174 +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 { schema, TypeOf } from '@kbn/config-schema'; -import moment from 'moment'; - -const KibanaServerSchema = schema.object({ - hostname: schema.maybe( - schema.string({ - validate(value) { - if (value === '0') { - return 'must not be "0" for the headless browser to correctly resolve the host'; - } - }, - hostname: true, - }) - ), - port: schema.maybe(schema.number()), - protocol: schema.maybe( - schema.string({ - validate(value) { - if (!/^https?$/.test(value)) { - return 'must be "http" or "https"'; - } - }, - }) - ), -}); - -const QueueSchema = schema.object({ - indexInterval: schema.string({ defaultValue: 'week' }), - pollEnabled: schema.boolean({ defaultValue: true }), - pollInterval: schema.number({ defaultValue: 3000 }), - pollIntervalErrorMultiplier: schema.number({ defaultValue: 10 }), - timeout: schema.number({ defaultValue: moment.duration(2, 'm').asMilliseconds() }), -}); - -const RulesSchema = schema.object({ - allow: schema.boolean(), - host: schema.maybe(schema.string()), - protocol: schema.maybe(schema.string()), -}); - -const CaptureSchema = schema.object({ - timeouts: schema.object({ - openUrl: schema.number({ defaultValue: 30000 }), - waitForElements: schema.number({ defaultValue: 30000 }), - renderComplete: schema.number({ defaultValue: 30000 }), - }), - networkPolicy: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - rules: schema.arrayOf(RulesSchema, { - defaultValue: [ - { host: undefined, allow: true, protocol: 'http:' }, - { host: undefined, allow: true, protocol: 'https:' }, - { host: undefined, allow: true, protocol: 'ws:' }, - { host: undefined, allow: true, protocol: 'wss:' }, - { host: undefined, allow: true, protocol: 'data:' }, - { host: undefined, allow: false, protocol: undefined }, // Default action is to deny! - ], - }), - }), - zoom: schema.number({ defaultValue: 2 }), - viewport: schema.object({ - width: schema.number({ defaultValue: 1950 }), - height: schema.number({ defaultValue: 1200 }), - }), - loadDelay: schema.number({ - defaultValue: moment.duration(3, 's').asMilliseconds(), - }), // TODO: use schema.duration - browser: schema.object({ - autoDownload: schema.conditional( - schema.contextRef('dist'), - true, - schema.boolean({ defaultValue: false }), - schema.boolean({ defaultValue: true }) - ), - chromium: schema.object({ - inspect: schema.conditional( - schema.contextRef('dist'), - true, - schema.boolean({ defaultValue: false }), - schema.maybe(schema.never()) - ), - disableSandbox: schema.boolean({ defaultValue: false }), - proxy: schema.object({ - enabled: schema.boolean({ defaultValue: false }), - server: schema.conditional( - schema.siblingRef('enabled'), - true, - schema.uri({ scheme: ['http', 'https'] }), - schema.maybe(schema.never()) - ), - bypass: schema.conditional( - schema.siblingRef('enabled'), - true, - schema.arrayOf(schema.string({ hostname: true })), - schema.maybe(schema.never()) - ), - }), - userDataDir: schema.maybe(schema.string()), // FIXME unused? - }), - type: schema.string({ defaultValue: 'chromium' }), - }), - maxAttempts: schema.conditional( - schema.contextRef('dist'), - true, - schema.number({ defaultValue: 3 }), - schema.number({ defaultValue: 1 }) - ), -}); - -const CsvSchema = schema.object({ - checkForFormulas: schema.boolean({ defaultValue: true }), - enablePanelActionDownload: schema.boolean({ defaultValue: true }), - maxSizeBytes: schema.number({ - defaultValue: 1024 * 1024 * 10, // 10MB - }), // TODO: use schema.byteSize - scroll: schema.object({ - duration: schema.string({ - defaultValue: '30s', - validate(value) { - if (!/^[0-9]+(d|h|m|s|ms|micros|nanos)$/.test(value)) { - return 'must be a duration string'; - } - }, - }), - size: schema.number({ defaultValue: 500 }), - }), -}); - -const EncryptionKeySchema = schema.conditional( - schema.contextRef('dist'), - true, - schema.maybe(schema.string({ minLength: 32 })), - schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) -); - -const RolesSchema = schema.object({ - allow: schema.arrayOf(schema.string(), { defaultValue: ['reporting_user'] }), -}); - -const IndexSchema = schema.string({ defaultValue: '.reporting' }); - -const PollSchema = schema.object({ - jobCompletionNotifier: schema.object({ - interval: schema.number({ - defaultValue: moment.duration(10, 's').asMilliseconds(), - }), // TODO: use schema.duration - intervalErrorMultiplier: schema.number({ defaultValue: 5 }), - }), - jobsRefresh: schema.object({ - interval: schema.number({ - defaultValue: moment.duration(5, 's').asMilliseconds(), - }), // TODO: use schema.duration - intervalErrorMultiplier: schema.number({ defaultValue: 5 }), - }), -}); - -export const ConfigSchema = schema.object({ - kibanaServer: KibanaServerSchema, - queue: QueueSchema, - capture: CaptureSchema, - csv: CsvSchema, - encryptionKey: EncryptionKeySchema, - roles: RolesSchema, - index: IndexSchema, - poll: PollSchema, -}); - -export type ConfigType = TypeOf; diff --git a/x-pack/plugins/reporting/server/index.ts b/x-pack/plugins/reporting/server/index.ts deleted file mode 100644 index 2b1844cf2e10e..0000000000000 --- a/x-pack/plugins/reporting/server/index.ts +++ /dev/null @@ -1,14 +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 { PluginInitializerContext } from 'src/core/server'; -import { ReportingPlugin } from './plugin'; - -export { config, ConfigSchema } from './config'; -export { ConfigType, PluginsSetup } from './plugin'; - -export const plugin = (initializerContext: PluginInitializerContext) => - new ReportingPlugin(initializerContext); diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts deleted file mode 100644 index 53d821cffbb1f..0000000000000 --- a/x-pack/plugins/reporting/server/plugin.ts +++ /dev/null @@ -1,38 +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 { Observable } from 'rxjs'; -import { first } from 'rxjs/operators'; -import { CoreSetup, Logger, Plugin, PluginInitializerContext } from '../../../../src/core/server'; -import { ConfigType, createConfig$ } from './config'; - -export interface PluginsSetup { - /** @deprecated */ - __legacy: { - config$: Observable; - }; -} - -export class ReportingPlugin implements Plugin { - private readonly log: Logger; - - constructor(private readonly initializerContext: PluginInitializerContext) { - this.log = this.initializerContext.logger.get(); - } - - public async setup(core: CoreSetup): Promise { - return { - __legacy: { - config$: createConfig$(core, this.initializerContext, this.log).pipe(first()), - }, - }; - } - - public start() {} - public stop() {} -} - -export { ConfigType }; From b0eb79858f2d9fd42e9608f16a00c4ed068460dc Mon Sep 17 00:00:00 2001 From: Phillip Burch Date: Tue, 24 Mar 2020 11:19:04 -0500 Subject: [PATCH 14/56] Default duration to minutes, only show alerting on metrics explorer (#61058) --- .../infra/public/components/alerting/metrics/expression.tsx | 4 ++-- x-pack/plugins/infra/public/pages/infrastructure/index.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx index ea8dd1484a670..0909a3c2ed569 100644 --- a/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx +++ b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx @@ -70,7 +70,7 @@ export const Expressions: React.FC = props => { const { setAlertParams, alertParams, errors, alertsContext } = props; const { source, createDerivedIndexPattern } = useSource({ sourceId: 'default' }); const [timeSize, setTimeSize] = useState(1); - const [timeUnit, setTimeUnit] = useState('s'); + const [timeUnit, setTimeUnit] = useState('m'); const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ createDerivedIndexPattern, @@ -93,7 +93,7 @@ export const Expressions: React.FC = props => { comparator: '>', threshold: [], timeSize: 1, - timeUnit: 's', + timeUnit: 'm', indexPattern: source?.configuration.metricAlias, }), [source] diff --git a/x-pack/plugins/infra/public/pages/infrastructure/index.tsx b/x-pack/plugins/infra/public/pages/infrastructure/index.tsx index 730f67ab2bdca..422eb53148fe6 100644 --- a/x-pack/plugins/infra/public/pages/infrastructure/index.tsx +++ b/x-pack/plugins/infra/public/pages/infrastructure/index.tsx @@ -91,7 +91,7 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { /> - + From 03877d09ef2f64e795b1bad91604e4a5fc06d371 Mon Sep 17 00:00:00 2001 From: Chris Mark Date: Tue, 24 Mar 2020 18:59:09 +0200 Subject: [PATCH 15/56] [Home][Tutorial] Add Openmetrics data UI (#61073) --- .../tutorial_resources/logos/openmetrics.svg | 1 + .../tutorials/openmetrics_metrics/index.ts | 63 +++++++++++++++++++ src/plugins/home/server/tutorials/register.ts | 2 + 3 files changed, 66 insertions(+) create mode 100644 src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/openmetrics.svg create mode 100644 src/plugins/home/server/tutorials/openmetrics_metrics/index.ts diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/openmetrics.svg b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/openmetrics.svg new file mode 100644 index 0000000000000..feccb88a3f34b --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/openmetrics.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts b/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts new file mode 100644 index 0000000000000..b0ff61c7116ce --- /dev/null +++ b/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import { TutorialsCategory } from '../../services/tutorials'; +import { + onPremInstructions, + cloudInstructions, + onPremCloudInstructions, +} from '../instructions/metricbeat_instructions'; +import { + TutorialContext, + TutorialSchema, +} from '../../services/tutorials/lib/tutorials_registry_types'; + +export function openmetricsMetricsSpecProvider(context: TutorialContext): TutorialSchema { + const moduleName = 'openmetrics'; + return { + id: 'openmetricsMetrics', + name: i18n.translate('home.tutorials.openmetricsMetrics.nameTitle', { + defaultMessage: 'OpenMetrics metrics', + }), + category: TutorialsCategory.METRICS, + shortDescription: i18n.translate('home.tutorials.openmetricsMetrics.shortDescription', { + defaultMessage: 'Fetch metrics from an endpoint that serves metrics in OpenMetrics format.', + }), + longDescription: i18n.translate('home.tutorials.openmetricsMetrics.longDescription', { + defaultMessage: + 'The `openmetrics` Metricbeat module fetches metrics from an endpoint that serves metrics in OpenMetrics format. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-openmetrics.html', + }, + }), + euiIconType: '/plugins/kibana/home/tutorial_resources/logos/openmetrics.svg', + artifacts: { + dashboards: [], + exportedFields: { + documentationUrl: '{config.docs.beats.metricbeat}/exported-fields-openmetrics.html', + }, + }, + completionTimeMinutes: 10, + onPrem: onPremInstructions(moduleName, context), + elasticCloud: cloudInstructions(moduleName), + onPremElasticCloud: onPremCloudInstructions(moduleName), + }; +} diff --git a/src/plugins/home/server/tutorials/register.ts b/src/plugins/home/server/tutorials/register.ts index f1a51018bfd98..b6c56a35554b2 100644 --- a/src/plugins/home/server/tutorials/register.ts +++ b/src/plugins/home/server/tutorials/register.ts @@ -87,6 +87,7 @@ import { envoyproxyMetricsSpecProvider } from './envoyproxy_metrics'; import { ibmmqMetricsSpecProvider } from './ibmmq_metrics'; import { statsdMetricsSpecProvider } from './statsd_metrics'; import { redisenterpriseMetricsSpecProvider } from './redisenterprise_metrics'; +import { openmetricsMetricsSpecProvider } from './openmetrics_metrics'; export const builtInTutorials = [ systemLogsSpecProvider, @@ -160,4 +161,5 @@ export const builtInTutorials = [ envoyproxyMetricsSpecProvider, statsdMetricsSpecProvider, redisenterpriseMetricsSpecProvider, + openmetricsMetricsSpecProvider, ]; From 5ff4c4ca6e4f4f5633329deb821909d9de2ce107 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Tue, 24 Mar 2020 10:11:36 -0700 Subject: [PATCH 16/56] UI changes due to the text review (#61019) * Fixed UI due to text review * Fixed due to comments * Fixed due to review comments --- .../components/builtin_action_types/email.tsx | 17 +- .../builtin_action_types/es_index.tsx | 8 +- .../builtin_action_types/pagerduty.tsx | 10 +- .../builtin_action_types/server_log.tsx | 6 + .../components/builtin_action_types/slack.tsx | 12 +- .../builtin_action_types/webhook.tsx | 18 +- .../threshold/expression.tsx | 295 ++++++++++-------- .../builtin_alert_types/threshold/index.ts | 2 +- .../threshold/visualization.tsx | 4 +- .../public/application/home.tsx | 16 +- .../lib/check_action_type_enabled.test.tsx | 4 +- .../lib/check_action_type_enabled.tsx | 8 +- .../action_connector_form/action_form.tsx | 17 +- .../action_type_menu.test.tsx | 2 +- .../connector_add_flyout.tsx | 25 +- .../connector_add_modal.tsx | 6 +- .../connector_edit_flyout.tsx | 40 ++- .../components/actions_connectors_list.tsx | 12 +- .../components/alert_details.test.tsx | 7 +- .../components/alert_details.tsx | 8 +- .../sections/alert_form/alert_add.tsx | 19 +- .../sections/alert_form/alert_edit.test.tsx | 4 +- .../sections/alert_form/alert_edit.tsx | 39 ++- .../sections/alert_form/alert_form.tsx | 8 +- .../alerts_list/components/alerts_list.tsx | 6 +- 25 files changed, 344 insertions(+), 249 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx index f17180ee74e56..b4bbb8af36a19 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx @@ -137,7 +137,7 @@ export function getActionType(): ActionTypeModel { const errorText = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText', { - defaultMessage: 'No [to], [cc], or [bcc] entries. At least one entry is required.', + defaultMessage: 'No To, Cc, or Bcc entry. At least one entry is required.', } ); errors.to.push(errorText); @@ -396,7 +396,7 @@ const EmailParamsFields: React.FunctionComponent setAddCC(true)}> @@ -415,7 +415,7 @@ const EmailParamsFields: React.FunctionComponent ) : null} @@ -459,7 +459,7 @@ const EmailParamsFields: React.FunctionComponent @@ -500,7 +500,7 @@ const EmailParamsFields: React.FunctionComponent @@ -540,7 +540,7 @@ const EmailParamsFields: React.FunctionComponent @@ -550,7 +550,6 @@ const EmailParamsFields: React.FunctionComponent { editAction('subject', e.target.value, index); }} @@ -568,7 +567,7 @@ const EmailParamsFields: React.FunctionComponent { const validationResult = { errors: {} }; const errors = { @@ -179,7 +185,7 @@ const IndexActionConnectorFields: React.FunctionComponent {' '} } @@ -270,12 +270,20 @@ const PagerDutyParamsFields: React.FunctionComponent )); + const addVariableButtonTitle = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.addVariableTitle', + { + defaultMessage: 'Add alert variable', + } + ); + const getAddVariableComponent = (paramsProperty: string, buttonName: string) => { return ( setIsVariablesPopoverOpen({ ...isVariablesPopoverOpen, [paramsProperty]: true }) } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx index f0ac43c04ee0e..8f84e9da5ada0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx @@ -134,6 +134,12 @@ export const ServerLogParamsFields: React.FunctionComponent setIsVariablesPopoverOpen(true)} iconType="indexOpen" + title={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.addVariableTitle', + { + defaultMessage: 'Add variable', + } + )} aria-label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.addVariablePopoverButton', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx index a8ba11faa08dd..2ca07e0d57a8e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx @@ -98,7 +98,7 @@ const SlackActionFields: React.FunctionComponent } @@ -115,7 +115,7 @@ const SlackActionFields: React.FunctionComponent 0 && webhookUrl !== undefined} name="webhookUrl" - placeholder="URL like https://hooks.slack.com/services" + placeholder="Example: https://hooks.slack.com/services" value={webhookUrl || ''} data-test-subj="slackWebhookUrlInput" onChange={e => { @@ -182,10 +182,16 @@ const SlackParamsFields: React.FunctionComponent setIsVariablesPopoverOpen(true)} iconType="indexOpen" + title={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.addVariableTitle', + { + defaultMessage: 'Add alert variable', + } + )} aria-label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.addVariablePopoverButton', { - defaultMessage: 'Add variable', + defaultMessage: 'Add alert variable', } )} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx index 5d07483c8a989..f611c3715e56a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx @@ -47,6 +47,12 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send a request to a web service.', } ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.actionTypeTitle', + { + defaultMessage: 'Webhook data', + } + ), validateConnector: (action: WebhookActionConnector): ValidationResult => { const validationResult = { errors: {} }; const errors = { @@ -142,7 +148,7 @@ const WebhookActionConnectorFields: React.FunctionComponent
@@ -496,6 +502,12 @@ const WebhookParamsFields: React.FunctionComponent setIsVariablesPopoverOpen(true)} iconType="indexOpen" + title={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.addVariableTitle', + { + defaultMessage: 'Add variable', + } + )} aria-label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.addVariablePopoverButton', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx index fa26e8b11bfec..96513f0563ad0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -10,7 +10,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexItem, EuiFlexGroup, - EuiFormLabel, EuiExpression, EuiPopover, EuiPopoverTitle, @@ -23,6 +22,8 @@ import { EuiEmptyPrompt, EuiText, } from '@elastic/eui'; +import { EuiSteps } from '@elastic/eui'; +import { EuiButtonIcon } from '@elastic/eui'; import { firstFieldOption, getIndexPatterns, @@ -213,6 +214,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent ); + const firstSetOfSteps = [ + { + title: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.selectIndex', { + defaultMessage: 'Select an index.', + }), + children: ( + <> + + + { + setIndexPopoverOpen(true); + }} + color={index ? 'secondary' : 'danger'} + /> + } + isOpen={indexPopoverOpen} + closePopover={() => { + setIndexPopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition="downLeft" + zIndex={8000} + > +
+ + + + {i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.indexButtonLabel', + { + defaultMessage: 'index', + } + )} + + + { + setIndexPopoverOpen(false); + }} + /> + + + + + {indexPopover} +
+
+
+
+ + + + setAlertParams('aggType', selectedAggType) + } + /> + + {aggType && builtInAggregationTypes[aggType].fieldRequired ? ( + + + setAlertParams('aggField', selectedAggField) + } + /> + + ) : null} + + + + + setAlertParams('groupBy', selectedGroupBy) + } + onChangeSelectedTermField={selectedTermField => + setAlertParams('termField', selectedTermField) + } + onChangeSelectedTermSize={selectedTermSize => + setAlertParams('termSize', selectedTermSize) + } + /> + + + + ), + }, + { + title: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.conditionPrompt', { + defaultMessage: 'Define the condition.', + }), + children: ( + <> + + + + setAlertParams('threshold', selectedThresholds) + } + onChangeSelectedThresholdComparator={selectedThresholdComparator => + setAlertParams('thresholdComparator', selectedThresholdComparator) + } + /> + + + + setAlertParams('timeWindowSize', selectedWindowSize) + } + onChangeWindowUnit={(selectedWindowUnit: any) => + setAlertParams('timeWindowUnit', selectedWindowUnit) + } + /> + + + + ), + }, + ]; + return ( {hasExpressionErrors ? ( @@ -281,136 +441,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent ) : null} - - - - - - - { - setIndexPopoverOpen(true); - }} - color={index ? 'secondary' : 'danger'} - /> - } - isOpen={indexPopoverOpen} - closePopover={() => { - setIndexPopoverOpen(false); - }} - ownFocus - withTitle - anchorPosition="downLeft" - zIndex={8000} - > -
- - {i18n.translate( - 'xpack.triggersActionsUI.sections.alertAdd.threshold.indexButtonLabel', - { - defaultMessage: 'index', - } - )} - - {indexPopover} -
-
-
-
- - - - setAlertParams('aggType', selectedAggType) - } - /> - - {aggType && builtInAggregationTypes[aggType].fieldRequired ? ( - - - setAlertParams('aggField', selectedAggField) - } - /> - - ) : null} - - - - setAlertParams('groupBy', selectedGroupBy)} - onChangeSelectedTermField={selectedTermField => - setAlertParams('termField', selectedTermField) - } - onChangeSelectedTermSize={selectedTermSize => - setAlertParams('termSize', selectedTermSize) - } - /> - - - - - - - - - - - setAlertParams('threshold', selectedThresholds) - } - onChangeSelectedThresholdComparator={selectedThresholdComparator => - setAlertParams('thresholdComparator', selectedThresholdComparator) - } - /> - - - - setAlertParams('timeWindowSize', selectedWindowSize) - } - onChangeWindowUnit={(selectedWindowUnit: any) => - setAlertParams('timeWindowUnit', selectedWindowUnit) - } - /> - - +
{canShowVizualization ? ( @@ -422,7 +453,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts index ecf60e995d1a1..983f759214b6b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts @@ -10,7 +10,7 @@ import { validateExpression } from './validation'; export function getAlertType(): AlertTypeModel { return { id: '.index-threshold', - name: 'Index Threshold', + name: 'Index threshold', iconClass: 'alert', alertParamsExpression: IndexThresholdAlertTypeExpression, validate: validateExpression, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/visualization.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/visualization.tsx index ef08ac9a9d0de..06d311ef064c3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/visualization.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/visualization.tsx @@ -305,14 +305,14 @@ export const ThresholdVisualization: React.FunctionComponent = ({ title={ } color="warning" > )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx index b478a9f0ced8b..6130233f33815 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx @@ -17,6 +17,7 @@ import { EuiTabs, EuiTitle, EuiBetaBadge, + EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -28,6 +29,7 @@ import { hasShowActionsCapability, hasShowAlertsCapability } from './lib/capabil import { ActionsConnectorsList } from './sections/actions_connectors_list/components/actions_connectors_list'; import { AlertsList } from './sections/alerts_list/components/alerts_list'; +import { PLUGIN } from './constants/plugin'; interface MatchParams { section: Section; @@ -100,12 +102,24 @@ export const TriggersActionsUIHome: React.FunctionComponent + + +

+ +

+
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx index eb51bb8ac5098..566ed7935e013 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx @@ -43,11 +43,11 @@ test('returns isEnabled:false when action type is disabled by license', async () expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(` Object { "isEnabled": false, - "message": "This connector is disabled because it requires a basic license.", + "message": "This connector requires a Basic license.", "messageCard": { try { setIsLoadingActionTypes(true); - const registeredActionTypes = actionTypes ?? (await loadActionTypes({ http })); + const registeredActionTypes = ( + actionTypes ?? (await loadActionTypes({ http })) + ).sort((a, b) => a.name.localeCompare(b.name)); const index: ActionTypeIndex = {}; for (const actionTypeItem of registeredActionTypes) { index[actionTypeItem.id] = actionTypeItem; @@ -188,7 +190,7 @@ export const ActionForm = ({ label={
, ]} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index 84d5269337b9e..70aa862aa3c3d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -188,7 +188,7 @@ describe('connector_add_flyout', () => { expect(element.exists()).toBeTruthy(); expect(element.first().prop('betaBadgeLabel')).toEqual('Upgrade'); expect(element.first().prop('betaBadgeTooltipContent')).toEqual( - 'This connector is disabled because it requires a gold license.' + 'This connector requires a Gold license.' ); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index 665eeca43acb4..6b4a461bad24d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -31,6 +31,7 @@ import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; +import { PLUGIN } from '../../constants/plugin'; export interface ConnectorAddFlyoutProps { addFlyoutVisible: boolean; @@ -138,15 +139,11 @@ export const ConnectorAddFlyout = ({ }) .catch(errorRes => { toastNotifications.addDanger( - i18n.translate( - 'xpack.triggersActionsUI.sections.addConnectorForm.updateErrorNotificationText', - { - defaultMessage: 'Failed to create connector: {message}', - values: { - message: errorRes.body?.message ?? '', - }, - } - ) + errorRes.body?.message ?? + i18n.translate( + 'xpack.triggersActionsUI.sections.addConnectorForm.updateErrorNotificationText', + { defaultMessage: 'Cannot create a connector.' } + ) ); return undefined; }); @@ -179,7 +176,10 @@ export const ConnectorAddFlyout = ({ 'xpack.triggersActionsUI.sections.addConnectorForm.betaBadgeTooltipContent', { defaultMessage: - 'This module is not GA. Please help us by reporting any bugs.', + '{pluginName} 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.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> @@ -203,7 +203,10 @@ export const ConnectorAddFlyout = ({ 'xpack.triggersActionsUI.sections.addFlyout.betaBadgeTooltipContent', { defaultMessage: - 'This module is not GA. Please help us by reporting any bugs.', + '{pluginName} 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.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 977a908fd86f0..e04484b897e1c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -24,6 +24,7 @@ import { connectorReducer } from './connector_reducer'; import { createActionConnector } from '../../lib/action_connector_api'; import { TypeRegistry } from '../../type_registry'; import './connector_add_modal.scss'; +import { PLUGIN } from '../../constants/plugin'; interface ConnectorAddModalProps { actionType: ActionType; @@ -133,7 +134,10 @@ export const ConnectorAddModal = ({ 'xpack.triggersActionsUI.sections.addModalConnectorForm.betaBadgeTooltipContent', { defaultMessage: - 'This module is not GA. Please help us by reporting any bugs.', + '{pluginName} 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.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 39c0b7255a7b9..ed8811d26331b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -25,6 +25,7 @@ import { connectorReducer } from './connector_reducer'; import { updateActionConnector } from '../../lib/action_connector_api'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { PLUGIN } from '../../constants/plugin'; export interface ConnectorEditProps { initialConnector: ActionConnectorTableItem; @@ -66,33 +67,27 @@ export const ConnectorEditFlyout = ({ const onActionConnectorSave = async (): Promise => await updateActionConnector({ http, connector, id: connector.id }) .then(savedConnector => { - if (toastNotifications) { - toastNotifications.addSuccess( - i18n.translate( - 'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText', - { - defaultMessage: "Updated '{connectorName}'", - values: { - connectorName: savedConnector.name, - }, - } - ) - ); - } - return savedConnector; - }) - .catch(errorRes => { - toastNotifications.addDanger( + toastNotifications.addSuccess( i18n.translate( - 'xpack.triggersActionsUI.sections.editConnectorForm.updateErrorNotificationText', + 'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText', { - defaultMessage: 'Failed to update connector: {message}', + defaultMessage: "Updated '{connectorName}'", values: { - message: errorRes.body?.message ?? '', + connectorName: savedConnector.name, }, } ) ); + return savedConnector; + }) + .catch(errorRes => { + toastNotifications.addDanger( + errorRes.body?.message ?? + i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.updateErrorNotificationText', + { defaultMessage: 'Cannot update a connector.' } + ) + ); return undefined; }); @@ -119,7 +114,10 @@ export const ConnectorEditFlyout = ({ 'xpack.triggersActionsUI.sections.editConnectorForm.betaBadgeTooltipContent', { defaultMessage: - 'This module is not GA. Please help us by reporting any bugs.', + '{pluginName} 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.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index 8c2565538f718..facfc8efa299e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -114,7 +114,7 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { title: i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionsMessage', { - defaultMessage: 'Unable to load actions', + defaultMessage: 'Unable to load connectors', } ), }); @@ -213,11 +213,11 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { description: canDelete ? i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDescription', - { defaultMessage: 'Delete this action' } + { defaultMessage: 'Delete this connector' } ) : i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDisabledDescription', - { defaultMessage: 'Unable to delete actions' } + { defaultMessage: 'Unable to delete connectors' } ), type: 'icon', icon: 'trash', @@ -290,13 +290,13 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { ? undefined : i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteDisabledTitle', - { defaultMessage: 'Unable to delete actions' } + { defaultMessage: 'Unable to delete connectors' } ) } > { toastNotifications.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.failedToDeleteActionsMessage', - { defaultMessage: 'Failed to delete action(s)' } + { defaultMessage: 'Failed to delete connectors(s)' } ), }); // Refresh the actions from the server, some actions may have beend deleted diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index d781e8b761845..9da4f059f8967 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -12,6 +12,7 @@ import { EuiTitle, EuiBadge, EuiFlexItem, EuiSwitch, EuiBetaBadge } from '@elast import { times, random } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ViewInApp } from './view_in_app'; +import { PLUGIN } from '../../../constants/plugin'; jest.mock('../../../app_context', () => ({ useAppDependencies: jest.fn(() => ({ @@ -63,7 +64,11 @@ describe('alert_details', () => { tooltipContent={i18n.translate( 'xpack.triggersActionsUI.sections.alertDetails.betaBadgeTooltipContent', { - defaultMessage: 'This module is not GA. Please help us by reporting any bugs.', + defaultMessage: + '{pluginName} 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.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 1f55e61e9ee0d..5bfcf9fd2d4e6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -33,6 +33,7 @@ import { } from '../../common/components/with_bulk_alert_api_operations'; import { AlertInstancesRouteWithApi } from './alert_instances_route'; import { ViewInApp } from './view_in_app'; +import { PLUGIN } from '../../../constants/plugin'; type AlertDetailsProps = { alert: Alert; @@ -77,7 +78,10 @@ export const AlertDetails: React.FunctionComponent = ({ 'xpack.triggersActionsUI.sections.alertDetails.betaBadgeTooltipContent', { defaultMessage: - 'This module is not GA. Please help us by reporting any bugs.', + '{pluginName} 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.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> @@ -177,7 +181,7 @@ export const AlertDetails: React.FunctionComponent = ({

diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index 4e6d63e97ec45..6600a2379cd23 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -24,6 +24,7 @@ import { Alert, AlertAction, IErrorObject } from '../../../types'; import { AlertForm, validateBaseProperties } from './alert_form'; import { alertReducer } from './alert_reducer'; import { createAlert } from '../../lib/alert_api'; +import { PLUGIN } from '../../constants/plugin'; interface AlertAddProps { consumer: string; @@ -109,12 +110,10 @@ export const AlertAdd = ({ return newAlert; } catch (errorRes) { toastNotifications.addDanger( - i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveErrorNotificationText', { - defaultMessage: 'Failed to save alert: {message}', - values: { - message: errorRes.body?.message ?? '', - }, - }) + errorRes.body?.message ?? + i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveErrorNotificationText', { + defaultMessage: 'Cannot create alert.', + }) ); } } @@ -132,7 +131,7 @@ export const AlertAdd = ({

  @@ -141,7 +140,11 @@ export const AlertAdd = ({ tooltipContent={i18n.translate( 'xpack.triggersActionsUI.sections.alertAdd.betaBadgeTooltipContent', { - defaultMessage: 'This module is not GA. Please help us by reporting any bugs.', + defaultMessage: + '{pluginName} 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.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx index 9a335b2f2c242..e9ef6712b88e9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -147,8 +147,6 @@ describe('alert_edit', () => { .first() .simulate('click'); }); - expect(mockedCoreSetup.notifications.toasts.addDanger).toHaveBeenCalledWith( - 'Failed to save alert: Fail message' - ); + expect(mockedCoreSetup.notifications.toasts.addDanger).toHaveBeenCalledWith('Fail message'); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx index 3feceb42e6ddc..41ab3279f91c6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx @@ -26,6 +26,7 @@ import { Alert, AlertAction, IErrorObject } from '../../../types'; import { AlertForm, validateBaseProperties } from './alert_form'; import { alertReducer } from './alert_reducer'; import { updateAlert } from '../../lib/alert_api'; +import { PLUGIN } from '../../constants/plugin'; interface AlertEditProps { initialAlert: Alert; @@ -82,28 +83,22 @@ export const AlertEdit = ({ async function onSaveAlert(): Promise { try { const newAlert = await updateAlert({ http, alert, id: alert.id }); - if (toastNotifications) { - toastNotifications.addSuccess( - i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText', { - defaultMessage: "Updated '{alertName}'", - values: { - alertName: newAlert.name, - }, - }) - ); - } + toastNotifications.addSuccess( + i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText', { + defaultMessage: "Updated '{alertName}'", + values: { + alertName: newAlert.name, + }, + }) + ); return newAlert; } catch (errorRes) { - if (toastNotifications) { - toastNotifications.addDanger( + toastNotifications.addDanger( + errorRes.body?.message ?? i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveErrorNotificationText', { - defaultMessage: 'Failed to save alert: {message}', - values: { - message: errorRes.body?.message ?? '', - }, + defaultMessage: 'Cannot update alert.', }) - ); - } + ); } } @@ -120,7 +115,7 @@ export const AlertEdit = ({

  @@ -129,7 +124,11 @@ export const AlertEdit = ({ tooltipContent={i18n.translate( 'xpack.triggersActionsUI.sections.alertEdit.betaBadgeTooltipContent', { - defaultMessage: 'This module is not GA. Please help us by reporting any bugs.', + defaultMessage: + '{pluginName} 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.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> 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 2c601eeb75645..4b8045d1bc8a4 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 @@ -260,7 +260,7 @@ export const AlertForm = ({ position="right" type="questionInCircle" content={i18n.translate('xpack.triggersActionsUI.sections.alertForm.checkWithTooltip', { - defaultMessage: 'This is some help text here for check alert.', + defaultMessage: 'Define how often to evaluate the condition.', })} /> @@ -270,13 +270,13 @@ export const AlertForm = ({ <> {' '} @@ -456,7 +456,7 @@ export const AlertForm = ({
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 84e4d5794859c..9f10528b1dd46 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -120,7 +120,7 @@ export const AlertsList: React.FunctionComponent = () => { (async () => { try { const result = await loadActionTypes({ http }); - setActionTypes(result); + setActionTypes(result.filter(actionType => actionTypeRegistry.has(actionType.id))); } catch (e) { toastNotifications.addDanger({ title: i18n.translate( @@ -285,7 +285,7 @@ export const AlertsList: React.FunctionComponent = () => { > ); @@ -307,7 +307,7 @@ export const AlertsList: React.FunctionComponent = () => {

} From 676a03d8c5e566085d08983aa2500f504c26d41d Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Tue, 24 Mar 2020 10:11:51 -0700 Subject: [PATCH 17/56] Update crypto-js to 3.3.0 (#58911) Co-authored-by: Elastic Machine --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 37cebe420a362..f88db13f4ead1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10525,9 +10525,9 @@ crypto-browserify@^3.11.0: randomfill "^1.0.3" crypto-js@^3.1.9-1: - version "3.1.9-1" - resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.1.9-1.tgz#fda19e761fc077e01ffbfdc6e9fdfc59e8806cd8" - integrity sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg= + version "3.3.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.3.0.tgz#846dd1cce2f68aacfa156c8578f926a609b7976b" + integrity sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q== crypto-random-string@^1.0.0: version "1.0.0" From 6950c260efb0955d9212a96d9d956ee998127a85 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Tue, 24 Mar 2020 14:12:54 -0400 Subject: [PATCH 18/56] kbn-es: Support choosing the correct architecture (#61096) --- packages/kbn-es/src/artifact.js | 5 ++++- packages/kbn-es/src/artifact.test.js | 24 ++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/kbn-es/src/artifact.js b/packages/kbn-es/src/artifact.js index 9ea78386269d9..83dcd1cf36d2e 100644 --- a/packages/kbn-es/src/artifact.js +++ b/packages/kbn-es/src/artifact.js @@ -117,11 +117,14 @@ async function getArtifactSpecForSnapshot(urlVersion, license, log) { const manifest = JSON.parse(json); const platform = process.platform === 'win32' ? 'windows' : process.platform; + const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; + const archive = manifest.archives.find( archive => archive.version === desiredVersion && archive.platform === platform && - archive.license === desiredLicense + archive.license === desiredLicense && + archive.architecture === arch ); if (!archive) { diff --git a/packages/kbn-es/src/artifact.test.js b/packages/kbn-es/src/artifact.test.js index 453eb1a9a7689..02e4d5318f63f 100644 --- a/packages/kbn-es/src/artifact.test.js +++ b/packages/kbn-es/src/artifact.test.js @@ -28,6 +28,7 @@ const log = new ToolingLog(); let MOCKS; const PLATFORM = process.platform === 'win32' ? 'windows' : process.platform; +const ARCHITECTURE = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; const MOCK_VERSION = 'test-version'; const MOCK_URL = 'http://127.0.0.1:12345'; const MOCK_FILENAME = 'test-filename'; @@ -38,13 +39,15 @@ const PERMANENT_SNAPSHOT_BASE_URL = const createArchive = (params = {}) => { const license = params.license || 'default'; + const architecture = params.architecture || ARCHITECTURE; return { license: 'default', + architecture, version: MOCK_VERSION, url: MOCK_URL + `/${license}`, platform: PLATFORM, - filename: MOCK_FILENAME + `.${license}`, + filename: MOCK_FILENAME + `-${architecture}.${license}`, ...params, }; }; @@ -77,6 +80,12 @@ beforeEach(() => { valid: { archives: [createArchive({ license: 'oss' }), createArchive({ license: 'default' })], }, + multipleArch: { + archives: [ + createArchive({ architecture: 'fake_arch', license: 'oss' }), + createArchive({ architecture: ARCHITECTURE, license: 'oss' }), + ], + }, }; }); @@ -95,7 +104,7 @@ const artifactTest = (requestedLicense, expectedLicense, fetchTimesCalled = 1) = expect(artifact.getUrl()).toEqual(MOCK_URL + `/${expectedLicense}`); expect(artifact.getChecksumUrl()).toEqual(MOCK_URL + `/${expectedLicense}.sha512`); expect(artifact.getChecksumType()).toEqual('sha512'); - expect(artifact.getFilename()).toEqual(MOCK_FILENAME + `.${expectedLicense}`); + expect(artifact.getFilename()).toEqual(MOCK_FILENAME + `-${ARCHITECTURE}.${expectedLicense}`); }; }; @@ -153,6 +162,17 @@ describe('Artifact', () => { }); }); + describe('with snapshots for multiple architectures', () => { + beforeEach(() => { + mockFetch(MOCKS.multipleArch); + }); + + it('should return artifact metadata for the correct architecture', async () => { + const artifact = await Artifact.getSnapshot('oss', MOCK_VERSION, log); + expect(artifact.getFilename()).toEqual(MOCK_FILENAME + `-${ARCHITECTURE}.oss`); + }); + }); + describe('with custom snapshot manifest URL', () => { const CUSTOM_URL = 'http://www.creedthoughts.gov.www/creedthoughts'; From be1953a4dc3a3beebc39e328ee26bc4083c85c5a Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Tue, 24 Mar 2020 11:33:29 -0700 Subject: [PATCH 19/56] [Metric Alerts] Align date histogram buckets to the last bucket (#60714) * [Metrics Alert] Align date_histogram to last bucket - adds extended_bounds to date_histogram - change range query to use epoch_millis - add lte to range query - add offset to date_histogram to realign buckets * Removing unused import * Adding note about calculating FROM to be more clear Co-authored-by: Elastic Machine --- .../metric_threshold_executor.ts | 19 ++++++++++++++++++- .../server/lib/snapshot/query_helpers.ts | 3 +-- .../infra/server/lib/snapshot/snapshot.ts | 2 +- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 778889ba0c7a5..bfe04b82b95fc 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -11,6 +11,10 @@ import { getAllCompositeData } from '../../../utils/get_all_composite_data'; import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; import { MetricExpressionParams, Comparator, AlertStates } from './types'; import { AlertServices, AlertExecutorOptions } from '../../../../../alerting/server'; +import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; +import { getDateHistogramOffset } from '../../snapshot/query_helpers'; + +const TOTAL_BUCKETS = 5; interface Aggregation { aggregatedIntervals: { @@ -70,6 +74,12 @@ export const getElasticsearchMetricQuery = ( throw new Error('Can only aggregate without a metric if using the document count aggregator'); } const interval = `${timeSize}${timeUnit}`; + const to = Date.now(); + const intervalAsSeconds = getIntervalInSeconds(interval); + // We need enough data for 5 buckets worth of data. We also need + // to convert the intervalAsSeconds to milliseconds. + const from = to - intervalAsSeconds * 1000 * TOTAL_BUCKETS; + const offset = getDateHistogramOffset(from, interval); const aggregations = aggType === 'count' @@ -89,6 +99,11 @@ export const getElasticsearchMetricQuery = ( date_histogram: { field: '@timestamp', fixed_interval: interval, + offset, + extended_bounds: { + min: from, + max: to, + }, }, aggregations, }, @@ -118,7 +133,9 @@ export const getElasticsearchMetricQuery = ( { range: { '@timestamp': { - gte: `now-${interval}`, + gte: from, + lte: to, + format: 'epoch_millis', }, }, }, diff --git a/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts b/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts index 383dc9a773abe..82a393079745f 100644 --- a/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts +++ b/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts @@ -87,8 +87,7 @@ export const getMetricsAggregations = (options: InfraSnapshotRequestOptions): Sn return aggregation; }; -export const getDateHistogramOffset = (options: InfraSnapshotRequestOptions): string => { - const { from, interval } = options.timerange; +export const getDateHistogramOffset = (from: number, interval: string): string => { const fromInSeconds = Math.floor(from / 1000); const bucketSizeInSeconds = getIntervalInSeconds(interval); diff --git a/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts b/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts index 8e5f8e6716f3c..07abfa5fd474a 100644 --- a/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts +++ b/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts @@ -159,7 +159,7 @@ const requestNodeMetrics = async ( date_histogram: { field: options.sourceConfiguration.fields.timestamp, interval: options.timerange.interval || '1m', - offset: getDateHistogramOffset(options), + offset: getDateHistogramOffset(options.timerange.from, options.timerange.interval), extended_bounds: { min: options.timerange.from, max: options.timerange.to, From ec8ac301ddc2676f80e8270285ac1b22e8014395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Tue, 24 Mar 2020 18:57:35 +0000 Subject: [PATCH 20/56] [skip-ci] Fix README in usageCollection (#61137) * Fix README in usageCollection * savedObjectsRepository instead of savedObjectsClient --- src/plugins/usage_collection/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/plugins/usage_collection/README.md b/src/plugins/usage_collection/README.md index 1c97c9c63c0e2..e32dfae35832b 100644 --- a/src/plugins/usage_collection/README.md +++ b/src/plugins/usage_collection/README.md @@ -83,14 +83,14 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { CoreSetup, CoreStart } from 'kibana/server'; class Plugin { - private savedObjectsClient?: ISavedObjectsRepository; + private savedObjectsRepository?: ISavedObjectsRepository; public setup(core: CoreSetup, plugins: { usageCollection?: UsageCollectionSetup }) { - registerMyPluginUsageCollector(() => this.savedObjectsClient, plugins.usageCollection); + registerMyPluginUsageCollector(() => this.savedObjectsRepository, plugins.usageCollection); } public start(core: CoreStart) { - this.savedObjectsClient = core.savedObjects.client + this.savedObjectsRepository = core.savedObjects.createInternalRepository(); } } ``` @@ -101,7 +101,7 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { ISavedObjectsRepository } from 'kibana/server'; export function registerMyPluginUsageCollector( - getSavedObjectsClient: () => ISavedObjectsRepository | undefined, + getSavedObjectsRepository: () => ISavedObjectsRepository | undefined, usageCollection?: UsageCollectionSetup ): void { // usageCollection is an optional dependency, so make sure to return if it is not registered. @@ -112,9 +112,9 @@ export function registerMyPluginUsageCollector( // create usage collector const myCollector = usageCollection.makeUsageCollector({ type: MY_USAGE_TYPE, - isReady: () => typeof getSavedObjectsClient() !== 'undefined', + isReady: () => typeof getSavedObjectsRepository() !== 'undefined', fetch: async () => { - const savedObjectsClient = getSavedObjectsClient()!; + const savedObjectsRepository = getSavedObjectsRepository()!; // get something from the savedObjects return { my_objects }; From 18793dbc6cd922184390e9ef507c1366e26d5e6e Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Tue, 24 Mar 2020 19:15:27 +0000 Subject: [PATCH 21/56] [Alerting] notifies user when security is enabled but TLS is not (#60270) This PR: 1. Adds a callout on the Alerting UI when security is enabled but TLS is not 2. Cleans up displayed error message when creation fails due to TLS being switched off --- .../alerting_example/public/application.tsx | 5 +- .../public/components/create_alert.tsx | 2 + .../apm/public/new-platform/plugin.tsx | 1 + .../alerts/uptime_alerts_context_provider.tsx | 2 + x-pack/plugins/alerting/common/index.ts | 9 + x-pack/plugins/alerting/server/plugin.ts | 2 + .../server/routes/_mock_handler_arguments.ts | 3 +- .../plugins/alerting/server/routes/create.ts | 35 ++-- .../plugins/alerting/server/routes/enable.ts | 31 ++-- .../alerting/server/routes/health.test.ts | 171 ++++++++++++++++++ .../plugins/alerting/server/routes/health.ts | 67 +++++++ .../plugins/alerting/server/routes/index.ts | 1 + .../server/routes/lib/error_handler.ts | 47 +++++ .../plugins/alerting/server/routes/update.ts | 41 +++-- .../alerting/server/routes/update_api_key.ts | 31 ++-- .../alerting/metrics/alert_flyout.tsx | 1 + x-pack/plugins/triggers_actions_ui/README.md | 5 + .../alert_action_security_call_out.test.tsx | 78 ++++++++ .../alert_action_security_call_out.tsx | 78 ++++++++ .../components/security_call_out.test.tsx | 72 ++++++++ .../components/security_call_out.tsx | 75 ++++++++ .../application/context/alerts_context.tsx | 3 +- .../public/application/home.tsx | 4 +- .../public/application/lib/alert_api.test.ts | 15 ++ .../public/application/lib/alert_api.ts | 6 +- .../sections/alert_form/alert_add.test.tsx | 46 +++-- .../sections/alert_form/alert_add.tsx | 12 ++ .../sections/alert_form/alert_edit.test.tsx | 44 +++-- .../sections/alert_form/alert_edit.tsx | 12 ++ .../sections/alert_form/alert_form.test.tsx | 3 + .../alerts_list/components/alerts_list.tsx | 2 + .../with_bulk_alert_api_operations.tsx | 5 +- .../triggers_actions_ui/public/types.ts | 3 +- 33 files changed, 809 insertions(+), 103 deletions(-) create mode 100644 x-pack/plugins/alerting/server/routes/health.test.ts create mode 100644 x-pack/plugins/alerting/server/routes/health.ts create mode 100644 x-pack/plugins/alerting/server/routes/lib/error_handler.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.tsx diff --git a/examples/alerting_example/public/application.tsx b/examples/alerting_example/public/application.tsx index d71db92d3d421..6ff5a7d0880b8 100644 --- a/examples/alerting_example/public/application.tsx +++ b/examples/alerting_example/public/application.tsx @@ -25,6 +25,7 @@ import { AppMountParameters, CoreStart, IUiSettingsClient, + DocLinksStart, ToastsSetup, } from '../../../src/core/public'; import { DataPublicPluginStart } from '../../../src/plugins/data/public'; @@ -45,6 +46,7 @@ export interface AlertingExampleComponentParams { data: DataPublicPluginStart; charts: ChartsPluginStart; uiSettings: IUiSettingsClient; + docLinks: DocLinksStart; toastNotifications: ToastsSetup; } @@ -88,7 +90,7 @@ const AlertingExampleApp = (deps: AlertingExampleComponentParams) => { }; export const renderApp = ( - { application, notifications, http, uiSettings }: CoreStart, + { application, notifications, http, uiSettings, docLinks }: CoreStart, deps: AlertingExamplePublicStartDeps, { appBasePath, element }: AppMountParameters ) => { @@ -99,6 +101,7 @@ export const renderApp = ( toastNotifications={notifications.toasts} http={http} uiSettings={uiSettings} + docLinks={docLinks} {...deps} />, element diff --git a/examples/alerting_example/public/components/create_alert.tsx b/examples/alerting_example/public/components/create_alert.tsx index 65b8a9412dcda..0541e0b18a2e1 100644 --- a/examples/alerting_example/public/components/create_alert.tsx +++ b/examples/alerting_example/public/components/create_alert.tsx @@ -33,6 +33,7 @@ export const CreateAlert = ({ triggers_actions_ui, charts, uiSettings, + docLinks, data, toastNotifications, }: AlertingExampleComponentParams) => { @@ -56,6 +57,7 @@ export const CreateAlert = ({ alertTypeRegistry: triggers_actions_ui.alertTypeRegistry, toastNotifications, uiSettings, + docLinks, charts, dataFieldsFormats: data.fieldFormats, }} diff --git a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx index f95767492d85b..e30bed1810c1d 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx +++ b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx @@ -173,6 +173,7 @@ export class ApmPlugin { notifications, triggers_actions_ui: { actionTypeRegistry, alertTypeRegistry }, uiSettings, + docLinks, }, } = useKibana(); @@ -26,6 +27,7 @@ export const UptimeAlertsContextProvider: React.FC = ({ children }) => { actionTypeRegistry, alertTypeRegistry, charts, + docLinks, dataFieldsFormats: fieldFormats, http, toastNotifications: notifications?.toasts, diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index b705a334bc2b5..9d4ea69a63609 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -10,4 +10,13 @@ export * from './alert_instance'; export * from './alert_task_instance'; export * from './alert_navigation'; +export interface ActionGroup { + id: string; + name: string; +} + +export interface AlertingFrameworkHealth { + isSufficientlySecure: boolean; +} + export const BASE_ALERT_API_PATH = '/api/alert'; diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 58807b42dc278..e88124322c1eb 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -46,6 +46,7 @@ import { unmuteAllAlertRoute, muteAlertInstanceRoute, unmuteAlertInstanceRoute, + healthRoute, } from './routes'; import { LicensingPluginSetup } from '../../licensing/server'; import { @@ -173,6 +174,7 @@ export class AlertingPlugin { unmuteAllAlertRoute(router, this.licenseState); muteAlertInstanceRoute(router, this.licenseState); unmuteAlertInstanceRoute(router, this.licenseState); + healthRoute(router, this.licenseState); return { registerType: alertTypeRegistry.register.bind(alertTypeRegistry), diff --git a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts index 9815ad5194af7..5a1d680eb06f3 100644 --- a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts +++ b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts @@ -10,13 +10,14 @@ import { httpServerMock } from '../../../../../src/core/server/mocks'; import { alertsClientMock } from '../alerts_client.mock'; export function mockHandlerArguments( - { alertsClient, listTypes: listTypesRes = [] }: any, + { alertsClient, listTypes: listTypesRes = [], elasticsearch }: any, req: any, res?: Array> ): [RequestHandlerContext, KibanaRequest, KibanaResponseFactory] { const listTypes = jest.fn(() => listTypesRes); return [ ({ + core: { elasticsearch }, alerting: { listTypes, getAlertsClient() { diff --git a/x-pack/plugins/alerting/server/routes/create.ts b/x-pack/plugins/alerting/server/routes/create.ts index 7e17a66e84547..f08460ffcb453 100644 --- a/x-pack/plugins/alerting/server/routes/create.ts +++ b/x-pack/plugins/alerting/server/routes/create.ts @@ -15,6 +15,7 @@ import { import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { validateDurationSchema } from '../lib'; +import { handleDisabledApiKeysError } from './lib/error_handler'; import { Alert, BASE_ALERT_API_PATH } from '../types'; export const bodySchema = schema.object({ @@ -50,22 +51,24 @@ export const createAlertRoute = (router: IRouter, licenseState: LicenseState) => tags: ['access:alerting-all'], }, }, - router.handleLegacyErrors(async function( - context: RequestHandlerContext, - req: KibanaRequest, any>, - res: KibanaResponseFactory - ): Promise> { - verifyApiAccess(licenseState); + handleDisabledApiKeysError( + router.handleLegacyErrors(async function( + context: RequestHandlerContext, + req: KibanaRequest, any>, + res: KibanaResponseFactory + ): Promise> { + verifyApiAccess(licenseState); - if (!context.alerting) { - return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); - } - const alertsClient = context.alerting.getAlertsClient(); - const alert = req.body; - const alertRes: Alert = await alertsClient.create({ data: alert }); - return res.ok({ - body: alertRes, - }); - }) + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } + const alertsClient = context.alerting.getAlertsClient(); + const alert = req.body; + const alertRes: Alert = await alertsClient.create({ data: alert }); + return res.ok({ + body: alertRes, + }); + }) + ) ); }; diff --git a/x-pack/plugins/alerting/server/routes/enable.ts b/x-pack/plugins/alerting/server/routes/enable.ts index 9fb837e5074e8..2283ae4a4c765 100644 --- a/x-pack/plugins/alerting/server/routes/enable.ts +++ b/x-pack/plugins/alerting/server/routes/enable.ts @@ -15,6 +15,7 @@ import { import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; +import { handleDisabledApiKeysError } from './lib/error_handler'; const paramSchema = schema.object({ id: schema.string(), @@ -31,19 +32,21 @@ export const enableAlertRoute = (router: IRouter, licenseState: LicenseState) => tags: ['access:alerting-all'], }, }, - router.handleLegacyErrors(async function( - context: RequestHandlerContext, - req: KibanaRequest, any, any, any>, - res: KibanaResponseFactory - ): Promise> { - verifyApiAccess(licenseState); - if (!context.alerting) { - return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); - } - const alertsClient = context.alerting.getAlertsClient(); - const { id } = req.params; - await alertsClient.enable({ id }); - return res.noContent(); - }) + handleDisabledApiKeysError( + router.handleLegacyErrors(async function( + context: RequestHandlerContext, + req: KibanaRequest, any, any, any>, + res: KibanaResponseFactory + ): Promise> { + verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } + const alertsClient = context.alerting.getAlertsClient(); + const { id } = req.params; + await alertsClient.enable({ id }); + return res.noContent(); + }) + ) ); }; diff --git a/x-pack/plugins/alerting/server/routes/health.test.ts b/x-pack/plugins/alerting/server/routes/health.test.ts new file mode 100644 index 0000000000000..9efe020bc10c4 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/health.test.ts @@ -0,0 +1,171 @@ +/* + * 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 { healthRoute } from './health'; +import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock'; +import { mockHandlerArguments } from './_mock_handler_arguments'; +import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { verifyApiAccess } from '../lib/license_api_access'; +import { mockLicenseState } from '../lib/license_state.mock'; + +jest.mock('../lib/license_api_access.ts', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('healthRoute', () => { + it('registers the route', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + + const [config] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/api/alert/_health"`); + }); + + it('queries the usage api', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + const [, handler] = router.get.mock.calls[0]; + + const elasticsearch = elasticsearchServiceMock.createSetup(); + elasticsearch.adminClient.callAsInternalUser.mockReturnValue(Promise.resolve({})); + + const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']); + + await handler(context, req, res); + + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + + expect(elasticsearch.adminClient.callAsInternalUser.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "transport.request", + Object { + "method": "GET", + "path": "/_xpack/usage", + }, + ] + `); + }); + + it('evaluates missing security info from the usage api to mean that the security plugin is disbled', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + const [, handler] = router.get.mock.calls[0]; + + const elasticsearch = elasticsearchServiceMock.createSetup(); + elasticsearch.adminClient.callAsInternalUser.mockReturnValue(Promise.resolve({})); + + const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "isSufficientlySecure": true, + }, + } + `); + }); + + it('evaluates missing security http info from the usage api to mean that the security plugin is disbled', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + const [, handler] = router.get.mock.calls[0]; + + const elasticsearch = elasticsearchServiceMock.createSetup(); + elasticsearch.adminClient.callAsInternalUser.mockReturnValue(Promise.resolve({ security: {} })); + + const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "isSufficientlySecure": true, + }, + } + `); + }); + + it('evaluates security enabled, and missing ssl info from the usage api to mean that the user cannot generate keys', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + const [, handler] = router.get.mock.calls[0]; + + const elasticsearch = elasticsearchServiceMock.createSetup(); + elasticsearch.adminClient.callAsInternalUser.mockReturnValue( + Promise.resolve({ security: { enabled: true } }) + ); + + const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "isSufficientlySecure": false, + }, + } + `); + }); + + it('evaluates security enabled, SSL info present but missing http info from the usage api to mean that the user cannot generate keys', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + const [, handler] = router.get.mock.calls[0]; + + const elasticsearch = elasticsearchServiceMock.createSetup(); + elasticsearch.adminClient.callAsInternalUser.mockReturnValue( + Promise.resolve({ security: { enabled: true, ssl: {} } }) + ); + + const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "isSufficientlySecure": false, + }, + } + `); + }); + + it('evaluates security and tls enabled to mean that the user can generate keys', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + const [, handler] = router.get.mock.calls[0]; + + const elasticsearch = elasticsearchServiceMock.createSetup(); + elasticsearch.adminClient.callAsInternalUser.mockReturnValue( + Promise.resolve({ security: { enabled: true, ssl: { http: { enabled: true } } } }) + ); + + const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "isSufficientlySecure": true, + }, + } + `); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/health.ts b/x-pack/plugins/alerting/server/routes/health.ts new file mode 100644 index 0000000000000..29c2f3c5730f4 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/health.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + IRouter, + RequestHandlerContext, + KibanaRequest, + IKibanaResponse, + KibanaResponseFactory, +} from 'kibana/server'; +import { LicenseState } from '../lib/license_state'; +import { verifyApiAccess } from '../lib/license_api_access'; +import { AlertingFrameworkHealth } from '../types'; + +interface XPackUsageSecurity { + security?: { + enabled?: boolean; + ssl?: { + http?: { + enabled?: boolean; + }; + }; + }; +} + +export function healthRoute(router: IRouter, licenseState: LicenseState) { + router.get( + { + path: '/api/alert/_health', + validate: false, + }, + router.handleLegacyErrors(async function( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + verifyApiAccess(licenseState); + try { + const { + security: { + enabled: isSecurityEnabled = false, + ssl: { http: { enabled: isTLSEnabled = false } = {} } = {}, + } = {}, + }: XPackUsageSecurity = await context.core.elasticsearch.adminClient + // `transport.request` is potentially unsafe when combined with untrusted user input. + // Do not augment with such input. + .callAsInternalUser('transport.request', { + method: 'GET', + path: '/_xpack/usage', + }); + + const frameworkHealth: AlertingFrameworkHealth = { + isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled), + }; + + return res.ok({ + body: frameworkHealth, + }); + } catch (error) { + return res.badRequest({ body: error }); + } + }) + ); +} diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index 7ec901ae685c4..f833a29c67bb9 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -18,3 +18,4 @@ export { muteAlertInstanceRoute } from './mute_instance'; export { unmuteAlertInstanceRoute } from './unmute_instance'; export { muteAllAlertRoute } from './mute_all'; export { unmuteAllAlertRoute } from './unmute_all'; +export { healthRoute } from './health'; diff --git a/x-pack/plugins/alerting/server/routes/lib/error_handler.ts b/x-pack/plugins/alerting/server/routes/lib/error_handler.ts new file mode 100644 index 0000000000000..b3cf48c52fe17 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/lib/error_handler.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { + RequestHandler, + KibanaRequest, + KibanaResponseFactory, + RequestHandlerContext, + RouteMethod, +} from 'kibana/server'; + +export function handleDisabledApiKeysError( + handler: RequestHandler +): RequestHandler { + return async ( + context: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ) => { + try { + return await handler(context, request, response); + } catch (e) { + if (isApiKeyDisabledError(e)) { + return response.badRequest({ + body: new Error( + i18n.translate('xpack.alerting.api.error.disabledApiKeys', { + defaultMessage: 'Alerting relies upon API keys which appear to be disabled', + }) + ), + }); + } + throw e; + } + }; +} + +export function isApiKeyDisabledError(e: Error) { + return e?.message?.includes('api keys are not enabled') ?? false; +} + +export function isSecurityPluginDisabledError(e: Error) { + return e?.message?.includes('no handler found') ?? false; +} diff --git a/x-pack/plugins/alerting/server/routes/update.ts b/x-pack/plugins/alerting/server/routes/update.ts index 26a8320fffebb..45f7b26b521d4 100644 --- a/x-pack/plugins/alerting/server/routes/update.ts +++ b/x-pack/plugins/alerting/server/routes/update.ts @@ -15,6 +15,7 @@ import { import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { validateDurationSchema } from '../lib'; +import { handleDisabledApiKeysError } from './lib/error_handler'; import { BASE_ALERT_API_PATH } from '../../common'; const paramSchema = schema.object({ @@ -52,24 +53,26 @@ export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => tags: ['access:alerting-all'], }, }, - router.handleLegacyErrors(async function( - context: RequestHandlerContext, - req: KibanaRequest, any, TypeOf, any>, - res: KibanaResponseFactory - ): Promise> { - verifyApiAccess(licenseState); - if (!context.alerting) { - return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); - } - const alertsClient = context.alerting.getAlertsClient(); - const { id } = req.params; - const { name, actions, params, schedule, tags, throttle } = req.body; - return res.ok({ - body: await alertsClient.update({ - id, - data: { name, actions, params, schedule, tags, throttle }, - }), - }); - }) + handleDisabledApiKeysError( + router.handleLegacyErrors(async function( + context: RequestHandlerContext, + req: KibanaRequest, any, TypeOf, any>, + res: KibanaResponseFactory + ): Promise> { + verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } + const alertsClient = context.alerting.getAlertsClient(); + const { id } = req.params; + const { name, actions, params, schedule, tags, throttle } = req.body; + return res.ok({ + body: await alertsClient.update({ + id, + data: { name, actions, params, schedule, tags, throttle }, + }), + }); + }) + ) ); }; diff --git a/x-pack/plugins/alerting/server/routes/update_api_key.ts b/x-pack/plugins/alerting/server/routes/update_api_key.ts index 62c1b1510ddac..f70d30f0bb5da 100644 --- a/x-pack/plugins/alerting/server/routes/update_api_key.ts +++ b/x-pack/plugins/alerting/server/routes/update_api_key.ts @@ -15,6 +15,7 @@ import { import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; +import { handleDisabledApiKeysError } from './lib/error_handler'; const paramSchema = schema.object({ id: schema.string(), @@ -31,19 +32,21 @@ export const updateApiKeyRoute = (router: IRouter, licenseState: LicenseState) = tags: ['access:alerting-all'], }, }, - router.handleLegacyErrors(async function( - context: RequestHandlerContext, - req: KibanaRequest, any, any, any>, - res: KibanaResponseFactory - ): Promise> { - verifyApiAccess(licenseState); - if (!context.alerting) { - return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); - } - const alertsClient = context.alerting.getAlertsClient(); - const { id } = req.params; - await alertsClient.updateApiKey({ id }); - return res.noContent(); - }) + handleDisabledApiKeysError( + router.handleLegacyErrors(async function( + context: RequestHandlerContext, + req: KibanaRequest, any, any, any>, + res: KibanaResponseFactory + ): Promise> { + verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } + const alertsClient = context.alerting.getAlertsClient(); + const { id } = req.params; + await alertsClient.updateApiKey({ id }); + return res.noContent(); + }) + ) ); }; diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx index a00d63af8aac2..914054e1fd9b7 100644 --- a/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx @@ -35,6 +35,7 @@ export const AlertFlyout = (props: Props) => { }, toastNotifications: services.notifications?.toasts, http: services.http, + docLinks: services.docLinks, actionTypeRegistry: triggersActionsUI.actionTypeRegistry, alertTypeRegistry: triggersActionsUI.alertTypeRegistry, }} diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index e6af63ecd4359..3b6ca4f9da7cc 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -660,6 +660,7 @@ const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); alertTypeRegistry: triggers_actions_ui.alertTypeRegistry, toastNotifications: toasts, uiSettings, + docLinks, charts, dataFieldsFormats, metadata: { test: 'some value', fields: ['test'] }, @@ -697,6 +698,7 @@ export interface AlertsContextValue> { alertTypeRegistry: TypeRegistry; actionTypeRegistry: TypeRegistry; uiSettings?: IUiSettingsClient; + docLinks: DocLinksStart; toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' @@ -714,6 +716,7 @@ export interface AlertsContextValue> { |alertTypeRegistry|Registry for alert types.| |actionTypeRegistry|Registry for action types.| |uiSettings|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| +|docLinks|Documentation Links, needed to link to the documentation from informational callouts.| |toastNotifications|Toast messages.| |charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| |dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| @@ -1322,6 +1325,7 @@ export interface AlertsContextValue { alertTypeRegistry: TypeRegistry; actionTypeRegistry: TypeRegistry; uiSettings?: IUiSettingsClient; + docLinks: DocLinksStart; toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' @@ -1338,6 +1342,7 @@ export interface AlertsContextValue { |alertTypeRegistry|Registry for alert types.| |actionTypeRegistry|Registry for action types.| |uiSettings|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| +|docLinks|Documentation Links, needed to link to the documentation from informational callouts.| |toastNotifications|Toast messages.| |charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| |dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.test.tsx new file mode 100644 index 0000000000000..85699cfbd750f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { AlertActionSecurityCallOut } from './alert_action_security_call_out'; + +import { EuiCallOut, EuiButton } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; + +const docLinks = { ELASTIC_WEBSITE_URL: 'elastic.co/', DOC_LINK_VERSION: 'current' }; + +const http = httpServiceMock.createStartContract(); + +describe('alert action security call out', () => { + let useEffect: any; + + const mockUseEffect = () => { + // make react execute useEffects despite shallow rendering + useEffect.mockImplementationOnce((f: Function) => f()); + }; + + beforeEach(() => { + jest.resetAllMocks(); + useEffect = jest.spyOn(React, 'useEffect'); + mockUseEffect(); + }); + + test('renders nothing while health is loading', async () => { + http.get.mockImplementationOnce(() => new Promise(() => {})); + + let component: ShallowWrapper | undefined; + await act(async () => { + component = shallow( + + ); + }); + + expect(component?.is(Fragment)).toBeTruthy(); + expect(component?.html()).toBe(''); + }); + + test('renders nothing if keys are enabled', async () => { + http.get.mockResolvedValue({ isSufficientlySecure: true }); + + let component: ShallowWrapper | undefined; + await act(async () => { + component = shallow( + + ); + }); + + expect(component?.is(Fragment)).toBeTruthy(); + expect(component?.html()).toBe(''); + }); + + test('renders the callout if keys are disabled', async () => { + http.get.mockResolvedValue({ isSufficientlySecure: false }); + + let component: ShallowWrapper | undefined; + await act(async () => { + component = shallow( + + ); + }); + + expect(component?.find(EuiCallOut).prop('title')).toMatchInlineSnapshot( + `"Alert creation requires TLS between Elasticsearch and Kibana."` + ); + + expect(component?.find(EuiButton).prop('href')).toMatchInlineSnapshot( + `"elastic.co/guide/en/kibana/current/configuring-tls.html"` + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.tsx new file mode 100644 index 0000000000000..f7a80202dff89 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { Option, none, some, fold, filter } from 'fp-ts/lib/Option'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { DocLinksStart, HttpSetup } from 'kibana/public'; +import { AlertingFrameworkHealth } from '../../types'; +import { health } from '../lib/alert_api'; + +interface Props { + docLinks: Pick; + action: string; + http: HttpSetup; +} + +export const AlertActionSecurityCallOut: React.FunctionComponent = ({ + http, + action, + docLinks, +}) => { + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; + + const [alertingHealth, setAlertingHealth] = React.useState>(none); + + React.useEffect(() => { + async function fetchSecurityConfigured() { + setAlertingHealth(some(await health({ http }))); + } + + fetchSecurityConfigured(); + }, [http]); + + return pipe( + alertingHealth, + filter(healthCheck => !healthCheck.isSufficientlySecure), + fold( + () => , + () => ( + + + + + + + + + ) + ) + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.test.tsx new file mode 100644 index 0000000000000..28bc02ec3392f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.test.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 React, { Fragment } from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { SecurityEnabledCallOut } from './security_call_out'; + +import { EuiCallOut, EuiButton } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; + +const docLinks = { ELASTIC_WEBSITE_URL: 'elastic.co/', DOC_LINK_VERSION: 'current' }; + +const http = httpServiceMock.createStartContract(); + +describe('security call out', () => { + let useEffect: any; + + const mockUseEffect = () => { + // make react execute useEffects despite shallow rendering + useEffect.mockImplementationOnce((f: Function) => f()); + }; + + beforeEach(() => { + jest.resetAllMocks(); + useEffect = jest.spyOn(React, 'useEffect'); + mockUseEffect(); + }); + + test('renders nothing while health is loading', async () => { + http.get.mockImplementationOnce(() => new Promise(() => {})); + + let component: ShallowWrapper | undefined; + await act(async () => { + component = shallow(); + }); + + expect(component?.is(Fragment)).toBeTruthy(); + expect(component?.html()).toBe(''); + }); + + test('renders nothing if keys are enabled', async () => { + http.get.mockResolvedValue({ isSufficientlySecure: true }); + + let component: ShallowWrapper | undefined; + await act(async () => { + component = shallow(); + }); + + expect(component?.is(Fragment)).toBeTruthy(); + expect(component?.html()).toBe(''); + }); + + test('renders the callout if keys are disabled', async () => { + http.get.mockImplementationOnce(async () => ({ isSufficientlySecure: false })); + + let component: ShallowWrapper | undefined; + await act(async () => { + component = shallow(); + }); + + expect(component?.find(EuiCallOut).prop('title')).toMatchInlineSnapshot( + `"Enable Transport Layer Security"` + ); + + expect(component?.find(EuiButton).prop('href')).toMatchInlineSnapshot( + `"elastic.co/guide/en/kibana/current/configuring-tls.html"` + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.tsx new file mode 100644 index 0000000000000..9874a3a0697d2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { Option, none, some, fold, filter } from 'fp-ts/lib/Option'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { DocLinksStart, HttpSetup } from 'kibana/public'; + +import { AlertingFrameworkHealth } from '../../types'; +import { health } from '../lib/alert_api'; + +interface Props { + docLinks: Pick; + http: HttpSetup; +} + +export const SecurityEnabledCallOut: React.FunctionComponent = ({ docLinks, http }) => { + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; + + const [alertingHealth, setAlertingHealth] = React.useState>(none); + + React.useEffect(() => { + async function fetchSecurityConfigured() { + setAlertingHealth(some(await health({ http }))); + } + + fetchSecurityConfigured(); + }, [http]); + + return pipe( + alertingHealth, + filter(healthCheck => !healthCheck?.isSufficientlySecure), + fold( + () => , + () => ( + + +

+ +

+ + + +
+ +
+ ) + ) + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx index 1944cdeab7552..340370cc0314b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx @@ -5,7 +5,7 @@ */ import React, { useContext, createContext } from 'react'; -import { HttpSetup, IUiSettingsClient, ToastsApi } from 'kibana/public'; +import { HttpSetup, IUiSettingsClient, ToastsApi, DocLinksStart } from 'kibana/public'; import { ChartsPluginSetup } from 'src/plugins/charts/public'; import { DataPublicPluginSetup } from 'src/plugins/data/public'; import { TypeRegistry } from '../type_registry'; @@ -22,6 +22,7 @@ export interface AlertsContextValue> { >; uiSettings?: IUiSettingsClient; charts?: ChartsPluginSetup; + docLinks: DocLinksStart; dataFieldsFormats?: DataPublicPluginSetup['fieldFormats']; metadata?: MetaData; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx index 6130233f33815..7c8d798984bf2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx @@ -29,6 +29,7 @@ import { hasShowActionsCapability, hasShowAlertsCapability } from './lib/capabil import { ActionsConnectorsList } from './sections/actions_connectors_list/components/actions_connectors_list'; import { AlertsList } from './sections/alerts_list/components/alerts_list'; +import { SecurityEnabledCallOut } from './components/security_call_out'; import { PLUGIN } from './constants/plugin'; interface MatchParams { @@ -41,7 +42,7 @@ export const TriggersActionsUIHome: React.FunctionComponent { - const { chrome, capabilities, setBreadcrumbs } = useAppDependencies(); + const { chrome, capabilities, setBreadcrumbs, docLinks, http } = useAppDependencies(); const canShowActions = hasShowActionsCapability(capabilities); const canShowAlerts = hasShowAlertsCapability(capabilities); @@ -87,6 +88,7 @@ export const TriggersActionsUIHome: React.FunctionComponent + diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index 453fbc4a9eb4f..b830ac471c4d0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -24,6 +24,7 @@ import { updateAlert, muteAlertInstance, unmuteAlertInstance, + health, } from './alert_api'; import uuid from 'uuid'; @@ -618,3 +619,17 @@ describe('unmuteAlerts', () => { `); }); }); + +describe('health', () => { + test('should call health API', async () => { + const result = await health({ http }); + expect(result).toEqual(undefined); + expect(http.get.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/_health", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index 359c48850549a..0fec2d49df986 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { pick } from 'lodash'; -import { alertStateSchema } from '../../../../alerting/common'; +import { alertStateSchema, AlertingFrameworkHealth } from '../../../../alerting/common'; import { BASE_ALERT_API_PATH } from '../constants'; import { Alert, AlertType, AlertWithoutId, AlertTaskState } from '../../types'; @@ -214,3 +214,7 @@ export async function unmuteAlerts({ }): Promise { await Promise.all(ids.map(id => unmuteAlert({ id, http }))); } + +export async function health({ http }: { http: HttpSetup }): Promise { + return await http.get(`${BASE_ALERT_API_PATH}/_health`); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index fc524debe7443..ff83737325e8b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -17,6 +17,7 @@ import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { ReactWrapper } from 'enzyme'; +import { AppContextProvider } from '../../app_context'; const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -49,7 +50,11 @@ describe('alert_add', () => { charts: chartPluginMock.createStartContract(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; + + mockes.http.get.mockResolvedValue({ isSufficientlySecure: true }); + const alertType = { id: 'my-alert-type', iconClass: 'test', @@ -83,22 +88,30 @@ describe('alert_add', () => { actionTypeRegistry.has.mockReturnValue(true); wrapper = mountWithIntl( - { - return new Promise(() => {}); - }, - http: deps.http, - actionTypeRegistry: deps.actionTypeRegistry, - alertTypeRegistry: deps.alertTypeRegistry, - toastNotifications: deps.toastNotifications, - uiSettings: deps.uiSettings, - metadata: { test: 'some value', fields: ['test'] }, - }} - > - {}} /> - + + { + return new Promise(() => {}); + }, + http: deps.http, + actionTypeRegistry: deps.actionTypeRegistry, + alertTypeRegistry: deps.alertTypeRegistry, + toastNotifications: deps.toastNotifications, + uiSettings: deps.uiSettings, + docLinks: deps.docLinks, + metadata: { test: 'some value', fields: ['test'] }, + }} + > + {}} + /> + + ); + // Wait for active space to resolve before requesting the component to update await act(async () => { await nextTick(); @@ -108,12 +121,15 @@ describe('alert_add', () => { it('renders alert add flyout', async () => { await setup(); + expect(wrapper.find('[data-test-subj="addAlertFlyoutTitle"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="saveAlertButton"]').exists()).toBeTruthy(); + wrapper .find('[data-test-subj="my-alert-type-SelectOption"]') .first() .simulate('click'); + expect(wrapper.contains('Metadata: some value. Fields: test.')).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index 6600a2379cd23..e44e20751b315 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -24,6 +24,7 @@ import { Alert, AlertAction, IErrorObject } from '../../../types'; import { AlertForm, validateBaseProperties } from './alert_form'; import { alertReducer } from './alert_reducer'; import { createAlert } from '../../lib/alert_api'; +import { AlertActionSecurityCallOut } from '../../components/alert_action_security_call_out'; import { PLUGIN } from '../../constants/plugin'; interface AlertAddProps { @@ -65,6 +66,7 @@ export const AlertAdd = ({ toastNotifications, alertTypeRegistry, actionTypeRegistry, + docLinks, } = useAlertsContext(); const closeFlyout = useCallback(() => { @@ -151,6 +153,16 @@ export const AlertAdd = ({

+ { uiSettings: mockedCoreSetup.uiSettings, actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; + + mockedCoreSetup.http.get.mockResolvedValue({ isSufficientlySecure: true }); + const alertType = { id: 'my-alert-type', iconClass: 'test', @@ -102,24 +107,27 @@ describe('alert_edit', () => { actionTypeRegistry.has.mockReturnValue(true); wrapper = mountWithIntl( - { - return new Promise(() => {}); - }, - http: deps!.http, - actionTypeRegistry: deps!.actionTypeRegistry, - alertTypeRegistry: deps!.alertTypeRegistry, - toastNotifications: deps!.toastNotifications, - uiSettings: deps!.uiSettings, - }} - > - {}} - initialAlert={alert} - /> - + + { + return new Promise(() => {}); + }, + http: deps!.http, + actionTypeRegistry: deps!.actionTypeRegistry, + alertTypeRegistry: deps!.alertTypeRegistry, + toastNotifications: deps!.toastNotifications, + uiSettings: deps!.uiSettings, + docLinks: deps.docLinks, + }} + > + {}} + initialAlert={alert} + /> + + ); // Wait for active space to resolve before requesting the component to update await act(async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx index 41ab3279f91c6..3f27a7860bafa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx @@ -26,6 +26,7 @@ import { Alert, AlertAction, IErrorObject } from '../../../types'; import { AlertForm, validateBaseProperties } from './alert_form'; import { alertReducer } from './alert_reducer'; import { updateAlert } from '../../lib/alert_api'; +import { AlertActionSecurityCallOut } from '../../components/alert_action_security_call_out'; import { PLUGIN } from '../../constants/plugin'; interface AlertEditProps { @@ -49,6 +50,7 @@ export const AlertEdit = ({ toastNotifications, alertTypeRegistry, actionTypeRegistry, + docLinks, } = useAlertsContext(); const closeFlyout = useCallback(() => { @@ -135,6 +137,16 @@ export const AlertEdit = ({

+ {hasActionsDisabled && ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx index b87aaacb3ec0e..72c22f46f217e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx @@ -53,6 +53,7 @@ describe('alert_form', () => { uiSettings: mockes.uiSettings, actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; alertTypeRegistry.list.mockReturnValue([alertType]); alertTypeRegistry.has.mockReturnValue(true); @@ -80,6 +81,7 @@ describe('alert_form', () => { return new Promise(() => {}); }, http: deps!.http, + docLinks: deps.docLinks, actionTypeRegistry: deps!.actionTypeRegistry, alertTypeRegistry: deps!.alertTypeRegistry, toastNotifications: deps!.toastNotifications, @@ -159,6 +161,7 @@ describe('alert_form', () => { return new Promise(() => {}); }, http: deps!.http, + docLinks: deps.docLinks, actionTypeRegistry: deps!.actionTypeRegistry, alertTypeRegistry: deps!.alertTypeRegistry, toastNotifications: deps!.toastNotifications, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 9f10528b1dd46..2be8418cd9802 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -59,6 +59,7 @@ export const AlertsList: React.FunctionComponent = () => { alertTypeRegistry, actionTypeRegistry, uiSettings, + docLinks, charts, dataPlugin, } = useAppDependencies(); @@ -480,6 +481,7 @@ export const AlertsList: React.FunctionComponent = () => { alertTypeRegistry, toastNotifications, uiSettings, + docLinks, charts, dataFieldsFormats: dataPlugin.fieldFormats, }} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx index 0ba590ab462a7..a60b7e68f1f94 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -6,7 +6,7 @@ import React from 'react'; -import { Alert, AlertType, AlertTaskState } from '../../../../types'; +import { Alert, AlertType, AlertTaskState, AlertingFrameworkHealth } from '../../../../types'; import { useAppDependencies } from '../../../app_context'; import { deleteAlerts, @@ -23,6 +23,7 @@ import { loadAlert, loadAlertState, loadAlertTypes, + health, } from '../../../lib/alert_api'; export interface ComponentOpts { @@ -51,6 +52,7 @@ export interface ComponentOpts { loadAlert: (id: Alert['id']) => Promise; loadAlertState: (id: Alert['id']) => Promise; loadAlertTypes: () => Promise; + getHealth: () => Promise; } export type PropsWithOptionalApiHandlers = Omit & Partial; @@ -115,6 +117,7 @@ export function withBulkAlertOperations( loadAlert={async (alertId: Alert['id']) => loadAlert({ http, alertId })} loadAlertState={async (alertId: Alert['id']) => loadAlertState({ http, alertId })} loadAlertTypes={async () => loadAlertTypes({ http })} + getHealth={async () => health({ http })} /> ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 900521830571c..7dfaa7b918f70 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -12,8 +12,9 @@ import { AlertAction, AlertTaskState, RawAlertInstance, + AlertingFrameworkHealth, } from '../../../plugins/alerting/common'; -export { Alert, AlertAction, AlertTaskState, RawAlertInstance }; +export { Alert, AlertAction, AlertTaskState, RawAlertInstance, AlertingFrameworkHealth }; export { ActionType }; export type ActionTypeIndex = Record; From 0fc0440cdc59df97d10b4a0161cd516ef87a998f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Tue, 24 Mar 2020 20:28:11 +0100 Subject: [PATCH 22/56] [APM] E2E: Zero config for running e2e locally (#59152) --- .gitignore | 3 + x-pack/legacy/plugins/apm/e2e/.gitignore | 3 +- x-pack/legacy/plugins/apm/e2e/README.md | 54 +- .../legacy/plugins/apm/e2e/ci/entrypoint.sh | 2 +- .../legacy/plugins/apm/e2e/ci/kibana.dev.yml | 7 - .../legacy/plugins/apm/e2e/ci/kibana.e2e.yml | 31 + .../plugins/apm/e2e/ci/prepare-kibana.sh | 11 +- x-pack/legacy/plugins/apm/e2e/cypress.json | 9 +- .../apm/e2e/cypress/integration/apm.feature | 4 +- .../apm/e2e/cypress/integration/helpers.ts | 41 +- .../apm/e2e/cypress/integration/snapshots.js | 19 +- .../cypress/support/step_definitions/apm.ts | 8 +- .../e2e/{cypress => }/ingest-data/replay.js | 61 +- x-pack/legacy/plugins/apm/e2e/package.json | 14 +- x-pack/legacy/plugins/apm/e2e/run-e2e.sh | 93 ++ x-pack/legacy/plugins/apm/e2e/tsconfig.json | 11 +- x-pack/legacy/plugins/apm/e2e/yarn.lock | 1210 +++++++++-------- 17 files changed, 873 insertions(+), 708 deletions(-) delete mode 100644 x-pack/legacy/plugins/apm/e2e/ci/kibana.dev.yml create mode 100644 x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml rename x-pack/legacy/plugins/apm/e2e/{cypress => }/ingest-data/replay.js (60%) create mode 100755 x-pack/legacy/plugins/apm/e2e/run-e2e.sh diff --git a/.gitignore b/.gitignore index efb5c57774633..bd7a954f950e9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ package-lock.json *.sublime-* npm-debug.log* .tern-project +x-pack/legacy/plugins/apm/tsconfig.json +apm.tsconfig.json +/x-pack/legacy/plugins/apm/e2e/snapshots.js diff --git a/x-pack/legacy/plugins/apm/e2e/.gitignore b/x-pack/legacy/plugins/apm/e2e/.gitignore index 10c769065fc28..a14856506bc6c 100644 --- a/x-pack/legacy/plugins/apm/e2e/.gitignore +++ b/x-pack/legacy/plugins/apm/e2e/.gitignore @@ -1,4 +1,3 @@ -cypress/ingest-data/events.json cypress/screenshots/* - cypress/test-results +tmp diff --git a/x-pack/legacy/plugins/apm/e2e/README.md b/x-pack/legacy/plugins/apm/e2e/README.md index 73a1e860f5564..a891d64539a3f 100644 --- a/x-pack/legacy/plugins/apm/e2e/README.md +++ b/x-pack/legacy/plugins/apm/e2e/README.md @@ -1,58 +1,16 @@ # End-To-End (e2e) Test for APM UI -## Ingest static data into Elasticsearch via APM Server +**Run E2E tests** -1. Start Elasticsearch and APM Server, using [apm-integration-testing](https://github.com/elastic/apm-integration-testing): - -```shell -$ git clone https://github.com/elastic/apm-integration-testing.git -$ cd apm-integration-testing -./scripts/compose.py start master --no-kibana --no-xpack-secure -``` - -2. Download [static data file](https://storage.googleapis.com/apm-ui-e2e-static-data/events.json) - -```shell -$ cd x-pack/legacy/plugins/apm/e2e/cypress/ingest-data -$ curl https://storage.googleapis.com/apm-ui-e2e-static-data/events.json --output events.json -``` - -3. Post to APM Server - -```shell -$ cd x-pack/legacy/plugins/apm/e2e/cypress/ingest-data -$ node replay.js --server-url http://localhost:8200 --secret-token abcd --events ./events.json -``` ->This process will take a few minutes to ingest all data - -4. Start Kibana - -```shell -$ yarn kbn bootstrap -$ yarn start --no-base-path --csp.strict=false -``` - -> Content Security Policy (CSP) Settings: Your Kibana instance must have the `csp.strict: false`. - -## How to run the tests - -_Note: Run the following commands from `kibana/x-pack/legacy/plugins/apm/e2e/cypress`._ - -### Interactive mode - -``` -yarn cypress open +```sh +x-pack/legacy/plugins/apm/e2e/run-e2e.sh ``` -### Headless mode - -``` -yarn cypress run -``` +_Starts Kibana, APM Server, Elasticsearch (with sample data) and runs the tests_ ## Reproducing CI builds ->This process is very slow compared to the local development described above. Consider that the CI must install and configure the build tools and create a Docker image for the project to run tests in a consistent manner. +> This process is very slow compared to the local development described above. Consider that the CI must install and configure the build tools and create a Docker image for the project to run tests in a consistent manner. The Jenkins CI uses a shell script to prepare Kibana: @@ -60,7 +18,7 @@ The Jenkins CI uses a shell script to prepare Kibana: # Prepare and run Kibana locally $ x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh # Build Docker image for Kibana -$ docker build --tag cypress --build-arg NODE_VERSION=$(cat .node-version) x-pack/legacy/plugins/apm/e2e/ci +$ docker build --tag cypress --build-arg NODE_VERSION=$(cat .node-version) x-pack/legacy/plugins/apm/e2e/ci # Run Docker image $ docker run --rm -t --user "$(id -u):$(id -g)" \ -v `pwd`:/app --network="host" \ diff --git a/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh b/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh index f7226dca1d276..ae5155d966e58 100755 --- a/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh +++ b/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh @@ -7,7 +7,7 @@ if [ -z "${kibana}" ] ; then kibana=127.0.0.1 fi -export CYPRESS_BASE_URL=http://${kibana}:5601 +export CYPRESS_BASE_URL=http://${kibana}:5701 ## To avoid issues with the home and caching artifacts export HOME=/tmp diff --git a/x-pack/legacy/plugins/apm/e2e/ci/kibana.dev.yml b/x-pack/legacy/plugins/apm/e2e/ci/kibana.dev.yml deleted file mode 100644 index db57db9a1abe9..0000000000000 --- a/x-pack/legacy/plugins/apm/e2e/ci/kibana.dev.yml +++ /dev/null @@ -1,7 +0,0 @@ -## -# Disabled plugins -######################## -logging.verbose: true -elasticsearch.username: "kibana_system_user" -elasticsearch.password: "changeme" -xpack.security.encryptionKey: "something_at_least_32_characters" diff --git a/x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml b/x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml new file mode 100644 index 0000000000000..19f3f7c8978fa --- /dev/null +++ b/x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml @@ -0,0 +1,31 @@ +# Kibana +server.port: 5701 +xpack.security.encryptionKey: 'something_at_least_32_characters' +csp.strict: false +logging.verbose: true + +# Elasticsearch +# Started via apm-integration-testing +# ./scripts/compose.py start master --no-kibana --elasticsearch-port 9201 --apm-server-port 8201 +elasticsearch.hosts: http://localhost:9201 +elasticsearch.username: 'kibana_system_user' +elasticsearch.password: 'changeme' + +# APM index pattern +apm_oss.indexPattern: apm-* + +# APM Indices +apm_oss.errorIndices: apm-*-error* +apm_oss.sourcemapIndices: apm-*-sourcemap +apm_oss.transactionIndices: apm-*-transaction* +apm_oss.spanIndices: apm-*-span* +apm_oss.metricsIndices: apm-*-metric* +apm_oss.onboardingIndices: apm-*-onboarding* + +# APM options +xpack.apm.enabled: true +xpack.apm.serviceMapEnabled: false +xpack.apm.autocreateApmIndexPattern: true +xpack.apm.ui.enabled: true +xpack.apm.ui.transactionGroupBucketSize: 100 +xpack.apm.ui.maxTraceItems: 1000 diff --git a/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh b/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh index 4f176fd0070f5..6df17bd51e0e8 100755 --- a/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh +++ b/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh @@ -1,24 +1,21 @@ #!/usr/bin/env bash set -e -CYPRESS_DIR="x-pack/legacy/plugins/apm/e2e" +E2E_DIR="x-pack/legacy/plugins/apm/e2e" echo "1/3 Install dependencies ..." # shellcheck disable=SC1091 source src/dev/ci_setup/setup_env.sh true yarn kbn bootstrap -cp ${CYPRESS_DIR}/ci/kibana.dev.yml config/kibana.dev.yml -echo 'elasticsearch:' >> config/kibana.dev.yml -cp ${CYPRESS_DIR}/ci/kibana.dev.yml config/kibana.yml echo "2/3 Ingest test data ..." -pushd ${CYPRESS_DIR} +pushd ${E2E_DIR} yarn install curl --silent https://storage.googleapis.com/apm-ui-e2e-static-data/events.json --output ingest-data/events.json -node ingest-data/replay.js --server-url http://localhost:8200 --secret-token abcd --events ./events.json > ingest-data.log +node ingest-data/replay.js --server-url http://localhost:8201 --secret-token abcd --events ./events.json > ingest-data.log echo "3/3 Start Kibana ..." popd ## Might help to avoid FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory export NODE_OPTIONS="--max-old-space-size=4096" -nohup node scripts/kibana --no-base-path --csp.strict=false --optimize.watch=false> kibana.log 2>&1 & +nohup node scripts/kibana --config "${E2E_DIR}/ci/kibana.e2e.yml" --no-base-path --optimize.watch=false> kibana.log 2>&1 & diff --git a/x-pack/legacy/plugins/apm/e2e/cypress.json b/x-pack/legacy/plugins/apm/e2e/cypress.json index 310964656f107..0894cfd13a197 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress.json +++ b/x-pack/legacy/plugins/apm/e2e/cypress.json @@ -1,5 +1,6 @@ { - "baseUrl": "http://localhost:5601", + "nodeVersion": "system", + "baseUrl": "http://localhost:5701", "video": false, "trashAssetsBeforeRuns": false, "fileServerFolder": "../", @@ -15,5 +16,9 @@ "mochaFile": "./cypress/test-results/[hash]-e2e-tests.xml", "toConsole": false }, - "testFiles": "**/*.{feature,features}" + "testFiles": "**/*.{feature,features}", + "env": { + "elasticsearch_username": "admin", + "elasticsearch_password": "changeme" + } } diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature b/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature index 01fee2bf68b09..285615108266b 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature +++ b/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature @@ -2,6 +2,6 @@ Feature: APM Scenario: Transaction duration charts Given a user browses the APM UI application - When the user inspects the opbeans-go service + When the user inspects the opbeans-node service Then should redirect to correct path with correct params - And should have correct y-axis ticks \ No newline at end of file + And should have correct y-axis ticks diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts b/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts index 1239ef397e086..90d5c9eda632d 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts +++ b/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts @@ -6,45 +6,26 @@ /* eslint-disable import/no-extraneous-dependencies */ -import { safeLoad } from 'js-yaml'; - -const RANGE_FROM = '2019-09-04T18:00:00.000Z'; -const RANGE_TO = '2019-09-05T06:00:00.000Z'; +const RANGE_FROM = '2020-03-04T12:30:00.000Z'; +const RANGE_TO = '2020-03-04T13:00:00.000Z'; const BASE_URL = Cypress.config().baseUrl; -/** - * Credentials in the `kibana.dev.yml` config file will be used to authenticate with Kibana - */ -const KIBANA_DEV_YML_PATH = '../../../../../config/kibana.dev.yml'; - /** The default time in ms to wait for a Cypress command to complete */ -export const DEFAULT_TIMEOUT = 30 * 1000; +export const DEFAULT_TIMEOUT = 60 * 1000; export function loginAndWaitForPage(url: string) { - // read the login details from `kibana.dev.yml` - cy.readFile(KIBANA_DEV_YML_PATH).then(kibanaDevYml => { - const config = safeLoad(kibanaDevYml); - const username = config['elasticsearch.username']; - const password = config['elasticsearch.password']; - - const hasCredentials = username && password; - - cy.log( - `Authenticating via config credentials from "${KIBANA_DEV_YML_PATH}". username: ${username}, password: ${password}` - ); + const username = Cypress.env('elasticsearch_username'); + const password = Cypress.env('elasticsearch_password'); - const options = hasCredentials - ? { - auth: { username, password } - } - : {}; + cy.log(`Authenticating via ${username} / ${password}`); - const fullUrl = `${BASE_URL}${url}?rangeFrom=${RANGE_FROM}&rangeTo=${RANGE_TO}`; - cy.visit(fullUrl, options); - }); + const fullUrl = `${BASE_URL}${url}?rangeFrom=${RANGE_FROM}&rangeTo=${RANGE_TO}`; + cy.visit(fullUrl, { auth: { username, password } }); cy.viewport('macbook-15'); // wait for loading spinner to disappear - cy.get('.kibanaLoaderWrap', { timeout: DEFAULT_TIMEOUT }).should('not.exist'); + cy.get('#kbn_loading_message', { timeout: DEFAULT_TIMEOUT }).should( + 'not.exist' + ); } diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js b/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js index 0e4b91ab45a40..968c2675a62e7 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js +++ b/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js @@ -1,19 +1,10 @@ module.exports = { - "When clicking opbeans-go service": { - "transaction duration charts": { - "should have correct y-axis ticks": { - "1": "3.7 min", - "2": "1.8 min", - "3": "0.0 min" - } - } - }, - "__version": "3.8.3", "APM": { "Transaction duration charts": { - "1": "3.7 min", - "2": "1.8 min", - "3": "0.0 min" + "1": "500 ms", + "2": "250 ms", + "3": "0 ms" } - } + }, + "__version": "4.2.0" } diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts b/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts index f2f1e515f967a..f58118f3352ea 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts +++ b/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts @@ -12,15 +12,15 @@ Given(`a user browses the APM UI application`, () => { loginAndWaitForPage(`/app/apm#/services`); }); -When(`the user inspects the opbeans-go service`, () => { - // click opbeans-go service - cy.get(':contains(opbeans-go)') +When(`the user inspects the opbeans-node service`, () => { + // click opbeans-node service + cy.get(':contains(opbeans-node)') .last() .click({ force: true }); }); Then(`should redirect to correct path with correct params`, () => { - cy.url().should('contain', `/app/apm#/services/opbeans-go/transactions`); + cy.url().should('contain', `/app/apm#/services/opbeans-node/transactions`); cy.url().should('contain', `transactionType=request`); }); diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/ingest-data/replay.js b/x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js similarity index 60% rename from x-pack/legacy/plugins/apm/e2e/cypress/ingest-data/replay.js rename to x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js index 990fc37bb7b2e..59cd34704d624 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/ingest-data/replay.js +++ b/x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js @@ -4,15 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable no-console */ +/* eslint-disable import/no-extraneous-dependencies */ + /** * This script is useful for ingesting previously generated APM data into Elasticsearch via APM Server * * You can either: * 1. Download a static test data file from: https://storage.googleapis.com/apm-ui-e2e-static-data/events.json - * 2. Or, generate the test data file yourself by following the steps in: https://github.com/elastic/kibana/blob/5207a0b68a66d4f513fe1b0cedb021b296641712/x-pack/legacy/plugins/apm/cypress/README.md#generate-static-data + * 2. Or, generate the test data file yourself: + * git clone https://github.com/elastic/apm-integration-testing.git + * ./scripts/compose.py start master --no-kibana --with-opbeans-node --apm-server-record + * docker cp localtesting_8.0.0_apm-server-2:/app/events.json . && cat events.json | wc -l + * + * * * Run the script: * @@ -27,6 +33,7 @@ const axios = require('axios'); const readFile = promisify(fs.readFile); const pLimit = require('p-limit'); const { argv } = require('yargs'); +const ora = require('ora'); const APM_SERVER_URL = argv.serverUrl; const SECRET_TOKEN = argv.secretToken; @@ -43,10 +50,27 @@ if (!EVENTS_PATH) { } const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +const requestProgress = { + succeeded: 0, + failed: 0, + total: 0 +}; + +const spinner = ora({ text: 'Warming up...', stream: process.stdout }); + +function updateSpinnerText({ success }) { + success ? requestProgress.succeeded++ : requestProgress.failed++; + const remaining = + requestProgress.total - + (requestProgress.succeeded + requestProgress.failed); + + spinner.text = `Remaining: ${remaining}. Succeeded: ${requestProgress.succeeded}. Failed: ${requestProgress.failed}.`; +} + async function insertItem(item) { try { const url = `${APM_SERVER_URL}${item.url}`; - console.log(Date.now(), url); const headers = { 'content-type': 'application/x-ndjson' @@ -63,20 +87,20 @@ async function insertItem(item) { data: item.body }); + updateSpinnerText({ success: true }); + // add delay to avoid flooding the queue return delay(500); } catch (e) { - console.log('an error occurred'); - if (e.response) { - console.log(e.response.data); - } else { - console.log('error', e); - } + console.error( + `${e.response ? JSON.stringify(e.response.data) : e.message}` + ); + updateSpinnerText({ success: false }); } } async function init() { - const content = await readFile(path.resolve(__dirname, EVENTS_PATH)); + const content = await readFile(path.resolve(EVENTS_PATH)); const items = content .toString() .split('\n') @@ -84,10 +108,21 @@ async function init() { .map(item => JSON.parse(item)) .filter(item => item.url === '/intake/v2/events'); + spinner.start(); + requestProgress.total = items.length; + const limit = pLimit(20); // number of concurrent requests await Promise.all(items.map(item => limit(() => insertItem(item)))); } -init().catch(e => { - console.log('An error occurred:', e); -}); +init() + .catch(e => { + console.log('An error occurred:', e); + process.exit(1); + }) + .then(() => { + spinner.succeed( + `Successfully ingested ${requestProgress.succeeded} of ${requestProgress.total} events` + ); + process.exit(0); + }); diff --git a/x-pack/legacy/plugins/apm/e2e/package.json b/x-pack/legacy/plugins/apm/e2e/package.json index c9026636e64fb..e298be7db514c 100644 --- a/x-pack/legacy/plugins/apm/e2e/package.json +++ b/x-pack/legacy/plugins/apm/e2e/package.json @@ -9,16 +9,18 @@ }, "dependencies": { "@cypress/snapshot": "^2.1.3", - "@cypress/webpack-preprocessor": "^4.1.0", - "@types/cypress-cucumber-preprocessor": "^1.14.0", + "@cypress/webpack-preprocessor": "^4.1.3", + "@types/cypress-cucumber-preprocessor": "^1.14.1", "@types/js-yaml": "^3.12.1", "@types/node": "^10.12.11", - "cypress": "^3.5.0", + "cypress": "^4.2.0", "cypress-cucumber-preprocessor": "^2.0.1", "js-yaml": "^3.13.1", + "ora": "^4.0.3", "p-limit": "^2.2.1", - "ts-loader": "^6.1.0", - "typescript": "3.7.5", - "webpack": "^4.41.5" + "ts-loader": "^6.2.2", + "typescript": "3.8.3", + "wait-on": "^4.0.1", + "webpack": "^4.42.1" } } diff --git a/x-pack/legacy/plugins/apm/e2e/run-e2e.sh b/x-pack/legacy/plugins/apm/e2e/run-e2e.sh new file mode 100755 index 0000000000000..6c9ac83678682 --- /dev/null +++ b/x-pack/legacy/plugins/apm/e2e/run-e2e.sh @@ -0,0 +1,93 @@ +# variables +KIBANA_PORT=5701 +ELASTICSEARCH_PORT=9201 +APM_SERVER_PORT=8201 + +# ensure Docker is running +docker ps &> /dev/null +if [ $? -ne 0 ]; then + echo "⚠️ Please start Docker" + exit 1 +fi + +# formatting +bold=$(tput bold) +normal=$(tput sgr0) + +# Create tmp folder +mkdir -p tmp + +# Ask user to start Kibana +echo " +${bold}To start Kibana please run the following command:${normal} + +node ./scripts/kibana --no-base-path --dev --no-dev-config --config x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml +" + +# Clone or pull apm-integration-testing +printf "\n${bold}=== apm-integration-testing ===\n${normal}" + +git clone "https://github.com/elastic/apm-integration-testing.git" "./tmp/apm-integration-testing" &> /dev/null +if [ $? -eq 0 ]; then + echo "Cloning repository" +else + echo "Pulling from master..." + git -C "./tmp/apm-integration-testing" pull &> /dev/null +fi + +# Start apm-integration-testing +echo "Starting (logs: ./tmp/apm-it.log)" +./tmp/apm-integration-testing/scripts/compose.py start master \ + --no-kibana \ + --elasticsearch-port $ELASTICSEARCH_PORT \ + --apm-server-port=$APM_SERVER_PORT \ + --elasticsearch-heap 4g \ + &> ./tmp/apm-it.log + +# Stop if apm-integration-testing failed to start correctly +if [ $? -ne 0 ]; then + printf "⚠️ apm-integration-testing could not be started.\n" + printf "Please see the logs in ./tmp/apm-it.log\n\n" + printf "As a last resort, reset docker with:\n\n./tmp/apm-integration-testing/scripts/compose.py stop && system prune --all --force --volumes\n" + exit 1 +fi + +printf "\n${bold}=== Static mock data ===\n${normal}" + +# Download static data if not already done +if [ -e "./tmp/events.json" ]; then + echo 'Skip: events.json already exists. Not downloading' +else + echo 'Downloading events.json...' + curl --silent https://storage.googleapis.com/apm-ui-e2e-static-data/events.json --output ./tmp/events.json +fi + +# echo "Deleting existing indices (apm* and .apm*)" +curl --silent --user admin:changeme -XDELETE "localhost:${ELASTICSEARCH_PORT}/.apm*" > /dev/null +curl --silent --user admin:changeme -XDELETE "localhost:${ELASTICSEARCH_PORT}/apm*" > /dev/null + +# Ingest data into APM Server +echo "Ingesting data (logs: tmp/ingest-data.log)" +node ingest-data/replay.js --server-url http://localhost:$APM_SERVER_PORT --events ./tmp/events.json 2> ./tmp/ingest-data.log + +# Install local dependencies +printf "\n" +echo "Installing local dependencies (logs: tmp/e2e-yarn.log)" +yarn &> ./tmp/e2e-yarn.log + +# Wait for Kibana to start +echo "Waiting for Kibana to start..." +yarn wait-on -i 500 -w 500 http://localhost:$KIBANA_PORT > /dev/null + +echo "\n✅ Setup completed successfully. Running tests...\n" + +# run cypress tests +yarn cypress run --config pageLoadTimeout=100000,watchForFileChanges=true + +echo " + +${bold}If you want to run the test interactively, run:${normal} + +yarn cypress open --config pageLoadTimeout=100000,watchForFileChanges=true +" + diff --git a/x-pack/legacy/plugins/apm/e2e/tsconfig.json b/x-pack/legacy/plugins/apm/e2e/tsconfig.json index de498816e30a4..a7091a20186b2 100644 --- a/x-pack/legacy/plugins/apm/e2e/tsconfig.json +++ b/x-pack/legacy/plugins/apm/e2e/tsconfig.json @@ -1,13 +1,8 @@ { "extends": "../../../../tsconfig.json", - "exclude": [], - "include": [ - "./**/*" - ], + "exclude": ["tmp"], + "include": ["./**/*"], "compilerOptions": { - "types": [ - "cypress", - "node" - ] + "types": ["cypress", "node"] } } diff --git a/x-pack/legacy/plugins/apm/e2e/yarn.lock b/x-pack/legacy/plugins/apm/e2e/yarn.lock index 48e6013fb6986..474337931d665 100644 --- a/x-pack/legacy/plugins/apm/e2e/yarn.lock +++ b/x-pack/legacy/plugins/apm/e2e/yarn.lock @@ -932,10 +932,10 @@ snap-shot-compare "2.8.3" snap-shot-store "1.2.3" -"@cypress/webpack-preprocessor@^4.1.0": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@cypress/webpack-preprocessor/-/webpack-preprocessor-4.1.1.tgz#3c0b5b8de6eaac605dac3b1f1c3f5916c1c6eaea" - integrity sha512-SfzDqOvWBSlfGRm8ak/XHUXAnndwHU2qJIRr1LIC7j2UqWcZoJ+286CuNloJbkwfyEAO6tQggLd4E/WHUAcKZQ== +"@cypress/webpack-preprocessor@^4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@cypress/webpack-preprocessor/-/webpack-preprocessor-4.1.3.tgz#d5fad767a304c16ec05ca08034827c601f1c9c0c" + integrity sha512-VtTzStrKtwyftLkcgopwCHzgjefK3uHHL6FgbAQP1o5N1pa/zYUb0g7hH2skrMAlKOmLGdbySlISkUl18Y3wHg== dependencies: bluebird "3.7.1" debug "4.1.1" @@ -952,10 +952,62 @@ debug "^3.1.0" lodash.once "^4.1.1" -"@types/cypress-cucumber-preprocessor@^1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@types/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.14.0.tgz#41d8ffb2b608d3ed4ab998a0c4394056f75af1e0" - integrity sha512-bOl4u6seZtxNIGa6J6xydroPntTxxWy8uqIrZ3OY10C96fUes4mZvJKY6NvOoe61/OVafG/UEFa+X2ZWKE6Ltw== +"@hapi/address@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@hapi/address/-/address-4.0.0.tgz#36affb4509b5a6adc628bcc394450f2a7d51d111" + integrity sha512-GDDpkCdSUfkQCznmWUHh9dDN85BWf/V8TFKQ2JLuHdGB4Yy3YTEGBzZxoBNxfNBEvreSR/o+ZxBBSNNEVzY+lQ== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@hapi/formula@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-2.0.0.tgz#edade0619ed58c8e4f164f233cda70211e787128" + integrity sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A== + +"@hapi/hoek@^9.0.0": + version "9.0.3" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.0.3.tgz#e49e637d5de8faa4f0d313c2590b455d7c00afd7" + integrity sha512-jKtjLLDiH95b002sJVc5c74PE6KKYftuyVdVmsuYId5stTaWcRFqE+5ukZI4gDUKjGn8wv2C3zPn3/nyjEI7gg== + +"@hapi/joi@^17.1.0": + version "17.1.0" + resolved "https://registry.yarnpkg.com/@hapi/joi/-/joi-17.1.0.tgz#cc4000b6c928a6a39b9bef092151b6bdee10ce55" + integrity sha512-ob67RcPlwRWxBzLCnWvcwx5qbwf88I3ykD7gcJLWOTRfLLgosK7r6aeChz4thA3XRvuBfI0KB1tPVl2EQFlPXw== + dependencies: + "@hapi/address" "^4.0.0" + "@hapi/formula" "^2.0.0" + "@hapi/hoek" "^9.0.0" + "@hapi/pinpoint" "^2.0.0" + "@hapi/topo" "^5.0.0" + +"@hapi/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@hapi/pinpoint/-/pinpoint-2.0.0.tgz#805b40d4dbec04fc116a73089494e00f073de8df" + integrity sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw== + +"@hapi/topo@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.0.0.tgz#c19af8577fa393a06e9c77b60995af959be721e7" + integrity sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@samverschueren/stream-to-observable@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" + integrity sha512-MI4Xx6LHs4Webyvi6EbspgyAb4D2Q2VtnCQ1blOJcoLS6mVa8lNN2rkIy1CVxfTUpoyIbCTkXES1rLXztFD1lg== + dependencies: + any-observable "^0.3.0" + +"@types/color-name@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" + integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== + +"@types/cypress-cucumber-preprocessor@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@types/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.14.1.tgz#9787f4e89553ebc6359ce157a26ad51ed14aa98b" + integrity sha512-CpYsiQ49UrOmadhFg0G5RkokPUmGGctD01mOWjNxFxHw5VgIRv33L2RyFHL8klaAI4HaedGN3Tcj4HTQ65hn+A== "@types/js-yaml@^3.12.1": version "3.12.2" @@ -972,150 +1024,149 @@ resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47" integrity sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg== -"@webassemblyjs/ast@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" - integrity sha512-aJMfngIZ65+t71C3y2nBBg5FFG0Okt9m0XEgWZ7Ywgn1oMAT8cNwx00Uv1cQyHtidq0Xn94R4TAywO+LCQ+ZAQ== - dependencies: - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/wast-parser" "1.8.5" - -"@webassemblyjs/floating-point-hex-parser@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.8.5.tgz#1ba926a2923613edce496fd5b02e8ce8a5f49721" - integrity sha512-9p+79WHru1oqBh9ewP9zW95E3XAo+90oth7S5Re3eQnECGq59ly1Ri5tsIipKGpiStHsUYmY3zMLqtk3gTcOtQ== - -"@webassemblyjs/helper-api-error@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.8.5.tgz#c49dad22f645227c5edb610bdb9697f1aab721f7" - integrity sha512-Za/tnzsvnqdaSPOUXHyKJ2XI7PDX64kWtURyGiJJZKVEdFOsdKUCPTNEVFZq3zJ2R0G5wc2PZ5gvdTRFgm81zA== - -"@webassemblyjs/helper-buffer@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.8.5.tgz#fea93e429863dd5e4338555f42292385a653f204" - integrity sha512-Ri2R8nOS0U6G49Q86goFIPNgjyl6+oE1abW1pS84BuhP1Qcr5JqMwRFT3Ah3ADDDYGEgGs1iyb1DGX+kAi/c/Q== - -"@webassemblyjs/helper-code-frame@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.8.5.tgz#9a740ff48e3faa3022b1dff54423df9aa293c25e" - integrity sha512-VQAadSubZIhNpH46IR3yWO4kZZjMxN1opDrzePLdVKAZ+DFjkGD/rf4v1jap744uPVU6yjL/smZbRIIJTOUnKQ== - dependencies: - "@webassemblyjs/wast-printer" "1.8.5" - -"@webassemblyjs/helper-fsm@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.8.5.tgz#ba0b7d3b3f7e4733da6059c9332275d860702452" - integrity sha512-kRuX/saORcg8se/ft6Q2UbRpZwP4y7YrWsLXPbbmtepKr22i8Z4O3V5QE9DbZK908dh5Xya4Un57SDIKwB9eow== - -"@webassemblyjs/helper-module-context@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.8.5.tgz#def4b9927b0101dc8cbbd8d1edb5b7b9c82eb245" - integrity sha512-/O1B236mN7UNEU4t9X7Pj38i4VoU8CcMHyy3l2cV/kIF4U5KoHXDVqcDuOs1ltkac90IM4vZdHc52t1x8Yfs3g== - dependencies: - "@webassemblyjs/ast" "1.8.5" - mamacro "^0.0.3" - -"@webassemblyjs/helper-wasm-bytecode@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.8.5.tgz#537a750eddf5c1e932f3744206551c91c1b93e61" - integrity sha512-Cu4YMYG3Ddl72CbmpjU/wbP6SACcOPVbHN1dI4VJNJVgFwaKf1ppeFJrwydOG3NDHxVGuCfPlLZNyEdIYlQ6QQ== - -"@webassemblyjs/helper-wasm-section@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.8.5.tgz#74ca6a6bcbe19e50a3b6b462847e69503e6bfcbf" - integrity sha512-VV083zwR+VTrIWWtgIUpqfvVdK4ff38loRmrdDBgBT8ADXYsEZ5mPQ4Nde90N3UYatHdYoDIFb7oHzMncI02tA== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-buffer" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/wasm-gen" "1.8.5" - -"@webassemblyjs/ieee754@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.8.5.tgz#712329dbef240f36bf57bd2f7b8fb9bf4154421e" - integrity sha512-aaCvQYrvKbY/n6wKHb/ylAJr27GglahUO89CcGXMItrOBqRarUMxWLJgxm9PJNuKULwN5n1csT9bYoMeZOGF3g== +"@webassemblyjs/ast@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" + integrity sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA== + dependencies: + "@webassemblyjs/helper-module-context" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/wast-parser" "1.9.0" + +"@webassemblyjs/floating-point-hex-parser@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz#3c3d3b271bddfc84deb00f71344438311d52ffb4" + integrity sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA== + +"@webassemblyjs/helper-api-error@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz#203f676e333b96c9da2eeab3ccef33c45928b6a2" + integrity sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw== + +"@webassemblyjs/helper-buffer@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz#a1442d269c5feb23fcbc9ef759dac3547f29de00" + integrity sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA== + +"@webassemblyjs/helper-code-frame@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz#647f8892cd2043a82ac0c8c5e75c36f1d9159f27" + integrity sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA== + dependencies: + "@webassemblyjs/wast-printer" "1.9.0" + +"@webassemblyjs/helper-fsm@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz#c05256b71244214671f4b08ec108ad63b70eddb8" + integrity sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw== + +"@webassemblyjs/helper-module-context@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz#25d8884b76839871a08a6c6f806c3979ef712f07" + integrity sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g== + dependencies: + "@webassemblyjs/ast" "1.9.0" + +"@webassemblyjs/helper-wasm-bytecode@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz#4fed8beac9b8c14f8c58b70d124d549dd1fe5790" + integrity sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw== + +"@webassemblyjs/helper-wasm-section@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz#5a4138d5a6292ba18b04c5ae49717e4167965346" + integrity sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + +"@webassemblyjs/ieee754@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz#15c7a0fbaae83fb26143bbacf6d6df1702ad39e4" + integrity sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg== dependencies: "@xtuc/ieee754" "^1.2.0" -"@webassemblyjs/leb128@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.8.5.tgz#044edeb34ea679f3e04cd4fd9824d5e35767ae10" - integrity sha512-plYUuUwleLIziknvlP8VpTgO4kqNaH57Y3JnNa6DLpu/sGcP6hbVdfdX5aHAV716pQBKrfuU26BJK29qY37J7A== +"@webassemblyjs/leb128@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.9.0.tgz#f19ca0b76a6dc55623a09cffa769e838fa1e1c95" + integrity sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw== dependencies: "@xtuc/long" "4.2.2" -"@webassemblyjs/utf8@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.8.5.tgz#a8bf3b5d8ffe986c7c1e373ccbdc2a0915f0cedc" - integrity sha512-U7zgftmQriw37tfD934UNInokz6yTmn29inT2cAetAsaU9YeVCveWEwhKL1Mg4yS7q//NGdzy79nlXh3bT8Kjw== - -"@webassemblyjs/wasm-edit@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.8.5.tgz#962da12aa5acc1c131c81c4232991c82ce56e01a" - integrity sha512-A41EMy8MWw5yvqj7MQzkDjU29K7UJq1VrX2vWLzfpRHt3ISftOXqrtojn7nlPsZ9Ijhp5NwuODuycSvfAO/26Q== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-buffer" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/helper-wasm-section" "1.8.5" - "@webassemblyjs/wasm-gen" "1.8.5" - "@webassemblyjs/wasm-opt" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - "@webassemblyjs/wast-printer" "1.8.5" - -"@webassemblyjs/wasm-gen@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.8.5.tgz#54840766c2c1002eb64ed1abe720aded714f98bc" - integrity sha512-BCZBT0LURC0CXDzj5FXSc2FPTsxwp3nWcqXQdOZE4U7h7i8FqtFK5Egia6f9raQLpEKT1VL7zr4r3+QX6zArWg== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/ieee754" "1.8.5" - "@webassemblyjs/leb128" "1.8.5" - "@webassemblyjs/utf8" "1.8.5" - -"@webassemblyjs/wasm-opt@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.8.5.tgz#b24d9f6ba50394af1349f510afa8ffcb8a63d264" - integrity sha512-HKo2mO/Uh9A6ojzu7cjslGaHaUU14LdLbGEKqTR7PBKwT6LdPtLLh9fPY33rmr5wcOMrsWDbbdCHq4hQUdd37Q== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-buffer" "1.8.5" - "@webassemblyjs/wasm-gen" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - -"@webassemblyjs/wasm-parser@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.8.5.tgz#21576f0ec88b91427357b8536383668ef7c66b8d" - integrity sha512-pi0SYE9T6tfcMkthwcgCpL0cM9nRYr6/6fjgDtL6q/ZqKHdMWvxitRi5JcZ7RI4SNJJYnYNaWy5UUrHQy998lw== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-api-error" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/ieee754" "1.8.5" - "@webassemblyjs/leb128" "1.8.5" - "@webassemblyjs/utf8" "1.8.5" - -"@webassemblyjs/wast-parser@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.8.5.tgz#e10eecd542d0e7bd394f6827c49f3df6d4eefb8c" - integrity sha512-daXC1FyKWHF1i11obK086QRlsMsY4+tIOKgBqI1lxAnkp9xe9YMcgOxm9kLe+ttjs5aWV2KKE1TWJCN57/Btsg== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/floating-point-hex-parser" "1.8.5" - "@webassemblyjs/helper-api-error" "1.8.5" - "@webassemblyjs/helper-code-frame" "1.8.5" - "@webassemblyjs/helper-fsm" "1.8.5" +"@webassemblyjs/utf8@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.9.0.tgz#04d33b636f78e6a6813227e82402f7637b6229ab" + integrity sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w== + +"@webassemblyjs/wasm-edit@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz#3fe6d79d3f0f922183aa86002c42dd256cfee9cf" + integrity sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/helper-wasm-section" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + "@webassemblyjs/wasm-opt" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + "@webassemblyjs/wast-printer" "1.9.0" + +"@webassemblyjs/wasm-gen@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz#50bc70ec68ded8e2763b01a1418bf43491a7a49c" + integrity sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/ieee754" "1.9.0" + "@webassemblyjs/leb128" "1.9.0" + "@webassemblyjs/utf8" "1.9.0" + +"@webassemblyjs/wasm-opt@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz#2211181e5b31326443cc8112eb9f0b9028721a61" + integrity sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + +"@webassemblyjs/wasm-parser@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz#9d48e44826df4a6598294aa6c87469d642fff65e" + integrity sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-api-error" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/ieee754" "1.9.0" + "@webassemblyjs/leb128" "1.9.0" + "@webassemblyjs/utf8" "1.9.0" + +"@webassemblyjs/wast-parser@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz#3031115d79ac5bd261556cecc3fa90a3ef451914" + integrity sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/floating-point-hex-parser" "1.9.0" + "@webassemblyjs/helper-api-error" "1.9.0" + "@webassemblyjs/helper-code-frame" "1.9.0" + "@webassemblyjs/helper-fsm" "1.9.0" "@xtuc/long" "4.2.2" -"@webassemblyjs/wast-printer@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.8.5.tgz#114bbc481fd10ca0e23b3560fa812748b0bae5bc" - integrity sha512-w0U0pD4EhlnvRyeJzBqaVSJAo9w/ce7/WPogeXLzGkO6hzhr4GnQIZ4W4uUt5b9ooAaXPtnXlj0gzsXEOUNYMg== +"@webassemblyjs/wast-printer@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz#4935d54c85fef637b00ce9f52377451d00d47899" + integrity sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA== dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/wast-parser" "1.8.5" + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/wast-parser" "1.9.0" "@xtuc/long" "4.2.2" "@wildpeaks/snapshot-dom@1.6.0": @@ -1195,10 +1246,10 @@ am-i-a-dependency@1.1.2: resolved "https://registry.yarnpkg.com/am-i-a-dependency/-/am-i-a-dependency-1.1.2.tgz#f9d3422304d6f642f821e4c407565035f6167f1f" integrity sha1-+dNCIwTW9kL4IeTEB1ZQNfYWfx8= -ansi-escapes@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" - integrity sha1-06ioOzGapneTZisT52HHkRQiMG4= +ansi-escapes@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== ansi-regex@^2.0.0: version "2.1.1" @@ -1215,6 +1266,11 @@ ansi-regex@^4.1.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + ansi-styles@^2.0.1, ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -1227,6 +1283,19 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" +ansi-styles@^4.1.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" + integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + dependencies: + "@types/color-name" "^1.1.1" + color-convert "^2.0.1" + +any-observable@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" + integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== + any-promise@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" @@ -1240,7 +1309,7 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" -aproba@^1.0.3, aproba@^1.1.1: +aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== @@ -1250,14 +1319,6 @@ arch@2.1.1: resolved "https://registry.yarnpkg.com/arch/-/arch-2.1.1.tgz#8f5c2731aa35a30929221bb0640eed65175ec84e" integrity sha512-BLM56aPo9vLLFVa8+/+pJLnrZ7QGGTVHWsCwieAWT9o9K8UeGaQbzZbGoabWLOo2ksBCztoXdqBZBplqLDDCSg== -are-we-there-yet@~1.1.2: - version "1.1.5" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" - integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== - dependencies: - delegates "^1.0.0" - readable-stream "^2.0.6" - argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -1338,12 +1399,10 @@ async-each@^1.0.1: resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== -async@2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" - integrity sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ== - dependencies: - lodash "^4.17.10" +async@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" + integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== asynckit@^0.4.0: version "0.4.0" @@ -1447,11 +1506,6 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -bluebird@3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c" - integrity sha1-eRQg1/VR7qKJdFOop3ZT+WYG1nw= - bluebird@3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7" @@ -1462,7 +1516,7 @@ bluebird@3.7.1: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.1.tgz#df70e302b471d7473489acf26a93d63b53f874de" integrity sha512-DdmyoGCleJnkbp3nkbxTLJ18rjDsE4yCggEwKNXkeV123sPNfOCYeDoeuOY+F2FrSjO1YXcTU+dsy96KMy+gcg== -bluebird@^3.4.1, bluebird@^3.5.5: +bluebird@3.7.2, bluebird@^3.4.1, bluebird@^3.5.5: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== @@ -1781,12 +1835,10 @@ cached-path-relative@^1.0.0, cached-path-relative@^1.0.2: resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.2.tgz#a13df4196d26776220cc3356eb147a52dba2c6db" integrity sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg== -cachedir@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-1.3.0.tgz#5e01928bf2d95b5edd94b0942188246740e0dbc4" - integrity sha512-O1ji32oyON9laVPJL1IZ5bmwd2cB46VfpxkDequezH+15FDzzVddEyrGEeX4WusDSqKxdyFdDQDEG1yo1GoWkg== - dependencies: - os-homedir "^1.0.1" +cachedir@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" + integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== caniuse-lite@^1.0.30001023: version "1.0.30001027" @@ -1810,7 +1862,7 @@ chai@^4.1.2: pathval "^1.1.0" type-detect "^4.0.5" -chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0: +chalk@2.4.2, chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -1830,6 +1882,14 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + check-error@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" @@ -1871,10 +1931,10 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -ci-info@^1.5.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497" - integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A== +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" @@ -1901,10 +1961,34 @@ cli-cursor@^1.0.2: dependencies: restore-cursor "^1.0.1" -cli-spinners@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-0.1.2.tgz#bb764d88e185fb9e1e6a2a1f19772318f605e31c" - integrity sha1-u3ZNiOGF+54eaiofGXcjGPYF4xw= +cli-cursor@^2.0.0, cli-cursor@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" + integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU= + dependencies: + restore-cursor "^2.0.0" + +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-spinners@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.2.0.tgz#e8b988d9206c692302d8ee834e7a85c0144d8f77" + integrity sha512-tgU3fKwzYjiLEQgPMD9Jt+JjHVL9kW93FiIMX/l7rivvOD4/LL0Mf7gda3+4U2KJBloybwgj5KEoQgGRioMiKQ== + +cli-table3@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.5.1.tgz#0252372d94dfc40dbd8df06005f48f31f656f202" + integrity sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw== + dependencies: + object-assign "^4.1.0" + string-width "^2.1.1" + optionalDependencies: + colors "^1.1.2" cli-table@^0.3.1: version "0.3.1" @@ -1921,6 +2005,11 @@ cli-truncate@^0.2.1: slice-ansi "0.0.4" string-width "^1.0.1" +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" @@ -1954,11 +2043,23 @@ color-convert@^1.9.0: dependencies: color-name "1.1.3" +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + colors@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" @@ -1986,10 +2087,10 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -commander@2.15.1: - version "2.15.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" - integrity sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag== +commander@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.0.tgz#545983a0603fe425bc672d66c9e3c89c42121a83" + integrity sha512-NIQrwvv9V39FHgGFm36+U9SMQzbiHvU79k+iADraJTpmrFFfx7Ds0IvDoAdZsDrknlkRk14OYoWXb57uTh7/sw== commander@^2.19.0, commander@^2.20.0, commander@^2.9.0: version "2.20.3" @@ -2039,11 +2140,6 @@ console-browserify@^1.1.0: resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== -console-control-strings@^1.0.0, console-control-strings@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= - constants-browserify@^1.0.0, constants-browserify@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" @@ -2242,42 +2338,45 @@ cypress-cucumber-preprocessor@^2.0.1: minimist "^1.2.0" through "^2.3.8" -cypress@^3.5.0: - version "3.8.3" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-3.8.3.tgz#e921f5482f1cbe5814891c878f26e704bbffd8f4" - integrity sha512-I9L/d+ilTPPA4vq3NC1OPKmw7jJIpMKNdyfR8t1EXYzYCjyqbc59migOm1YSse/VRbISLJ+QGb5k4Y3bz2lkYw== +cypress@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-4.2.0.tgz#45673fb648b1a77b9a78d73e58b89ed05212d243" + integrity sha512-8LdreL91S/QiTCLYLNbIjLL8Ht4fJmu/4HGLxUI20Tc7JSfqEfCmXELrRfuPT0kjosJwJJZacdSji9XSRkPKUw== dependencies: "@cypress/listr-verbose-renderer" "0.4.1" "@cypress/xvfb" "1.2.4" "@types/sizzle" "2.3.2" arch "2.1.1" - bluebird "3.5.0" - cachedir "1.3.0" + bluebird "3.7.2" + cachedir "2.3.0" chalk "2.4.2" check-more-types "2.24.0" - commander "2.15.1" + cli-table3 "0.5.1" + commander "4.1.0" common-tags "1.8.0" - debug "3.2.6" + debug "4.1.1" eventemitter2 "4.1.2" - execa "0.10.0" + execa "1.0.0" executable "4.1.1" extract-zip "1.6.7" - fs-extra "5.0.0" - getos "3.1.1" - is-ci "1.2.1" + fs-extra "8.1.0" + getos "3.1.4" + is-ci "2.0.0" is-installed-globally "0.1.0" lazy-ass "1.6.0" - listr "0.12.0" + listr "0.14.3" lodash "4.17.15" - log-symbols "2.2.0" - minimist "1.2.0" + log-symbols "3.0.0" + minimist "1.2.2" moment "2.24.0" - ramda "0.24.1" - request "2.88.0" + ospath "1.2.2" + pretty-bytes "5.3.0" + ramda "0.26.1" + request cypress-io/request#b5af0d1fa47eec97ba980cde90a13e69a2afcd16 request-progress "3.0.0" - supports-color "5.5.0" + supports-color "7.1.0" tmp "0.1.0" - untildify "3.0.3" + untildify "4.0.0" url "0.11.0" yauzl "2.10.0" @@ -2320,13 +2419,6 @@ debug@3.1.0: dependencies: ms "2.0.0" -debug@3.2.6, debug@^3.0.1, debug@^3.1.0, debug@^3.2.6: - version "3.2.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" - integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== - dependencies: - ms "^2.1.1" - debug@4.1.1, debug@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" @@ -2334,6 +2426,13 @@ debug@4.1.1, debug@^4.1.0: dependencies: ms "^2.1.1" +debug@^3.0.1, debug@^3.1.0: + version "3.2.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" @@ -2346,10 +2445,12 @@ deep-eql@^3.0.1: dependencies: type-detect "^4.0.0" -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== +defaults@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" + integrity sha1-xlYFHpgX2f8I7YgUd/P+QBnz730= + dependencies: + clone "^1.0.2" define-properties@^1.1.2: version "1.1.3" @@ -2390,11 +2491,6 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= - deps-sort@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/deps-sort/-/deps-sort-2.0.1.tgz#9dfdc876d2bcec3386b6829ac52162cda9fa208d" @@ -2413,11 +2509,6 @@ des.js@^1.0.0: inherits "^2.0.1" minimalistic-assert "^1.0.0" -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - detective@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.0.tgz#feb2a77e85b904ecdea459ad897cc90a99bd2a7b" @@ -2651,13 +2742,13 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: md5.js "^1.3.4" safe-buffer "^5.1.1" -execa@0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.10.0.tgz#ff456a8f53f90f8eccc71a96d11bdfc7f082cb50" - integrity sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw== +execa@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== dependencies: cross-spawn "^6.0.0" - get-stream "^3.0.0" + get-stream "^4.0.0" is-stream "^1.1.0" npm-run-path "^2.0.0" p-finally "^1.0.0" @@ -2784,7 +2875,7 @@ figgy-pudding@^3.5.1: resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" integrity sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w== -figures@2.0.0: +figures@2.0.0, figures@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI= @@ -2889,15 +2980,6 @@ from2@^2.1.0: inherits "^2.0.1" readable-stream "^2.0.0" -fs-extra@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-5.0.0.tgz#414d0110cdd06705734d055652c5411260c31abd" - integrity sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ== - dependencies: - graceful-fs "^4.1.2" - jsonfile "^4.0.0" - universalify "^0.1.0" - fs-extra@7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" @@ -2907,12 +2989,14 @@ fs-extra@7.0.1: jsonfile "^4.0.0" universalify "^0.1.0" -fs-minipass@^1.2.5: - version "1.2.7" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" - integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA== +fs-extra@8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== dependencies: - minipass "^2.6.0" + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" fs-write-stream-atomic@^1.0.8: version "1.0.10" @@ -2942,20 +3026,6 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== -gauge@~2.7.3: - version "2.7.4" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" - integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= - dependencies: - aproba "^1.0.3" - console-control-strings "^1.0.0" - has-unicode "^2.0.0" - object-assign "^4.1.0" - signal-exit "^3.0.0" - string-width "^1.0.1" - strip-ansi "^3.0.1" - wide-align "^1.1.0" - gensync@^1.0.0-beta.1: version "1.0.0-beta.1" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" @@ -2971,22 +3041,24 @@ get-func-name@^2.0.0: resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= -get-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" - integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= +get-stream@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= -getos@3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/getos/-/getos-3.1.1.tgz#967a813cceafee0156b0483f7cffa5b3eff029c5" - integrity sha512-oUP1rnEhAr97rkitiszGP9EgDVYnmchgFzfqRzSkgtfv7ai6tEi7Ko8GgjNXts7VLWEqrTWyhsOKLe5C5b/Zkg== +getos@3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/getos/-/getos-3.1.4.tgz#29cdf240ed10a70c049add7b6f8cb08c81876faf" + integrity sha512-UORPzguEB/7UG5hqiZai8f0vQ7hzynMQyJLxStoQ8dPGAcmgsfXOPA4iE/fGtweHYkK+z4zc9V0g+CIFRf5HYw== dependencies: - async "2.6.1" + async "^3.1.0" getpass@^0.1.1: version "0.1.7" @@ -3032,7 +3104,7 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6: +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== @@ -3042,7 +3114,7 @@ har-schema@^2.0.0: resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= -har-validator@~5.1.0: +har-validator@~5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g== @@ -3062,16 +3134,16 @@ has-flag@^3.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + has-symbols@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== -has-unicode@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= - has-value@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" @@ -3154,13 +3226,6 @@ https-browserify@^1.0.0: resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= -iconv-lite@^0.4.4: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - ieee754@^1.1.4: version "1.1.13" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" @@ -3171,25 +3236,11 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= -ignore-walk@^3.0.1: - version "3.0.3" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37" - integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw== - dependencies: - minimatch "^3.0.4" - imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= -indent-string@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" - integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA= - dependencies: - repeating "^2.0.0" - indent-string@^3.0.0, indent-string@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" @@ -3223,7 +3274,7 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= -ini@^1.3.4, ini@~1.3.0: +ini@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== @@ -3289,12 +3340,12 @@ is-buffer@^1.1.0, is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-ci@1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c" - integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg== +is-ci@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== dependencies: - ci-info "^1.5.0" + ci-info "^2.0.0" is-data-descriptor@^0.1.4: version "0.1.4" @@ -3350,11 +3401,6 @@ is-extglob@^2.1.0, is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= -is-finite@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3" - integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w== - is-fullwidth-code-point@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" @@ -3394,6 +3440,11 @@ is-installed-globally@0.1.0: global-dirs "^0.1.0" is-path-inside "^1.0.0" +is-interactive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" + integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== + is-number@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" @@ -3406,6 +3457,13 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-observable@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-observable/-/is-observable-1.1.0.tgz#b3e986c8f44de950867cab5403f5a3465005975e" + integrity sha512-NqCa4Sa2d+u7BWc6CukaObG3Fh+CU9bvixbpcXYhy2VvYS7vVGIdAgnIS5Ks3A/cqk4rebLJ9s8zBstT2aKnIA== + dependencies: + symbol-observable "^1.1.0" + is-path-inside@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" @@ -3655,10 +3713,10 @@ listr-silent-renderer@^1.1.1: resolved "https://registry.yarnpkg.com/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" integrity sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4= -listr-update-renderer@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/listr-update-renderer/-/listr-update-renderer-0.2.0.tgz#ca80e1779b4e70266807e8eed1ad6abe398550f9" - integrity sha1-yoDhd5tOcCZoB+ju0a1qvjmFUPk= +listr-update-renderer@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/listr-update-renderer/-/listr-update-renderer-0.5.0.tgz#4ea8368548a7b8aecb7e06d8c95cb45ae2ede6a2" + integrity sha512-tKRsZpKz8GSGqoI/+caPmfrypiaq+OQCbd+CovEC24uk1h952lVj5sC7SqyFUm+OaJ5HN/a1YLt5cit2FMNsFA== dependencies: chalk "^1.1.3" cli-truncate "^0.2.1" @@ -3666,40 +3724,33 @@ listr-update-renderer@^0.2.0: figures "^1.7.0" indent-string "^3.0.0" log-symbols "^1.0.2" - log-update "^1.0.2" + log-update "^2.3.0" strip-ansi "^3.0.1" -listr-verbose-renderer@^0.4.0: - version "0.4.1" - resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#8206f4cf6d52ddc5827e5fd14989e0e965933a35" - integrity sha1-ggb0z21S3cWCfl/RSYng6WWTOjU= +listr-verbose-renderer@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.5.0.tgz#f1132167535ea4c1261102b9f28dac7cba1e03db" + integrity sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw== dependencies: - chalk "^1.1.3" - cli-cursor "^1.0.2" + chalk "^2.4.1" + cli-cursor "^2.1.0" date-fns "^1.27.2" - figures "^1.7.0" + figures "^2.0.0" -listr@0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/listr/-/listr-0.12.0.tgz#6bce2c0f5603fa49580ea17cd6a00cc0e5fa451a" - integrity sha1-a84sD1YD+klYDqF81qAMwOX6RRo= +listr@0.14.3: + version "0.14.3" + resolved "https://registry.yarnpkg.com/listr/-/listr-0.14.3.tgz#2fea909604e434be464c50bddba0d496928fa586" + integrity sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA== dependencies: - chalk "^1.1.3" - cli-truncate "^0.2.1" - figures "^1.7.0" - indent-string "^2.1.0" + "@samverschueren/stream-to-observable" "^0.3.0" + is-observable "^1.1.0" is-promise "^2.1.0" is-stream "^1.1.0" listr-silent-renderer "^1.1.1" - listr-update-renderer "^0.2.0" - listr-verbose-renderer "^0.4.0" - log-symbols "^1.0.2" - log-update "^1.0.2" - ora "^0.2.3" - p-map "^1.1.1" - rxjs "^5.0.0-beta.11" - stream-to-observable "^0.1.0" - strip-ansi "^3.0.1" + listr-update-renderer "^0.5.0" + listr-verbose-renderer "^0.5.0" + p-map "^2.0.0" + rxjs "^6.3.3" loader-runner@^2.4.0: version "2.4.0" @@ -3738,17 +3789,17 @@ lodash.once@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= -lodash@4.17.15, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.4: +lodash@4.17.15, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.15, lodash@^4.17.4: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -log-symbols@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" - integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg== +log-symbols@3.0.0, log-symbols@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" + integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ== dependencies: - chalk "^2.0.1" + chalk "^2.4.2" log-symbols@^1.0.2: version "1.0.2" @@ -3757,13 +3808,14 @@ log-symbols@^1.0.2: dependencies: chalk "^1.0.0" -log-update@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/log-update/-/log-update-1.0.2.tgz#19929f64c4093d2d2e7075a1dad8af59c296b8d1" - integrity sha1-GZKfZMQJPS0ucHWh2tivWcKWuNE= +log-update@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-2.3.0.tgz#88328fd7d1ce7938b29283746f0b1bc126b24708" + integrity sha1-iDKP19HOeTiykoN0bwsbwSayRwg= dependencies: - ansi-escapes "^1.0.0" - cli-cursor "^1.0.2" + ansi-escapes "^3.0.0" + cli-cursor "^2.0.0" + wrap-ansi "^3.0.1" loose-envify@^1.0.0: version "1.4.0" @@ -3800,11 +3852,6 @@ make-dir@^2.0.0: pify "^4.0.1" semver "^5.6.0" -mamacro@^0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/mamacro/-/mamacro-0.0.3.tgz#ad2c9576197c9f1abf308d0787865bd975a3f3e4" - integrity sha512-qMEwh+UujcQ+kbz3T6V+wAmO2U8veoq2w+3wY8MquqwVA3jChfwY+Tk52GZKDfACEPjuZ7r2oJLejwpt8jtwTA== - map-cache@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" @@ -3889,6 +3936,16 @@ mime-types@^2.1.12, mime-types@~2.1.19: dependencies: mime-db "1.43.0" +mimic-fn@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" + integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" @@ -3911,25 +3968,20 @@ minimist@0.0.8: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= -minimist@1.2.0, minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0: +minimist@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.2.tgz#b00a00230a1108c48c169e69a291aafda3aacd63" + integrity sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA== + +minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= -minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" - integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - -minizlib@^1.2.1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" - integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q== - dependencies: - minipass "^2.9.0" +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== mississippi@^3.0.0: version "3.0.0" @@ -3962,6 +4014,13 @@ mkdirp@0.5.1, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: dependencies: minimist "0.0.8" +mkdirp@^0.5.3: + version "0.5.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.4.tgz#fd01504a6797ec5c9be81ff43d204961ed64a512" + integrity sha512-iG9AK/dJLtJ0XNgTuDbSyNS3zECqDlAhnQW4CsNxBG3LQJBbHmRX1egw39DmtOdCAqY+dKXV+sgPgilNWUKMVw== + dependencies: + minimist "^1.2.5" + module-deps@^6.0.0: version "6.2.2" resolved "https://registry.yarnpkg.com/module-deps/-/module-deps-6.2.2.tgz#d8a15c2265dfc119153c29bb47386987d0ee423b" @@ -4010,6 +4069,11 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +mute-stream@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" + integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== + mz@^2.4.0: version "2.7.0" resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" @@ -4041,15 +4105,6 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" -needle@^2.2.1: - version "2.3.2" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.3.2.tgz#3342dea100b7160960a450dc8c22160ac712a528" - integrity sha512-DUzITvPVDUy6vczKKYTnWc/pBZ0EnjMJnQ3y+Jo5zfKFimJs7S3HFCxCRZYB9FUZcrzUQr3WsmvZgddMEIZv6w== - dependencies: - debug "^3.2.6" - iconv-lite "^0.4.4" - sax "^1.2.4" - neo-async@^2.5.0, neo-async@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" @@ -4101,22 +4156,6 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-pre-gyp@*: - version "0.14.0" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz#9a0596533b877289bcad4e143982ca3d904ddc83" - integrity sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4.4.2" - node-releases@^1.1.47: version "1.1.48" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.48.tgz#7f647f0c453a0495bcd64cbd4778c26035c2f03a" @@ -4124,7 +4163,7 @@ node-releases@^1.1.47: dependencies: semver "^6.3.0" -nopt@^4.0.1, nopt@~4.0.1: +nopt@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= @@ -4144,27 +4183,6 @@ normalize-path@^3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -npm-bundled@^1.0.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b" - integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA== - dependencies: - npm-normalize-package-bin "^1.0.1" - -npm-normalize-package-bin@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2" - integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== - -npm-packlist@^1.1.6: - version "1.4.8" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e" - integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - npm-normalize-package-bin "^1.0.1" - npm-run-path@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" @@ -4172,16 +4190,6 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" -npmlog@^4.0.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" - integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.7.3" - set-blocking "~2.0.0" - number-is-nan@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" @@ -4247,22 +4255,40 @@ onetime@^1.0.0: resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k= -ora@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/ora/-/ora-0.2.3.tgz#37527d220adcd53c39b73571d754156d5db657a4" - integrity sha1-N1J9Igrc1Tw5tzVx11QVbV22V6Q= +onetime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" + integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ= dependencies: - chalk "^1.1.1" - cli-cursor "^1.0.2" - cli-spinners "^0.1.2" - object-assign "^4.0.1" + mimic-fn "^1.0.0" + +onetime@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.0.tgz#fff0f3c91617fe62bb50189636e99ac8a6df7be5" + integrity sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q== + dependencies: + mimic-fn "^2.1.0" + +ora@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/ora/-/ora-4.0.3.tgz#752a1b7b4be4825546a7a3d59256fa523b6b6d05" + integrity sha512-fnDebVFyz309A73cqCipVL1fBZewq4vwgSHfxh43vVy31mbyoQ8sCH3Oeaog/owYOs/lLlGVPCISQonTneg6Pg== + dependencies: + chalk "^3.0.0" + cli-cursor "^3.1.0" + cli-spinners "^2.2.0" + is-interactive "^1.0.0" + log-symbols "^3.0.0" + mute-stream "0.0.8" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" os-browserify@^0.3.0, os-browserify@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= -os-homedir@^1.0.0, os-homedir@^1.0.1: +os-homedir@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= @@ -4280,6 +4306,11 @@ osenv@^0.1.4: os-homedir "^1.0.0" os-tmpdir "^1.0.0" +ospath@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/ospath/-/ospath-1.2.2.tgz#1276639774a3f8ef2572f7fe4280e0ea4550c07b" + integrity sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs= + outpipe@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/outpipe/-/outpipe-1.1.1.tgz#50cf8616365e87e031e29a5ec9339a3da4725fa2" @@ -4306,10 +4337,10 @@ p-locate@^3.0.0: dependencies: p-limit "^2.0.0" -p-map@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" - integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA== +p-map@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" + integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== p-try@^2.0.0: version "2.2.0" @@ -4462,6 +4493,11 @@ posix-character-classes@^0.1.0: resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= +pretty-bytes@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.3.0.tgz#f2849e27db79fb4d6cfe24764fc4134f165989f2" + integrity sha512-hjGrh+P926p4R4WbaB6OckyRtO0F0/lQBiT+0gnxjV+5kjPBrfVBFCsCLbMqVQeydvIoouYTCmmEURiH3R1Bdg== + private@^0.1.6: version "0.1.8" resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" @@ -4502,7 +4538,7 @@ pseudomap@^1.0.2: resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= -psl@^1.1.24: +psl@^1.1.28: version "1.7.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.7.0.tgz#f1c4c47a8ef97167dea5d6bbf4816d736e884a3c" integrity sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ== @@ -4549,12 +4585,12 @@ punycode@1.3.2: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= -punycode@^1.2.4, punycode@^1.3.2, punycode@^1.4.1: +punycode@^1.2.4, punycode@^1.3.2: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== @@ -4574,16 +4610,16 @@ querystring@0.2.0: resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= -ramda@0.24.1: - version "0.24.1" - resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.24.1.tgz#c3b7755197f35b8dc3502228262c4c91ddb6b857" - integrity sha1-w7d1UZfzW43DUCIoJixMkd22uFc= - ramda@0.25.0: version "0.25.0" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" integrity sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ== +ramda@0.26.1: + version "0.26.1" + resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" + integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -4599,16 +4635,6 @@ randomfill@^1.0.3: randombytes "^2.0.5" safe-buffer "^5.1.0" -rc@^1.2.7: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - read-only-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/read-only-stream/-/read-only-stream-2.0.0.tgz#2724fd6a8113d73764ac288d4386270c1dbf17f0" @@ -4616,7 +4642,7 @@ read-only-stream@^2.0.0: dependencies: readable-stream "^2.0.2" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -4723,13 +4749,6 @@ repeat-string@^1.5.2, repeat-string@^1.6.1: resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= -repeating@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" - integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo= - dependencies: - is-finite "^1.0.0" - request-progress@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-3.0.0.tgz#4ca754081c7fec63f505e4faa825aa06cd669dbe" @@ -4737,10 +4756,51 @@ request-progress@3.0.0: dependencies: throttleit "^1.0.0" -request@2.88.0: - version "2.88.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" - integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== +request-promise-core@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.3.tgz#e9a3c081b51380dfea677336061fea879a829ee9" + integrity sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ== + dependencies: + lodash "^4.17.15" + +request-promise-native@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.8.tgz#a455b960b826e44e2bf8999af64dff2bfe58cb36" + integrity sha512-dapwLGqkHtwL5AEbfenuzjTYg35Jd6KPytsC2/TLkVMz8rm+tNt72MGUWT1RP/aYawMpN6HqbNGBQaRcBtjQMQ== + dependencies: + request-promise-core "1.1.3" + stealthy-require "^1.1.1" + tough-cookie "^2.3.3" + +request@^2.88.0: + version "2.88.2" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +request@cypress-io/request#b5af0d1fa47eec97ba980cde90a13e69a2afcd16: + version "2.88.1" + resolved "https://codeload.github.com/cypress-io/request/tar.gz/b5af0d1fa47eec97ba980cde90a13e69a2afcd16" dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" @@ -4749,7 +4809,7 @@ request@2.88.0: extend "~3.0.2" forever-agent "~0.6.1" form-data "~2.3.2" - har-validator "~5.1.0" + har-validator "~5.1.3" http-signature "~1.2.0" is-typedarray "~1.0.0" isstream "~0.1.2" @@ -4759,7 +4819,7 @@ request@2.88.0: performance-now "^2.1.0" qs "~6.5.2" safe-buffer "^5.1.2" - tough-cookie "~2.4.3" + tough-cookie "~2.5.0" tunnel-agent "^0.6.0" uuid "^3.3.2" @@ -4793,12 +4853,28 @@ restore-cursor@^1.0.1: exit-hook "^1.0.0" onetime "^1.0.0" +restore-cursor@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" + integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368= + dependencies: + onetime "^2.0.0" + signal-exit "^3.0.2" + +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + ret@~0.1.10: version "0.1.15" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== -rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.3: +rimraf@^2.5.4, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -4820,12 +4896,12 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" -rxjs@^5.0.0-beta.11: - version "5.5.12" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.12.tgz#6fa61b8a77c3d793dbaf270bee2f43f652d741cc" - integrity sha512-xx2itnL5sBbqeeiVgNPVuQQ1nC8Jp2WfNJhXWHmElW9YmrpS9UVnNzhP3EH3HFqexO5Tlp8GhYY+WEcqcVMvGw== +rxjs@^6.3.3, rxjs@^6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c" + integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q== dependencies: - symbol-observable "1.0.1" + tslib "^1.9.0" safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.0" @@ -4844,16 +4920,11 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sax@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" - integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== - schema-utils@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" @@ -4873,7 +4944,7 @@ semver@7.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: +semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -4893,11 +4964,6 @@ serialize-javascript@^2.1.2: resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== -set-blocking@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= - set-value@^2.0.0, set-value@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" @@ -4958,7 +5024,7 @@ sigmund@^1.0.1: resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" integrity sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA= -signal-exit@^3.0.0: +signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= @@ -5147,6 +5213,11 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" +stealthy-require@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" + integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= + stream-browserify@^2.0.0, stream-browserify@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" @@ -5205,11 +5276,6 @@ stream-splicer@^2.0.0: inherits "^2.0.1" readable-stream "^2.0.2" -stream-to-observable@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/stream-to-observable/-/stream-to-observable-0.1.0.tgz#45bf1d9f2d7dc09bed81f1c307c430e68b84cffe" - integrity sha1-Rb8dny19wJvtgfHDB8Qw5ouEz/4= - string-argv@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.0.2.tgz#dac30408690c21f3c3630a3ff3a05877bdcbd736" @@ -5224,7 +5290,7 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2": +string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== @@ -5267,16 +5333,18 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + dependencies: + ansi-regex "^5.0.0" + strip-eof@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= - subarg@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" @@ -5284,22 +5352,29 @@ subarg@^1.0.0: dependencies: minimist "^1.1.0" -supports-color@5.5.0, supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== +supports-color@7.1.0, supports-color@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== dependencies: - has-flag "^3.0.0" + has-flag "^4.0.0" supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= -symbol-observable@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4" - integrity sha1-g0D8RwLDEi310iKI+IKD9RPT/dQ= +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +symbol-observable@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== syntax-error@^1.1.1: version "1.4.0" @@ -5313,19 +5388,6 @@ tapable@^1.0.0, tapable@^1.1.3: resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== -tar@^4.4.2: - version "4.4.13" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" - integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.8.6" - minizlib "^1.2.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.3" - terser-webpack-plugin@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz#5ecaf2dbdc5fb99745fd06791f46fc9ddb1c9a7c" @@ -5453,18 +5515,18 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" -tough-cookie@~2.4.3: - version "2.4.3" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" - integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ== +tough-cookie@^2.3.3, tough-cookie@~2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== dependencies: - psl "^1.1.24" - punycode "^1.4.1" + psl "^1.1.28" + punycode "^2.1.1" -ts-loader@^6.1.0: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-6.2.1.tgz#67939d5772e8a8c6bdaf6277ca023a4812da02ef" - integrity sha512-Dd9FekWuABGgjE1g0TlQJ+4dFUfYGbYcs52/HQObE0ZmUNjQlmLAS7xXsSzy23AMaMwipsx5sNHvoEpT2CZq1g== +ts-loader@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-6.2.2.tgz#dffa3879b01a1a1e0a4b85e2b8421dc0dfff1c58" + integrity sha512-HDo5kXZCBml3EUPcc7RlZOV/JGlLHwppTLEHb3SHnr5V7NXD4klMEkrhJe5wgRbaWsSXi+Y1SIBN/K9B6zWGWQ== dependencies: chalk "^2.3.0" enhanced-resolve "^4.0.0" @@ -5519,10 +5581,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@3.7.5: - version "3.7.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae" - integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw== +typescript@3.8.3: + version "3.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" + integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== umd@^3.0.0: version "3.0.3" @@ -5600,10 +5662,10 @@ unset-value@^1.0.0: has-value "^0.3.1" isobject "^3.0.0" -untildify@3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/untildify/-/untildify-3.0.3.tgz#1e7b42b140bcfd922b22e70ca1265bfe3634c7c9" - integrity sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA== +untildify@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b" + integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== upath@^1.1.1: version "1.2.0" @@ -5698,6 +5760,18 @@ vm-browserify@^1.0.0, vm-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== +wait-on@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/wait-on/-/wait-on-4.0.1.tgz#c49ca18b1ea60580404feed9df76ab3af2425a56" + integrity sha512-x83fmTH2X0KL7vXoGt9aV5x4SMCvO8A/NbwWpaYYh4NJ16d3KSgbHwBy9dVdHj0B30cEhOFRvDob4fnpUmZxvA== + dependencies: + "@hapi/joi" "^17.1.0" + lodash "^4.17.15" + minimist "^1.2.0" + request "^2.88.0" + request-promise-native "^1.0.8" + rxjs "^6.5.4" + watchify@3.11.1: version "3.11.1" resolved "https://registry.yarnpkg.com/watchify/-/watchify-3.11.1.tgz#8e4665871fff1ef64c0430d1a2c9d084d9721881" @@ -5720,6 +5794,13 @@ watchpack@^1.6.0: graceful-fs "^4.1.2" neo-async "^2.5.0" +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g= + dependencies: + defaults "^1.0.3" + webpack-sources@^1.4.0, webpack-sources@^1.4.1: version "1.4.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" @@ -5728,15 +5809,15 @@ webpack-sources@^1.4.0, webpack-sources@^1.4.1: source-list-map "^2.0.0" source-map "~0.6.1" -webpack@^4.40.2: - version "4.41.5" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.41.5.tgz#3210f1886bce5310e62bb97204d18c263341b77c" - integrity sha512-wp0Co4vpyumnp3KlkmpM5LWuzvZYayDwM2n17EHFr4qxBBbRokC7DJawPJC7TfSFZ9HZ6GsdH40EBj4UV0nmpw== +webpack@^4.42.1: + version "4.42.1" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.42.1.tgz#ae707baf091f5ca3ef9c38b884287cfe8f1983ef" + integrity sha512-SGfYMigqEfdGchGhFFJ9KyRpQKnipvEvjc1TwrXEPCM6H5Wywu10ka8o3KGrMzSMxMQKt8aCHUFh5DaQ9UmyRg== dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/wasm-edit" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-module-context" "1.9.0" + "@webassemblyjs/wasm-edit" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" acorn "^6.2.1" ajv "^6.10.2" ajv-keywords "^3.4.1" @@ -5748,7 +5829,7 @@ webpack@^4.40.2: loader-utils "^1.2.3" memory-fs "^0.4.1" micromatch "^3.1.10" - mkdirp "^0.5.1" + mkdirp "^0.5.3" neo-async "^2.6.1" node-libs-browser "^2.2.1" schema-utils "^1.0.0" @@ -5764,13 +5845,6 @@ which@^1.2.9: dependencies: isexe "^2.0.0" -wide-align@^1.1.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" - integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== - dependencies: - string-width "^1.0.2 || 2" - worker-farm@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8" @@ -5778,6 +5852,14 @@ worker-farm@^1.7.0: dependencies: errno "~0.1.7" +wrap-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-3.0.1.tgz#288a04d87eda5c286e060dfe8f135ce8d007f8ba" + integrity sha1-KIoE2H7aXChuBg3+jxNc6NAH+Lo= + dependencies: + string-width "^2.1.1" + strip-ansi "^4.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -5798,7 +5880,7 @@ yallist@^2.1.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= -yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3: +yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== From 6b6f6aec8b38063d5ca428e7fa3448c80659a483 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Tue, 24 Mar 2020 15:35:08 -0400 Subject: [PATCH 23/56] [Remote clusters] Fix serialization for server_name field (#60953) --- .../common/lib/cluster_serialization.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts b/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts index fbea311cdeefa..d0898fda93a41 100644 --- a/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts +++ b/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts @@ -153,13 +153,13 @@ export function serializeCluster(deserializedClusterObject: Cluster): ClusterPay cluster: { remote: { [name]: { - skip_unavailable: skipUnavailable !== undefined ? skipUnavailable : null, - mode: mode ?? null, - proxy_address: proxyAddress ?? null, - proxy_socket_connections: proxySocketConnections ?? null, - server_name: serverName ?? null, - seeds: seeds ?? null, - node_connections: nodeConnections ?? null, + skip_unavailable: typeof skipUnavailable === 'boolean' ? skipUnavailable : null, + mode: mode || null, + proxy_address: proxyAddress || null, + proxy_socket_connections: proxySocketConnections || null, + server_name: serverName || null, + seeds: seeds || null, + node_connections: nodeConnections || null, }, }, }, From f9088ebcec6c2ecb9613c9a6761a3d0682676ef0 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Tue, 24 Mar 2020 12:49:17 -0700 Subject: [PATCH 24/56] Remove "Upgrade" badge on disabled action types in the create connector flyout (#61111) * Remove "Upgrade" badge on disabled action types in the create connector flyout * Fix height of disabled cards Co-authored-by: defazio --- .../lib/check_action_type_enabled.scss | 7 ++++++ .../action_type_menu.test.tsx | 7 +----- .../action_type_menu.tsx | 23 +++++++++---------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss index 32ab1bd7b1821..24dbb865742d8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss @@ -7,3 +7,10 @@ box-shadow: none; } } + +.actConnectorsListGrid { + .euiToolTipAnchor, + .euiCard { + height: 100%; + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index 70aa862aa3c3d..0fb759226c21f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -184,11 +184,6 @@ describe('connector_add_flyout', () => { ); - const element = wrapper.find('[data-test-subj="my-action-type-card"]'); - expect(element.exists()).toBeTruthy(); - expect(element.first().prop('betaBadgeLabel')).toEqual('Upgrade'); - expect(element.first().prop('betaBadgeTooltipContent')).toEqual( - 'This connector requires a Gold license.' - ); + expect(wrapper.find('EuiToolTip [data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index 2dd5e413faf9c..91ecfb2fa8ded 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useState } from 'react'; import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { EuiToolTip } from '@elastic/eui'; import { ActionType, ActionTypeIndex } from '../../../types'; import { loadActionTypes } from '../../lib/action_connector_api'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; @@ -81,21 +82,19 @@ export const ActionTypeMenu = ({ description={item.selectMessage} isDisabled={!checkEnabledResult.isEnabled} onClick={() => onActionTypeChange(item.actionType)} - betaBadgeLabel={ - checkEnabledResult.isEnabled - ? undefined - : i18n.translate( - 'xpack.triggersActionsUI.sections.actionsConnectorsList.upgradeBadge', - { defaultMessage: 'Upgrade' } - ) - } - betaBadgeTooltipContent={ - checkEnabledResult.isEnabled ? undefined : checkEnabledResult.message - } /> ); - return {card}; + return ( + + {checkEnabledResult.isEnabled && card} + {checkEnabledResult.isEnabled === false && ( + + {card} + + )} + + ); }); return ( From 2f1e689c91c52a0ff15acde1c12f27b527b0f9ab Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 24 Mar 2020 15:51:00 -0400 Subject: [PATCH 25/56] [Lens] Create filters on click with bar, line, area charts (#57261) --- .../public/lib/triggers/triggers.ts | 6 +- .../lens/public/app_plugin/app.test.tsx | 19 ++- .../plugins/lens/public/app_plugin/app.tsx | 15 ++ .../embeddable/embeddable.tsx | 15 ++ x-pack/legacy/plugins/lens/public/plugin.tsx | 5 +- .../__snapshots__/xy_expression.test.tsx.snap | 7 + .../lens/public/xy_visualization/index.ts | 11 +- .../lens/public/xy_visualization/services.ts | 12 ++ .../xy_visualization/xy_expression.test.tsx | 89 ++++++++++++ .../public/xy_visualization/xy_expression.tsx | 130 ++++++++++++++---- .../api_integration/apis/lens/telemetry.ts | 3 +- .../test/functional/apps/lens/smokescreen.ts | 45 +++++- .../es_archives/lens/basic/data.json.gz | Bin 3004 -> 4183 bytes 13 files changed, 323 insertions(+), 34 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/xy_visualization/services.ts diff --git a/src/plugins/embeddable/public/lib/triggers/triggers.ts b/src/plugins/embeddable/public/lib/triggers/triggers.ts index 0052403816eb8..e29302fd6cc13 100644 --- a/src/plugins/embeddable/public/lib/triggers/triggers.ts +++ b/src/plugins/embeddable/public/lib/triggers/triggers.ts @@ -25,10 +25,10 @@ export interface EmbeddableContext { } export interface EmbeddableVisTriggerContext { - embeddable: IEmbeddable; - timeFieldName: string; + embeddable?: IEmbeddable; + timeFieldName?: string; data: { - e: MouseEvent; + e?: MouseEvent; data: unknown; }; } diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx index fbda18cc0e307..be72dd4b4edef 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -77,6 +77,20 @@ function createMockFilterManager() { }; } +function createMockTimefilter() { + const unsubscribe = jest.fn(); + + return { + getTime: jest.fn(() => ({ from: 'now-7d', to: 'now' })), + setTime: jest.fn(), + getTimeUpdate$: () => ({ + subscribe: ({ next }: { next: () => void }) => { + return unsubscribe; + }, + }), + }; +} + describe('Lens App', () => { let frame: jest.Mocked; let core: ReturnType; @@ -108,10 +122,7 @@ describe('Lens App', () => { query: { filterManager: createMockFilterManager(), timefilter: { - timefilter: { - getTime: jest.fn(() => ({ from: 'now-7d', to: 'now' })), - setTime: jest.fn(), - }, + timefilter: createMockTimefilter(), }, }, indexPatterns: { diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index a0c6e4c21a34b..dfea2e39fcbc5 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -94,8 +94,23 @@ export function App({ trackUiEvent('app_filters_updated'); }, }); + + const timeSubscription = data.query.timefilter.timefilter.getTimeUpdate$().subscribe({ + next: () => { + const currentRange = data.query.timefilter.timefilter.getTime(); + setState(s => ({ + ...s, + dateRange: { + fromDate: currentRange.from, + toDate: currentRange.to, + }, + })); + }, + }); + return () => { filterSubscription.unsubscribe(); + timeSubscription.unsubscribe(); }; }, []); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index 252ba5c9bc0bc..d18174baacdb9 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -14,8 +14,11 @@ import { IIndexPattern, TimefilterContract, } from 'src/plugins/data/public'; + import { Subscription } from 'rxjs'; import { ReactExpressionRendererType } from '../../../../../../../src/plugins/expressions/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/events'; + import { Embeddable as AbstractEmbeddable, EmbeddableOutput, @@ -90,6 +93,18 @@ export class Embeddable extends AbstractEmbeddable !filter.meta.disabled) diff --git a/x-pack/legacy/plugins/lens/public/plugin.tsx b/x-pack/legacy/plugins/lens/public/plugin.tsx index c74653c70703c..16f1d194b240a 100644 --- a/x-pack/legacy/plugins/lens/public/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/plugin.tsx @@ -28,6 +28,8 @@ import { stopReportManager, trackUiEvent, } from './lens_ui_telemetry'; + +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; import { KibanaLegacySetup } from '../../../../../src/plugins/kibana_legacy/public'; import { NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../../../../plugins/lens/common'; import { @@ -40,7 +42,6 @@ import { EmbeddableSetup, EmbeddableStart } from '../../../../../src/plugins/emb import { EditorFrameStart } from './types'; import { getLensAliasConfig } from './vis_type_alias'; import { VisualizationsSetup } from './legacy_imports'; - export interface LensPluginSetupDependencies { kibanaLegacy: KibanaLegacySetup; expressions: ExpressionsSetup; @@ -56,6 +57,7 @@ export interface LensPluginStartDependencies { data: DataPublicPluginStart; embeddable: EmbeddableStart; expressions: ExpressionsStart; + uiActions: UiActionsStart; } export const isRisonObject = (value: RisonValue): value is RisonObject => { @@ -217,6 +219,7 @@ export class LensPlugin { start(core: CoreStart, startDependencies: LensPluginStartDependencies) { this.createEditorFrame = this.editorFrameService.start(core, startDependencies).createInstance; + this.xyVisualization.start(core, startDependencies); } stop() { diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap b/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap index 4b19ad288ddaa..bef53c2fd266e 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap @@ -6,6 +6,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` > ('executeTriggerActions'); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx index adf64fece2942..d6abee101db31 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -12,6 +12,8 @@ import { LineSeries, Settings, ScaleType, + GeometryValue, + XYChartSeriesIdentifier, SeriesNameFn, } from '@elastic/charts'; import { xyChart, XYChart } from './xy_expression'; @@ -20,6 +22,9 @@ import React from 'react'; import { shallow } from 'enzyme'; import { XYArgs, LegendConfig, legendConfig, layerConfig, LayerArgs } from './types'; import { createMockExecutionContext } from '../../../../../../src/plugins/expressions/common/mocks'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +const executeTriggerActions = jest.fn(); function sampleArgs() { const data: LensMultiTable = { @@ -141,6 +146,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -166,6 +172,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` @@ -195,6 +202,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(Settings).prop('xDomain')).toBeUndefined(); @@ -209,6 +217,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -224,6 +233,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -239,6 +249,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -246,6 +257,69 @@ describe('xy_expression', () => { expect(component.find(Settings).prop('rotation')).toEqual(90); }); + test('onElementClick returns correct context data', () => { + const geometry: GeometryValue = { x: 5, y: 1, accessor: 'y1' }; + const series = { + key: 'spec{d}yAccessor{d}splitAccessors{b-2}', + specId: 'd', + yAccessor: 'd', + splitAccessors: {}, + seriesKeys: [2, 'd'], + }; + + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + + ); + + wrapper + .find(Settings) + .first() + .prop('onElementClick')!([[geometry, series as XYChartSeriesIdentifier]]); + + expect(executeTriggerActions).toHaveBeenCalledWith('VALUE_CLICK_TRIGGER', { + data: { + data: [ + { + column: 1, + row: 1, + table: data.tables.first, + value: 5, + }, + { + column: 1, + row: 0, + table: data.tables.first, + value: 2, + }, + ], + }, + }); + }); + test('it renders stacked bar', () => { const { data, args } = sampleArgs(); const component = shallow( @@ -255,6 +329,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -271,6 +346,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -290,6 +366,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -307,6 +384,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="CEST" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(LineSeries).prop('timeZone')).toEqual('CEST'); @@ -323,6 +401,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); @@ -346,6 +425,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); @@ -363,6 +443,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(false); @@ -378,6 +459,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; @@ -414,6 +496,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; @@ -442,6 +525,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Ordinal); @@ -457,6 +541,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(LineSeries).prop('yScaleType')).toEqual(ScaleType.Sqrt); @@ -472,6 +557,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); @@ -488,6 +574,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); @@ -504,6 +591,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} chartTheme={{}} timeZone="UTC" + executeTriggerActions={executeTriggerActions} /> ); expect(getFormatSpy).toHaveBeenCalledWith({ @@ -522,6 +610,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx index ce966ee6150a0..98d95c2ea7715 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -15,6 +15,8 @@ import { BarSeries, Position, PartialTheme, + GeometryValue, + XYChartSeriesIdentifier, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { @@ -26,11 +28,15 @@ import { import { EuiIcon, EuiText, IconType, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { FormatFactory } from '../legacy_imports'; +import { EmbeddableVisTriggerContext } from '../../../../../../src/plugins/embeddable/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/events'; +import { FormatFactory } from '../../../../../../src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities'; import { LensMultiTable } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart } from './state_helpers'; +import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; +import { getExecuteTriggerActions } from './services'; type InferPropType = T extends React.FunctionComponent ? P : T; type SeriesSpec = InferPropType & @@ -52,6 +58,7 @@ type XYChartRenderProps = XYChartProps & { chartTheme: PartialTheme; formatFactory: FormatFactory; timeZone: string; + executeTriggerActions: UiActionsStart['executeTriggerActions']; }; export const xyChart: ExpressionFunctionDefinition< @@ -113,10 +120,15 @@ export const getXyChartRenderer = (dependencies: { validate: () => undefined, reuseDomNode: true, render: (domNode: Element, config: XYChartProps, handlers: IInterpreterRenderHandlers) => { + const executeTriggerActions = getExecuteTriggerActions(); handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); ReactDOM.render( - + , domNode, () => handlers.done() @@ -148,7 +160,14 @@ export function XYChartReportable(props: XYChartRenderProps) { ); } -export function XYChart({ data, args, formatFactory, timeZone, chartTheme }: XYChartRenderProps) { +export function XYChart({ + data, + args, + formatFactory, + timeZone, + chartTheme, + executeTriggerActions, +}: XYChartRenderProps) { const { legend, layers } = args; if (Object.values(data.tables).every(table => table.rows.length === 0)) { @@ -189,7 +208,13 @@ export function XYChart({ data, args, formatFactory, timeZone, chartTheme }: XYC const shouldRotate = isHorizontalChart(layers); const xTitle = (xAxisColumn && xAxisColumn.name) || args.xTitle; - + const xDomain = + data.dateRange && layers.every(l => l.xScaleType === 'time') + ? { + min: data.dateRange.fromDate.getTime(), + max: data.dateRange.toDate.getTime(), + } + : undefined; return ( l.xScaleType === 'time') - ? { - min: data.dateRange.fromDate.getTime(), - max: data.dateRange.toDate.getTime(), - } - : undefined - } + xDomain={xDomain} + onElementClick={([[geometry, series]]) => { + // for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue + const xySeries = series as XYChartSeriesIdentifier; + const xyGeometry = geometry as GeometryValue; + + const layer = layers.find(l => + xySeries.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) + ); + if (!layer) { + return; + } + + const table = data.tables[layer.layerId]; + + const points = [ + { + row: table.rows.findIndex( + row => layer.xAccessor && row[layer.xAccessor] === xyGeometry.x + ), + column: table.columns.findIndex(col => col.id === layer.xAccessor), + value: xyGeometry.x, + }, + ]; + + if (xySeries.seriesKeys.length > 1) { + const pointValue = xySeries.seriesKeys[0]; + + points.push({ + row: table.rows.findIndex( + row => layer.splitAccessor && row[layer.splitAccessor] === pointValue + ), + column: table.columns.findIndex(col => col.id === layer.splitAccessor), + value: pointValue, + }); + } + + const xAxisFieldName: string | undefined = table.columns.find( + col => col.id === layer.xAccessor + )?.meta?.aggConfigParams?.field; + + const timeFieldName = xDomain && xAxisFieldName; + + const context: EmbeddableVisTriggerContext = { + data: { + data: points.map(point => ({ + row: point.row, + column: point.column, + value: point.value, + table, + })), + }, + timeFieldName, + }; + + executeTriggerActions(VIS_EVENT_TO_TRIGGER.filter, context); + }} /> = columnToLabel ? JSON.parse(columnToLabel) : {}; + const table = data.tables[layerId]; + + // For date histogram chart type, we're getting the rows that represent intervals without data. + // To not display them in the legend, they need to be filtered out. + const rows = table.rows.filter( + row => + !(splitAccessor && !row[splitAccessor] && accessors.every(accessor => !row[accessor])) + ); + const seriesProps: SeriesSpec = { splitSeriesAccessors: splitAccessor ? [splitAccessor] : [], stackAccessors: seriesType.includes('stacked') ? [xAccessor] : [], id: splitAccessor || accessors.join(','), xAccessor, yAccessors: accessors, - data: table.rows, + data: rows, xScaleType, yScaleType, enableHistogramMode: isHistogram && (seriesType.includes('stacked') || !splitAccessor), @@ -276,16 +359,17 @@ export function XYChart({ data, args, formatFactory, timeZone, chartTheme }: XYC }, }; - return seriesType === 'line' ? ( - - ) : seriesType === 'bar' || - seriesType === 'bar_stacked' || - seriesType === 'bar_horizontal' || - seriesType === 'bar_horizontal_stacked' ? ( - - ) : ( - - ); + switch (seriesType) { + case 'line': + return ; + case 'bar': + case 'bar_stacked': + case 'bar_horizontal': + case 'bar_horizontal_stacked': + return ; + default: + return ; + } } )} diff --git a/x-pack/test/api_integration/apis/lens/telemetry.ts b/x-pack/test/api_integration/apis/lens/telemetry.ts index 653df453c2560..fce76bfc96e2c 100644 --- a/x-pack/test/api_integration/apis/lens/telemetry.ts +++ b/x-pack/test/api_integration/apis/lens/telemetry.ts @@ -201,8 +201,9 @@ export default ({ getService }: FtrProviderContext) => { expect(results.saved_overall).to.eql({ lnsMetric: 1, + bar_stacked: 1, }); - expect(results.saved_overall_total).to.eql(1); + expect(results.saved_overall_total).to.eql(2); await esArchiver.unload('lens/basic'); }); diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 5768e51ae5f9f..be7a2faae6711 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -21,6 +21,10 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { ]); const find = getService('find'); const dashboardAddPanel = getService('dashboardAddPanel'); + const elasticChart = getService('elasticChart'); + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + const filterBar = getService('filterBar'); async function assertExpectedMetric() { await PageObjects.lens.assertExactText( @@ -41,6 +45,29 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { ); } + async function assertExpectedChart() { + await PageObjects.lens.assertExactText( + '[data-test-subj="embeddablePanelHeading-lnsXYvis"]', + 'lnsXYvis' + ); + } + + async function assertExpectedTimerange() { + const time = await PageObjects.timePicker.getTimeConfig(); + expect(time.start).to.equal('Sep 21, 2015 @ 09:00:00.000'); + expect(time.end).to.equal('Sep 21, 2015 @ 12:00:00.000'); + } + + async function clickOnBarHistogram() { + const el = await elasticChart.getCanvas(); + + await browser + .getActions() + .move({ x: 5, y: 5, origin: el._webElement }) + .click() + .perform(); + } + describe('lens smokescreen tests', () => { it('should allow editing saved visualizations', async () => { await PageObjects.visualize.gotoVisualizationLandingPage(); @@ -49,7 +76,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { await assertExpectedMetric(); }); - it('should be embeddable in dashboards', async () => { + it('metric should be embeddable in dashboards', async () => { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.clickNewDashboard(); await dashboardAddPanel.clickOpenAddPanel(); @@ -59,6 +86,22 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { await assertExpectedMetric(); }); + it('click on the bar in XYChart adds proper filters/timerange', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await find.clickByButtonText('lnsXYvis'); + await dashboardAddPanel.closeAddPanel(); + await PageObjects.lens.goToTimeRange(); + await clickOnBarHistogram(); + await testSubjects.click('applyFiltersPopoverButton'); + + await assertExpectedChart(); + await assertExpectedTimerange(); + const hasIpFilter = await filterBar.hasFilter('ip', '97.220.3.248'); + expect(hasIpFilter).to.be(true); + }); + it('should allow seamless transition to and from table view', async () => { await PageObjects.visualize.gotoVisualizationLandingPage(); await PageObjects.lens.clickVisualizeListItemTitle('Artistpreviouslyknownaslens'); diff --git a/x-pack/test/functional/es_archives/lens/basic/data.json.gz b/x-pack/test/functional/es_archives/lens/basic/data.json.gz index a5079d92e77f082ca2a30559e9a8c1bbcace01ff..4ed7c29f7391e4b01ea2737a318cb533d72bf63a 100644 GIT binary patch literal 4183 zcmV-d5UB4TiwFpe1bSWo17u-zVJ>QOZ*BnX9cz!ALlTv z0;@{f0Kw3w6c~N_zF-qxjGV>jpBsoVSdoW|1w)vX;xs|gij{eB7Nw+6Bj0ltK%YDY zAjFUsL6CTBN@Y^Wc(xdU)hdB2kn+-^X##=&I*B*0V(&Lf2wc?dF4`~}C*bQNV#Q0_ zkfK#JoKmpD5|N0aBNu%T;aUnd+bTvrNa0i9vqb*Wkfw!MOSUNGK?i$rQd{m(wsB!{ zGCD=!{e*d$tFbCZV&M~@l;a@@$A~VZc;ES?PiZ{x}L}=hK^L6l473^QS@0Ng~QO1 ziAx*ctx*KWu|!1L<0K=c60ng)1#RFaPVVHvCIw>xFp-916rlu$fDI#!bW^M{43lwy z5}fAc@*x+3Hr$?0y0+`BT;iUG@TC!UF6TeQQA%SFAW4xILT3~EC_vC((kP@H0mu$a zx0xFvynYU-V(6t(g$@qmDbp_6foTd>(o>V*C^04Dl_Q)3N=wWtnsU<p<`s z(9l^!v|39P)i{jjbD-!~GdJ@ww{fg)fDaQkeL_w1SNInBvJ_p0R1=nXlS-DO03YO1+n$*gW zo|qKLXFyB2O`!^w5>r#=tz5c&{7d$@mpx7<7eh!c8sTjmVX4+-j*(1MVi)-(IuFkA zk%>Z2cL%nKi&QF7GM$*EoUlt;>3DKRidK=pR0UU7XIm;|{t~3Lk)i2xRX0>aztS|vG97I;*6itj zA2wUc%WxWRmU(L1kUBM_PMu1kA$4jK>398#wqfr>-w)T5E~d06K| z>eP@rB{wbQHid@Nsr%DVx_x{|of=Z79)e0k>eP@rHKa~GJT^n>)N@LmqLeCs0ZYtE z8a?CesvCqe5Tb94JPuOfEy9oR%VCvaD8$*=9P2&P(-Z+xiFhOTOW+c%&oTZ8ZlM3( z{S$iWTY8frm(q_6`=36(e>cvjSb@;arLx7YldaWBcC*!1HC;7~E8TWX!!h--p%J#0 z?d!A>$XNSLG;txc&bQK=vaL3K3WVqYVwTZs6prw9F=C581koyE9yPtImU%EDUw5P; zKw(PnIyj%MFZf- z1BpiW^6$VS>kP*USL#8WY(Sb<(Ob2`Wtv@u7`t4xUlkPLJHu|rD`=xndmuWSDvanZ zAP^?4b!Xek8g9UX-#cP>RtIJ~76)q_0ZYVlgX>^1Ty2U3A(6W*;ghuxZ|!6Bjl!Cc zuW{ltabK`C$ZA>9zwU%N#_5`rWZq|(3XBsiL zKPyG73@b$L6f%@TR0_o6Le6zebtkCq1*Fm6F<;fya*9|QCv3K0yUmw=rN!=|3TV^R zm#mzqVnA_-vM{`;R~ElnOzLyK)f<#_bM=X<`BPKFmpD^Vp%wbrXAT8 z-~Z<)x<@*sdNPR5?jVQAf7X>gcdVIXSmW7Le_#%UWWhPJY2aBz9#t=} ztXy@uwrQ$?KevpT7MQ@(&tV@0US%G<-6oWK`7V(|SVaF*I4qAI*4?Ix!esYyK@7Qt zV52VR21)*~^+ms2&_7>_Css%Q-z`Q8-&+WbPuHZBj6tzkZeX&4OBVFRN(k;?^2Vo) zyr~Z6AaGTn&nK!C0H^|OYN>&1&aG)M^{fdo0hC3gD0%10?+{Z{NMQsa5cUE=+U3>_ zewn@u!=I@MhcB7xR<`thItYJ>zZNAZ^1{sLviBp3QhZ4TSRufFU;;vhVyJ^Gjh9qH z#Hs~-3DSbuOa%oD&)4j^HdjqsCsVNlOLa|GQ|HEPI)g4S%wSd$uQ0C`rGd5JCRPD% z18&RK+|VkkavQFw<@VNsSzL?4<|-QJFSl4QyN3KxI77%fAwC7+e3)$_LQVfI*cOZ@ zg{r3Zolia}ArQC{@#iLPfh5AY!Nupyjj;u{%*$SH!7bWk8zl~u5^>fn-oo5dzUn3t z2=_It@xG3&k=N~nxj2)NyB!tzy@XSyENN*~!}bj7#5eA%}eh^*~i4|JK;WmBdZD9rjQ>V{s?60|E(5Z$er#$d)F z?Q@{rUVAW)gW=HHxNhRIvR0;`L(+I3)48&E+C9DNRuj3lstz00qG>(q9KJi^#;OZd z$jq)jGFZa^;UD7!eIv#T2-~G;#7zxm_Vrn{ZQXn7#bP#X%~=EAgi`~|fa)0&n;;Mb zDlh_1h0qNQ&7Eo59JG5@261F(8n9JwZq8K8vOIM@pF(x&`nEQKaAum5rqHx#gL~40 zfxs|95Ely!B(M$X5#AC%;ibUfiRJlI3;L=7h_P!;Y+VJxoN(Eg8rDR!CR4i=(5(o> zpF&IXAW)}KnkA`{s1wd*%s_Qy!o0 zaGi=cbwA$;ZTJ2M4@wtRrtfL4Kbfe8HYfClQ%waB7;0eKx;E3TVCK8k&Sh5?=q~Z2 zp>SGb<}HOtchyg7M7s-cdBnSbSB-jCL9|4^OTUXpznNQeQRk591^PTN$mVR>WUKJ4 zi3%o@K=tieFrRCNq1pBc9HKI@ToW}GujqSX9)wlG3uKE98HVqukPD*VRRlV< zPJ;t|sk!HXq4t%DHV(YJAp~8)W&rM3fUlhs6{U1M2-g)PJQUkIL!(wBcei&uYIl3Z z!*=4^$d$XIv_YDYl6w!_-XU=E=iJs&N(Umhj|z9;vb?CUW9C`zV+t_nb7AWBxAfM? zhulIdU8c*P@c?VX<@2g|dY3(Q#z@(_k))3c zk|m)x;k_?}NU62hi=`ta*Nay%vucV>sP{TJDEPfMv94W<3S0G5vh!%YNAQ-n>>t7{ zK3(BE(YZPl)D>Fy2=5l3wusnut*m(-el+M?pnW*>TedqV1YCAL5DFGr^;xDzVC1Ow z2oTr0^pVhm3@st`(H1=+^;mmdGE;n7!`YQlj)k_*nO;9w44xSHrZz_}(y*SDFnC*Z zsk3Jp6>rV`IMTThAFo=_GxW41wLyU=;c>H-hfLSmwGvwp!vI_=(+E*TNsCxb|9~eWy~6u)noLN9wCF>8H2W zWaCsr18i@A*gFYd7TF*@&Bs&gJX~}3qCG_z+qAjXXT2Y3b9CA#9u6NLx%F83s1Yt4 z9yctp&U>b;^4QsN4aVsB1=+(V$>p#er?tiBpaFmE{I=5gzUJ*g(^fpC?d4V+1Xew- z6+G=D-RcLp_x3WXN4dRh@l%}2RuStw-Yr`kJLWAL9y#vin6+Zjar7(l;=rMG^-jg! z_m1%Ie~@)i4J~vX{QmyOGd}dqkeVN1IIoBfbYqbCA3y{{o;CL-`ZWp9zwKP$P>v!@ hwk)cg3bP|0!M5M^rH)~ZO}htm{tsj#%=zD{001+P{;2=} literal 3004 zcmc)4^*}OJi4YK;vmrdS^*A9=&lBurx(!?({3#= z#FO33R7^q7Y||C|`%CRDJT>*<0jwMDSA2d#ai z(w=FvnXWgbd86DyZtgNGoZAst49HN;#pz%;X;DpQHT{fLykBdU7=J!pXCB9{2=_!V zv|`5}@>Eqj6GNGV2_0TS_%s(F8L>g=m*(#EW0_1h9Thr2s?A3mIKIB{{Ts&ld-5pHr>{8T-2h)Rnl6Uz<_zekderoJO;h$hN7d;8j) zO_DYltt^z%E6YI%;t=6V33zMD9T~{NrRQ!Y!rnqi=@_hZA}||kdu-48M|b0}sQ^1F zF*L>jDCN^DEZC+_q)yJdr8Q0^7$DG264wS=VF}9#k*1Z!(Ko(Bjfrf^ke?8Qmv}M_ zO#Brt?eh>x>mT(zw zGNnQ7&EsEBrU#;472W)#V?#?`gp73_JEsY#L+C-e%n(1BJ*2i|1!J~b^R&jt)Sl{# ztSKL*S-)aoucFoDEy4EIZlXo5r@swH7Ps*BjsyRZCYq}`>?gs#{)!5=Y9!2%sn>^j zTQ)n|?3C^)fL3!;^vCX2r|L_((#N0MnNLe=S`RpGp4TDod$ZB@87 zXgpSDWP#Vn$SjSX{s7VwSX%e77oRGyGqh%3kPVoZ%)dN$n;8PS9&RAbdiXxyT9G^) z^p=Osp2{$WdqxfZ=UzZ|=pNwijh$2i&$D_aD4CQ_`IF{&ZUn4}DgodMezMJ$!m97D&RZRvs5tgCE z;2gK7{qKz4zs<$BHajbWDmWBS%SOGiOjZyN~IiTr*gd<3@s>x+spSgSUD0$m5nVwcd>M~eZx)@XF_rgB$4SgbBKPpp4e6iYd z#cFt8uH5oqr zM};!8_;GTzvtXW;@@mD(p$_!6%+gtTO$pVPcU5u(4PMn^mH;q+i>te85}ke>pj`Z! zULkrORxkJCB>2B2*VD^sH8mK6aoVSN;*Q*6jo*3{tI{JR=DiIM_d~q9&JTLS1&XxM zRzt(Jt~;~C1@f4qRNe-h$wN@~^Q4KPg7+hJxNK#U%M-0n(3fg>5MamU0S+giR-s)s zaNjl*O~XCWFYY3h?=dPg)=)M&ET&kKqvW&IPDk;@5bGO=Ex;akw6`M|aBakQp<32j z%;jvqJLsJra^J#^6(m;u`y}QNP88gpT{sDuN37fLH5nBQ=vwjN;$rLW$MX^r`4QuY zWL(QB=4?$r^2cSRSy6iJ!2KlD3XHW1P?OTXKHiJsev@R7+PX&Z^ai}m@F2SW;@%UJ zAb%g;Fy4glaumnd*Bbj%rMjY3+oN=ScXb*GmsM{?)>BmH@ZSPSh-rNe8rm?3blPPSo%j@BHV@1>D#);;NO)*4_w+5X%bS*wcP_3+uO_8 zf%H!j85A@{$@HAP$CjBWBNP+ds{ekvfKKpOCdv0gdrH|{USY;we}>ef$tMk zyeXN5;42a`#RClus%q-gXlRd^F~Hr{rYqN1Q>T1?=akycYCgQ1hFtb+ z`2|r_sgJym>l3SZ|q}s(DM)yr@%|BtQ>06uaE1^$erI;@)Wi3`YNjt$Z&g}f84+0 z&tkirnydpysbto1ljYE^yffj^h}Hwmep#f__@$HIb^0ct$oW}7d4N~ZIbz{!>77HA zCDYdwg1E{3R5HugYvKWJ6OMZwd_UXj0lqCw@r;~uo1w=Z-&5F}`oW@<(kql1-ozqfK!DGDiQkcFQXqit&vSiO8BqP63xptBsRrCXT>hKO_B{?XamMC3I7yF@8tbIHwxiiwo z$sm%o3r9dP@-4PkY<;{wa)6=dN`X`L;=A<76;>&jmO6%AaRn>!tmlfbd-oiJPx2q8 zTdP^?nYm|6#YYI(pk!M^h Date: Tue, 24 Mar 2020 12:53:31 -0700 Subject: [PATCH 26/56] [APM] Service map - add page load and interaction telemetry (#61009) * Closes #60527 by adding called to shared observability usage tracking function when service map page is loaded, and if the user interacts with it * trigger usage tracking on specific cytoscape events: node select, object hover --- .../apm/public/components/app/ServiceMap/Cytoscape.tsx | 7 ++++++- .../plugins/apm/public/components/app/ServiceMap/index.tsx | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index a4cd6f4ed09a9..168b470580279 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -20,6 +20,7 @@ import { cytoscapeOptions, nodeHeight } from './cytoscapeOptions'; +import { useUiTracker } from '../../../../../../../plugins/observability/public'; export const CytoscapeContext = createContext( undefined @@ -117,6 +118,8 @@ export function Cytoscape({ // is required and can trigger rendering when changed. const divStyle = { ...style, height }; + const trackApmEvent = useUiTracker({ app: 'apm' }); + // Trigger a custom "data" event when data changes useEffect(() => { if (cy && elements.length > 0) { @@ -169,6 +172,7 @@ export function Cytoscape({ }); }; const mouseoverHandler: cytoscape.EventHandler = event => { + trackApmEvent({ metric: 'service_map_object_hover' }); event.target.addClass('hover'); event.target.connectedEdges().addClass('nodeHover'); }; @@ -177,6 +181,7 @@ export function Cytoscape({ event.target.connectedEdges().removeClass('nodeHover'); }; const selectHandler: cytoscape.EventHandler = event => { + trackApmEvent({ metric: 'service_map_node_select' }); resetConnectedEdgeStyle(event.target); }; const unselectHandler: cytoscape.EventHandler = event => { @@ -215,7 +220,7 @@ export function Cytoscape({ cy.removeListener('unselect', 'node', unselectHandler); } }; - }, [cy, height, serviceName, width]); + }, [cy, height, serviceName, trackApmEvent, width]); return ( diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index 4974553f6ca93..d523a7cb331e1 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -23,6 +23,7 @@ import { EmptyBanner } from './EmptyBanner'; import { Popover } from './Popover'; import { useRefDimensions } from './useRefDimensions'; import { BetaBadge } from './BetaBadge'; +import { useTrackPageview } from '../../../../../../../plugins/observability/public'; interface ServiceMapProps { serviceName?: string; @@ -55,6 +56,9 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { const { ref, height, width } = useRefDimensions(); + useTrackPageview({ app: 'apm', path: 'service_map' }); + useTrackPageview({ app: 'apm', path: 'service_map', delay: 15000 }); + if (!license) { return null; } From 223f774c3ff6abafb1ff78f3005a3ffd7c5bac53 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Tue, 24 Mar 2020 15:53:43 -0400 Subject: [PATCH 27/56] [Monitoring] Update /api/stats to use core.metrics (#60974) * Align api/stats with the monitoring logic for kibana ops metrics * Align collectors * Add in locale to kibana_settings * More tweaks * PR feedback * PR feedback --- src/legacy/server/status/index.js | 2 +- .../status/routes/api/register_stats.js | 29 +++++++++++++------ .../server/kibana_monitoring/bulk_uploader.js | 18 +++++++++--- .../collectors/get_ops_stats_collector.ts | 11 ++++++- .../collectors/get_settings_collector.ts | 3 -- x-pack/plugins/monitoring/server/plugin.ts | 4 +-- 6 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/legacy/server/status/index.js b/src/legacy/server/status/index.js index a9544049182a7..df02b3c45ec2f 100644 --- a/src/legacy/server/status/index.js +++ b/src/legacy/server/status/index.js @@ -57,7 +57,7 @@ export function statusMixin(kbnServer, server, config) { // init routes registerStatusPage(kbnServer, server, config); registerStatusApi(kbnServer, server, config); - registerStatsApi(usageCollection, server, config); + registerStatsApi(usageCollection, server, config, kbnServer); // expore shared functionality server.decorate('server', 'getOSInfo', getOSInfo); diff --git a/src/legacy/server/status/routes/api/register_stats.js b/src/legacy/server/status/routes/api/register_stats.js index e218c1caf1701..2dd66cb8caff7 100644 --- a/src/legacy/server/status/routes/api/register_stats.js +++ b/src/legacy/server/status/routes/api/register_stats.js @@ -21,7 +21,7 @@ import Joi from 'joi'; import boom from 'boom'; import { i18n } from '@kbn/i18n'; import { wrapAuthConfig } from '../../wrap_auth_config'; -import { KIBANA_STATS_TYPE } from '../../constants'; +import { getKibanaInfoForStats } from '../../lib'; const STATS_NOT_READY_MESSAGE = i18n.translate('server.stats.notReadyMessage', { defaultMessage: 'Stats are not ready yet. Please try again later.', @@ -37,7 +37,7 @@ const STATS_NOT_READY_MESSAGE = i18n.translate('server.stats.notReadyMessage', { * - Any other value causes a statusCode 400 response (Bad Request) * Including ?exclude_usage in the query string excludes the usage stats from the response. Same value semantics as ?extended */ -export function registerStatsApi(usageCollection, server, config) { +export function registerStatsApi(usageCollection, server, config, kbnServer) { const wrapAuth = wrapAuthConfig(config.get('status.allowAnonymous')); const getClusterUuid = async callCluster => { @@ -50,6 +50,17 @@ export function registerStatsApi(usageCollection, server, config) { return usageCollection.toObject(usage); }; + let lastMetrics = null; + /* kibana_stats gets singled out from the collector set as it is used + * for health-checking Kibana and fetch does not rely on fetching data + * from ES */ + server.newPlatform.setup.core.metrics.getOpsMetrics$().subscribe(metrics => { + lastMetrics = { + ...metrics, + timestamp: new Date().toISOString(), + }; + }); + server.route( wrapAuth({ method: 'GET', @@ -133,15 +144,15 @@ export function registerStatsApi(usageCollection, server, config) { } } - /* kibana_stats gets singled out from the collector set as it is used - * for health-checking Kibana and fetch does not rely on fetching data - * from ES */ - const kibanaStatsCollector = usageCollection.getCollectorByType(KIBANA_STATS_TYPE); - if (!(await kibanaStatsCollector.isReady())) { + if (!lastMetrics) { return boom.serverUnavailable(STATS_NOT_READY_MESSAGE); } - let kibanaStats = await kibanaStatsCollector.fetch(); - kibanaStats = usageCollection.toApiFieldNames(kibanaStats); + const kibanaStats = usageCollection.toApiFieldNames({ + ...lastMetrics, + kibana: getKibanaInfoForStats(server, kbnServer), + last_updated: new Date().toISOString(), + collection_interval_in_millis: config.get('ops.interval'), + }); return { ...kibanaStats, diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js b/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js index c09a08f61dc0a..b90a9aa7d139a 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js @@ -6,7 +6,10 @@ import { defaultsDeep, uniq, compact, get } from 'lodash'; -import { TELEMETRY_COLLECTION_INTERVAL } from '../../common/constants'; +import { + TELEMETRY_COLLECTION_INTERVAL, + KIBANA_STATS_TYPE_MONITORING, +} from '../../common/constants'; import { sendBulkPayload, monitoringBulk } from './lib'; import { hasMonitoringCluster } from '../es_client/instantiate_client'; @@ -188,11 +191,18 @@ export class BulkUploader { ); } - getKibanaStats() { - return { + getKibanaStats(type) { + const stats = { ...this.kibanaStats, status: this.kibanaStatusGetter(), }; + + if (type === KIBANA_STATS_TYPE_MONITORING) { + delete stats.port; + delete stats.locale; + } + + return stats; } /* @@ -252,7 +262,7 @@ export class BulkUploader { ...accum, { index: { _type: type } }, { - kibana: this.getKibanaStats(), + kibana: this.getKibanaStats(type), ...typesNested[type], }, ]; diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_ops_stats_collector.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_ops_stats_collector.ts index 00197e98948bf..85357f786ddc1 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_ops_stats_collector.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_ops_stats_collector.ts @@ -5,6 +5,7 @@ */ import { Observable } from 'rxjs'; +import { cloneDeep } from 'lodash'; import moment from 'moment'; import { OpsMetrics } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; @@ -22,7 +23,15 @@ export function getOpsStatsCollector( metrics$: Observable ) { let lastMetrics: MonitoringOpsMetrics | null = null; - metrics$.subscribe(metrics => { + metrics$.subscribe(_metrics => { + const metrics: any = cloneDeep(_metrics); + // Ensure we only include the same data that Metricbeat collection would get + delete metrics.process.pid; + metrics.response_times = { + average: metrics.response_times.avg_in_millis, + max: metrics.response_times.max_in_millis, + }; + delete metrics.requests.statusCodes; lastMetrics = { ...metrics, timestamp: moment.utc().toISOString(), diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts index 63e1dbc400787..c66adfcabd671 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts @@ -12,9 +12,6 @@ import { MonitoringConfig } from '../../config'; * If so, get email from kibana.yml */ export async function getDefaultAdminEmail(config: MonitoringConfig) { - if (!config.cluster_alerts.email_notifications.enabled) { - return null; - } return config.cluster_alerts.email_notifications.email_address || null; } diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index b8216f037eabb..d9500284b52dc 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -51,7 +51,6 @@ import { InfraPluginSetup } from '../../infra/server'; export interface LegacyAPI { getServerStatus: () => string; - infra: any; } interface PluginsSetup { @@ -189,8 +188,9 @@ export class Plugin { name: serverInfo.name, index: get(legacyConfig, 'kibana.index'), host: serverInfo.host, - transport_address: `${serverInfo.host}:${serverInfo.port}`, + locale: i18n.getLocale(), port: serverInfo.port.toString(), + transport_address: `${serverInfo.host}:${serverInfo.port}`, version: this.initializerContext.env.packageInfo.version, snapshot: snapshotRegex.test(this.initializerContext.env.packageInfo.version), }, From 30f5b7fa225fbb98c7e4438277261dad6b2eb627 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 24 Mar 2020 20:59:23 +0100 Subject: [PATCH 28/56] [APM] Display service.framework.name in popover (#61101) Closes #60771. --- .../service_map/dedupe_connections/index.ts | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts index d256f657bb778..6a433367d8217 100644 --- a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts +++ b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { isEqual, sortBy } from 'lodash'; +import { sortBy, pick, identity } from 'lodash'; import { ValuesType } from 'utility-types'; import { SERVICE_NAME, @@ -72,24 +72,35 @@ export function dedupeConnections(response: ServiceMapResponse) { return map; } - const service = - discoveredServices.find(({ from }) => { - if ('span.destination.service.resource' in node) { - return ( - node[SPAN_DESTINATION_SERVICE_RESOURCE] === - from[SPAN_DESTINATION_SERVICE_RESOURCE] - ); - } - return false; - })?.to ?? serviceNodes.find(serviceNode => serviceNode.id === node.id); - - if (service) { + const matchedService = discoveredServices.find(({ from }) => { + if ('span.destination.service.resource' in node) { + return ( + node[SPAN_DESTINATION_SERVICE_RESOURCE] === + from[SPAN_DESTINATION_SERVICE_RESOURCE] + ); + } + return false; + })?.to; + + let serviceName: string | undefined = matchedService?.[SERVICE_NAME]; + + if (!serviceName && 'service.name' in node) { + serviceName = node[SERVICE_NAME]; + } + + const matchedServiceNodes = services.filter( + serviceNode => serviceNode[SERVICE_NAME] === serviceName + ); + + if (matchedServiceNodes.length) { return { ...map, - [node.id]: { - id: service[SERVICE_NAME], - ...service - } + [node.id]: Object.assign( + { + id: matchedServiceNodes[0][SERVICE_NAME] + }, + ...matchedServiceNodes.map(serviceNode => pick(serviceNode, identity)) + ) }; } @@ -138,7 +149,7 @@ export function dedupeConnections(response: ServiceMapResponse) { const dedupedNodes: typeof nodes = []; nodes.forEach(node => { - if (!dedupedNodes.find(dedupedNode => isEqual(node, dedupedNode))) { + if (!dedupedNodes.find(dedupedNode => node.id === dedupedNode.id)) { dedupedNodes.push(node); } }); From 17db8a66b33ae81e700cc8ab15f8d0e476224cff Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Tue, 24 Mar 2020 16:04:43 -0400 Subject: [PATCH 29/56] [Canvas] Visualize embeddable (#60859) * Enables Visualize Embeddable * Fix i18n * Fix tests * Remove unused import --- .../common/saved_visualization.test.ts | 11 +- .../functions/common/saved_visualization.ts | 50 +++++++- .../renderers/embeddable/embeddable.tsx | 7 +- .../embeddable_input_to_expression.test.ts | 116 ++---------------- .../embeddable_input_to_expression.ts | 65 ++-------- .../input_type_to_expression/lens.test.ts | 55 +++++++++ .../input_type_to_expression/lens.ts | 27 ++++ .../input_type_to_expression/map.test.ts | 72 +++++++++++ .../input_type_to_expression/map.ts | 38 ++++++ .../visualization.test.ts | 72 +++++++++++ .../input_type_to_expression/visualization.ts | 35 ++++++ .../functions/dict/saved_visualization.ts | 16 ++- .../components/embeddable_flyout/index.tsx | 6 +- 13 files changed, 398 insertions(+), 172 deletions(-) create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.test.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts index 9c3e80bc22af1..754a113b87554 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts @@ -5,7 +5,7 @@ */ jest.mock('ui/new_platform'); import { savedVisualization } from './saved_visualization'; -import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; const filterContext = { and: [ @@ -24,20 +24,21 @@ describe('savedVisualization', () => { const fn = savedVisualization().fn; const args = { id: 'some-id', + timerange: null, + colors: null, + hideLegend: null, }; it('accepts null context', () => { const expression = fn(null, args, {} as any); expect(expression.input.filters).toEqual([]); - expect(expression.input.timeRange).toBeUndefined(); }); it('accepts filter context', () => { const expression = fn(filterContext, args, {} as any); - const embeddableFilters = buildEmbeddableFilters(filterContext.and); + const embeddableFilters = getQueryFilters(filterContext.and); - expect(expression.input.filters).toEqual(embeddableFilters.filters); - expect(expression.input.timeRange).toEqual(embeddableFilters.timeRange); + expect(expression.input.filters).toEqual(embeddableFilters); }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts index 5b612b7cbd666..9777eaebb36ed 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts @@ -11,16 +11,24 @@ import { EmbeddableExpressionType, EmbeddableExpression, } from '../../expression_types'; -import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter } from '../../../types'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { Filter, TimeRange as TimeRangeArg, SeriesStyle } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { id: string; + timerange: TimeRangeArg | null; + colors: SeriesStyle[] | null; + hideLegend: boolean | null; } type Output = EmbeddableExpression; +const defaultTimeRange = { + from: 'now-15m', + to: 'now', +}; + export function savedVisualization(): ExpressionFunctionDefinition< 'savedVisualization', Filter | null, @@ -37,17 +45,51 @@ export function savedVisualization(): ExpressionFunctionDefinition< required: false, help: argHelp.id, }, + timerange: { + types: ['timerange'], + help: argHelp.timerange, + required: false, + }, + colors: { + types: ['seriesStyle'], + help: argHelp.colors, + multi: true, + required: false, + }, + hideLegend: { + types: ['boolean'], + help: argHelp.hideLegend, + required: false, + }, }, type: EmbeddableExpressionType, - fn: (input, { id }) => { + fn: (input, { id, timerange, colors, hideLegend }) => { const filters = input ? input.and : []; + const visOptions: VisualizeInput['vis'] = {}; + + if (colors) { + visOptions.colors = colors.reduce((reduction, color) => { + if (color.label && color.color) { + reduction[color.label] = color.color; + } + return reduction; + }, {} as Record); + } + + if (hideLegend === true) { + // @ts-ignore LegendOpen missing on VisualizeInput + visOptions.legendOpen = false; + } + return { type: EmbeddableExpressionType, input: { id, disableTriggers: true, - ...buildEmbeddableFilters(filters), + timeRange: timerange || defaultTimeRange, + filters: getQueryFilters(filters), + vis: visOptions, }, embeddableType: EmbeddableTypes.visualization, }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index d91e70e43bfd5..3cdb6eb460224 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -82,8 +82,13 @@ const embeddable = () => ({ ReactDOM.unmountComponentAtNode(domNode); const subscription = embeddableObject.getInput$().subscribe(function(updatedInput) { - handlers.onEmbeddableInputChange(embeddableInputToExpression(updatedInput, embeddableType)); + const updatedExpression = embeddableInputToExpression(updatedInput, embeddableType); + + if (updatedExpression) { + handlers.onEmbeddableInputChange(updatedExpression); + } }); + ReactDOM.render(renderEmbeddable(embeddableObject, domNode), domNode, () => handlers.done()); handlers.onResize(() => { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts index 4c622b0c247fa..9dee40c0f683b 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts @@ -4,119 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -jest.mock('ui/new_platform'); -import { embeddableInputToExpression } from './embeddable_input_to_expression'; -import { SavedMapInput } from '../../functions/common/saved_map'; -import { SavedLensInput } from '../../functions/common/saved_lens'; -import { EmbeddableTypes } from '../../expression_types'; -import { fromExpression, Ast } from '@kbn/interpreter/common'; +import { + embeddableInputToExpression, + inputToExpressionTypeMap, +} from './embeddable_input_to_expression'; -const baseEmbeddableInput = { +const input = { id: 'embeddableId', filters: [], -}; - -const baseSavedMapInput = { - ...baseEmbeddableInput, - isLayerTOCOpen: false, - refreshConfig: { - isPaused: true, - interval: 0, - }, hideFilterActions: true as true, }; describe('input to expression', () => { - describe('Map Embeddable', () => { - it('converts to a savedMap expression', () => { - const input: SavedMapInput = { - ...baseSavedMapInput, - }; - - const expression = embeddableInputToExpression(input, EmbeddableTypes.map); - const ast = fromExpression(expression); - - expect(ast.type).toBe('expression'); - expect(ast.chain[0].function).toBe('savedMap'); - - expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); - - expect(ast.chain[0].arguments).not.toHaveProperty('title'); - expect(ast.chain[0].arguments).not.toHaveProperty('center'); - expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); - }); - - it('includes optional input values', () => { - const input: SavedMapInput = { - ...baseSavedMapInput, - mapCenter: { - lat: 1, - lon: 2, - zoom: 3, - }, - title: 'title', - timeRange: { - from: 'now-1h', - to: 'now', - }, - }; - - const expression = embeddableInputToExpression(input, EmbeddableTypes.map); - const ast = fromExpression(expression); - - const centerExpression = ast.chain[0].arguments.center[0] as Ast; - - expect(centerExpression.chain[0].function).toBe('mapCenter'); - expect(centerExpression.chain[0].arguments.lat[0]).toEqual(input.mapCenter?.lat); - expect(centerExpression.chain[0].arguments.lon[0]).toEqual(input.mapCenter?.lon); - expect(centerExpression.chain[0].arguments.zoom[0]).toEqual(input.mapCenter?.zoom); - - const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; - - expect(timerangeExpression.chain[0].function).toBe('timerange'); - expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); - expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); - }); - }); - - describe('Lens Embeddable', () => { - it('converts to a savedLens expression', () => { - const input: SavedLensInput = { - ...baseEmbeddableInput, - }; - - const expression = embeddableInputToExpression(input, EmbeddableTypes.lens); - const ast = fromExpression(expression); - - expect(ast.type).toBe('expression'); - expect(ast.chain[0].function).toBe('savedLens'); - - expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); - - expect(ast.chain[0].arguments).not.toHaveProperty('title'); - expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); - }); - - it('includes optional input values', () => { - const input: SavedLensInput = { - ...baseEmbeddableInput, - title: 'title', - timeRange: { - from: 'now-1h', - to: 'now', - }, - }; - - const expression = embeddableInputToExpression(input, EmbeddableTypes.map); - const ast = fromExpression(expression); + it('converts to expression if method is available', () => { + const newType = 'newType'; + const mockReturn = 'expression'; + inputToExpressionTypeMap[newType] = jest.fn().mockReturnValue(mockReturn); - expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); - expect(ast.chain[0].arguments).toHaveProperty('timerange'); + const expression = embeddableInputToExpression(input, newType); - const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; - expect(timerangeExpression.chain[0].function).toBe('timerange'); - expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); - expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); - }); + expect(expression).toBe(mockReturn); }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts index 6428507b16a0c..5cba012fcb8e3 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts @@ -5,8 +5,15 @@ */ import { EmbeddableTypes, EmbeddableInput } from '../../expression_types'; -import { SavedMapInput } from '../../functions/common/saved_map'; -import { SavedLensInput } from '../../functions/common/saved_lens'; +import { toExpression as mapToExpression } from './input_type_to_expression/map'; +import { toExpression as visualizationToExpression } from './input_type_to_expression/visualization'; +import { toExpression as lensToExpression } from './input_type_to_expression/lens'; + +export const inputToExpressionTypeMap = { + [EmbeddableTypes.map]: mapToExpression, + [EmbeddableTypes.visualization]: visualizationToExpression, + [EmbeddableTypes.lens]: lensToExpression, +}; /* Take the input from an embeddable and the type of embeddable and convert it into an expression @@ -14,56 +21,8 @@ import { SavedLensInput } from '../../functions/common/saved_lens'; export function embeddableInputToExpression( input: EmbeddableInput, embeddableType: string -): string { - const expressionParts: string[] = []; - - if (embeddableType === EmbeddableTypes.map) { - const mapInput = input as SavedMapInput; - - expressionParts.push('savedMap'); - - expressionParts.push(`id="${input.id}"`); - - if (input.title) { - expressionParts.push(`title="${input.title}"`); - } - - if (mapInput.mapCenter) { - expressionParts.push( - `center={mapCenter lat=${mapInput.mapCenter.lat} lon=${mapInput.mapCenter.lon} zoom=${mapInput.mapCenter.zoom}}` - ); - } - - if (mapInput.timeRange) { - expressionParts.push( - `timerange={timerange from="${mapInput.timeRange.from}" to="${mapInput.timeRange.to}"}` - ); - } - - if (mapInput.hiddenLayers && mapInput.hiddenLayers.length) { - for (const layerId of mapInput.hiddenLayers) { - expressionParts.push(`hideLayer="${layerId}"`); - } - } +): string | undefined { + if (inputToExpressionTypeMap[embeddableType]) { + return inputToExpressionTypeMap[embeddableType](input as any); } - - if (embeddableType === EmbeddableTypes.lens) { - const lensInput = input as SavedLensInput; - - expressionParts.push('savedLens'); - - expressionParts.push(`id="${input.id}"`); - - if (input.title) { - expressionParts.push(`title="${input.title}"`); - } - - if (lensInput.timeRange) { - expressionParts.push( - `timerange={timerange from="${lensInput.timeRange.from}" to="${lensInput.timeRange.to}"}` - ); - } - } - - return expressionParts.join(' '); } diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts new file mode 100644 index 0000000000000..c4a9a22be3202 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { toExpression } from './lens'; +import { SavedLensInput } from '../../../functions/common/saved_lens'; +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +const baseEmbeddableInput = { + id: 'embeddableId', + filters: [], +}; + +describe('toExpression', () => { + it('converts to a savedLens expression', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedLens'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); + expect(ast.chain[0].arguments).toHaveProperty('timerange'); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.ts new file mode 100644 index 0000000000000..445cb7480ff80 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.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 { SavedLensInput } from '../../../functions/common/saved_lens'; + +export function toExpression(input: SavedLensInput): string { + const expressionParts = [] as string[]; + + expressionParts.push('savedLens'); + + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (input.timeRange) { + expressionParts.push( + `timerange={timerange from="${input.timeRange.from}" to="${input.timeRange.to}"}` + ); + } + + return expressionParts.join(' '); +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts new file mode 100644 index 0000000000000..4c294fb37c2db --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts @@ -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 { toExpression } from './map'; +import { SavedMapInput } from '../../../functions/common/saved_map'; +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +const baseSavedMapInput = { + id: 'embeddableId', + filters: [], + isLayerTOCOpen: false, + refreshConfig: { + isPaused: true, + interval: 0, + }, + hideFilterActions: true as true, +}; + +describe('toExpression', () => { + it('converts to a savedMap expression', () => { + const input: SavedMapInput = { + ...baseSavedMapInput, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedMap'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('center'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedMapInput = { + ...baseSavedMapInput, + mapCenter: { + lat: 1, + lon: 2, + zoom: 3, + }, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + const centerExpression = ast.chain[0].arguments.center[0] as Ast; + + expect(centerExpression.chain[0].function).toBe('mapCenter'); + expect(centerExpression.chain[0].arguments.lat[0]).toEqual(input.mapCenter?.lat); + expect(centerExpression.chain[0].arguments.lon[0]).toEqual(input.mapCenter?.lon); + expect(centerExpression.chain[0].arguments.zoom[0]).toEqual(input.mapCenter?.zoom); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts new file mode 100644 index 0000000000000..e3f9eca61ae28 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts @@ -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 { SavedMapInput } from '../../../functions/common/saved_map'; + +export function toExpression(input: SavedMapInput): string { + const expressionParts = [] as string[]; + + expressionParts.push('savedMap'); + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (input.mapCenter) { + expressionParts.push( + `center={mapCenter lat=${input.mapCenter.lat} lon=${input.mapCenter.lon} zoom=${input.mapCenter.zoom}}` + ); + } + + if (input.timeRange) { + expressionParts.push( + `timerange={timerange from="${input.timeRange.from}" to="${input.timeRange.to}"}` + ); + } + + if (input.hiddenLayers && input.hiddenLayers.length) { + for (const layerId of input.hiddenLayers) { + expressionParts.push(`hideLayer="${layerId}"`); + } + } + + return expressionParts.join(' '); +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.test.ts new file mode 100644 index 0000000000000..306020293abe6 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.test.ts @@ -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 { toExpression } from './visualization'; +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +const baseInput = { + id: 'embeddableId', +}; + +describe('toExpression', () => { + it('converts to a savedVisualization expression', () => { + const input = { + ...baseInput, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedVisualization'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + }); + + it('includes timerange if given', () => { + const input = { + ...baseInput, + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); + + it('includes colors if given', () => { + const colorMap = { a: 'red', b: 'blue' }; + + const input = { + ...baseInput, + vis: { + colors: { + a: 'red', + b: 'blue', + }, + }, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + const colors = ast.chain[0].arguments.colors as Ast[]; + + const aColor = colors.find(color => color.chain[0].arguments.label[0] === 'a'); + const bColor = colors.find(color => color.chain[0].arguments.label[0] === 'b'); + + expect(aColor?.chain[0].arguments.color[0]).toBe(colorMap.a); + expect(bColor?.chain[0].arguments.color[0]).toBe(colorMap.b); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts new file mode 100644 index 0000000000000..be0dd6a79292f --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts @@ -0,0 +1,35 @@ +/* + * 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 { VisualizeInput } from 'src/legacy/core_plugins/visualizations/public'; + +export function toExpression(input: VisualizeInput): string { + const expressionParts = [] as string[]; + + expressionParts.push('savedVisualization'); + expressionParts.push(`id="${input.id}"`); + + if (input.timeRange) { + expressionParts.push( + `timerange={timerange from="${input.timeRange.from}" to="${input.timeRange.to}"}` + ); + } + + if (input.vis?.colors) { + Object.entries(input.vis.colors) + .map(([label, color]) => { + return `colors={seriesStyle label="${label}" color="${color}"}`; + }) + .reduce((_, part) => expressionParts.push(part), 0); + } + + // @ts-ignore LegendOpen missing on VisualizeInput type + if (input.vis?.legendOpen !== undefined && input.vis.legendOpen === false) { + expressionParts.push(`hideLegend=true`); + } + + return expressionParts.join(' '); +} diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_visualization.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_visualization.ts index e3b412284442d..21a2e1c1b8800 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_visualization.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_visualization.ts @@ -14,6 +14,20 @@ export const help: FunctionHelp> = { defaultMessage: `Returns an embeddable for a saved visualization object`, }), args: { - id: 'The id of the saved visualization object', + id: i18n.translate('xpack.canvas.functions.savedVisualization.args.idHelpText', { + defaultMessage: `The ID of the Saved Visualization Object`, + }), + timerange: i18n.translate('xpack.canvas.functions.savedVisualization.args.timerangeHelpText', { + defaultMessage: `The timerange of data that should be included`, + }), + colors: i18n.translate('xpack.canvas.functions.savedVisualization.args.colorsHelpText', { + defaultMessage: `Define the color to use for a specific series`, + }), + hideLegend: i18n.translate( + 'xpack.canvas.functions.savedVisualization.args.hideLegendHelpText', + { + defaultMessage: `Should the legend be hidden`, + } + ), }, }; diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx index 353a59397d6b6..a86784d374f49 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -24,10 +24,10 @@ const allowedEmbeddables = { [EmbeddableTypes.lens]: (id: string) => { return `savedLens id="${id}" | render`; }, - // FIX: Only currently allow Map embeddables - /* [EmbeddableTypes.visualization]: (id: string) => { - return `filters | savedVisualization id="${id}" | render`; + [EmbeddableTypes.visualization]: (id: string) => { + return `savedVisualization id="${id}" | render`; }, + /* [EmbeddableTypes.search]: (id: string) => { return `filters | savedSearch id="${id}" | render`; },*/ From a68eaf22c944faf16740e78ac64b5f13703f0aeb Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Tue, 24 Mar 2020 20:18:53 +0000 Subject: [PATCH 30/56] [ML] Allow edits to jobs with additional custom_settings props (#61067) * [ML] Allow edits to jobs with additional custom_settings props * [ML] Minor text edit Co-authored-by: Elastic Machine --- .../server/routes/schemas/anomaly_detectors_schema.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts index 6002bb218c41b..22c3d94dfb29e 100644 --- a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts @@ -35,10 +35,13 @@ const customUrlSchema = { time_range: schema.maybe(schema.any()), }; -const customSettingsSchema = schema.object({ - created_by: schema.maybe(schema.string()), - custom_urls: schema.maybe(schema.arrayOf(schema.maybe(schema.object({ ...customUrlSchema })))), -}); +const customSettingsSchema = schema.object( + { + created_by: schema.maybe(schema.string()), + custom_urls: schema.maybe(schema.arrayOf(schema.maybe(schema.object({ ...customUrlSchema })))), + }, + { unknowns: 'allow' } // Create / Update job API allows other fields to be added to custom_settings. +); export const anomalyDetectionUpdateJobSchema = { description: schema.maybe(schema.string()), From e19ab1ef4b8e93380e5fdd7b9e225e92357ab913 Mon Sep 17 00:00:00 2001 From: Lee Drengenberg Date: Tue, 24 Mar 2020 20:24:37 +0000 Subject: [PATCH 31/56] streamline indexPatternTitle usage (#60999) Co-authored-by: Elastic Machine --- test/functional/apps/getting_started/_shakespeare.js | 3 +-- test/functional/apps/management/_handle_alias.js | 6 ++---- .../apps/management/_index_pattern_create_delete.js | 3 +-- test/functional/page_objects/settings_page.ts | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/test/functional/apps/getting_started/_shakespeare.js b/test/functional/apps/getting_started/_shakespeare.js index ded4eca908410..9a4bb0081b7ad 100644 --- a/test/functional/apps/getting_started/_shakespeare.js +++ b/test/functional/apps/getting_started/_shakespeare.js @@ -60,8 +60,7 @@ export default function({ getService, getPageObjects }) { it('should create shakespeare index pattern', async function() { log.debug('Create shakespeare index pattern'); await PageObjects.settings.createIndexPattern('shakes', null); - const indexPageHeading = await PageObjects.settings.getIndexPageHeading(); - const patternName = await indexPageHeading.getVisibleText(); + const patternName = await PageObjects.settings.getIndexPageHeading(); expect(patternName).to.be('shakes*'); }); diff --git a/test/functional/apps/management/_handle_alias.js b/test/functional/apps/management/_handle_alias.js index 4ef02f6c9e873..35c43c4633410 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.js @@ -51,8 +51,7 @@ export default function({ getService, getPageObjects }) { it('should be able to create index pattern without time field', async function() { await PageObjects.settings.createIndexPattern('alias1', null); - const indexPageHeading = await PageObjects.settings.getIndexPageHeading(); - const patternName = await indexPageHeading.getVisibleText(); + const patternName = await PageObjects.settings.getIndexPageHeading(); expect(patternName).to.be('alias1*'); }); @@ -66,8 +65,7 @@ export default function({ getService, getPageObjects }) { it('should be able to create index pattern with timefield', async function() { await PageObjects.settings.createIndexPattern('alias2', 'date'); - const indexPageHeading = await PageObjects.settings.getIndexPageHeading(); - const patternName = await indexPageHeading.getVisibleText(); + const patternName = await PageObjects.settings.getIndexPageHeading(); expect(patternName).to.be('alias2*'); }); diff --git a/test/functional/apps/management/_index_pattern_create_delete.js b/test/functional/apps/management/_index_pattern_create_delete.js index 4661c9b4d53b8..a74620b696d1b 100644 --- a/test/functional/apps/management/_index_pattern_create_delete.js +++ b/test/functional/apps/management/_index_pattern_create_delete.js @@ -71,8 +71,7 @@ export default function({ getService, getPageObjects }) { }); it('should have index pattern in page header', async function() { - const indexPageHeading = await PageObjects.settings.getIndexPageHeading(); - const patternName = await indexPageHeading.getVisibleText(); + const patternName = await PageObjects.settings.getIndexPageHeading(); expect(patternName).to.be('logstash-*'); }); diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 25706fda74925..3f6036f58f0a9 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -169,7 +169,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider } async getIndexPageHeading() { - return await testSubjects.find('indexPatternTitle'); + return await testSubjects.getVisibleText('indexPatternTitle'); } async getConfigureHeader() { From 36285d62b65da3d3153c779c0ac81b4f9a064168 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Tue, 24 Mar 2020 13:56:08 -0700 Subject: [PATCH 32/56] Closes #60754 by removing uiFilters from the API route for service map (#61012) since it is unused. --- .../apm/public/components/app/ServiceMap/index.tsx | 10 +++------- .../apm/server/lib/service_map/get_service_map.ts | 12 +++++------- .../server/lib/service_map/get_trace_sample_ids.ts | 8 ++------ x-pack/plugins/apm/server/routes/service_map.ts | 3 +-- 4 files changed, 11 insertions(+), 22 deletions(-) diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index d523a7cb331e1..0abaa9d76fc07 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -31,7 +31,7 @@ interface ServiceMapProps { export function ServiceMap({ serviceName }: ServiceMapProps) { const license = useLicense(); - const { urlParams, uiFilters } = useUrlParams(); + const { urlParams } = useUrlParams(); const { data } = useFetcher(() => { const { start, end, environment } = urlParams; @@ -43,16 +43,12 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { start, end, environment, - serviceName, - uiFilters: JSON.stringify({ - ...uiFilters, - environment: undefined - }) + serviceName } } }); } - }, [serviceName, uiFilters, urlParams]); + }, [serviceName, urlParams]); const { ref, height, width } = useRefDimensions(); diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts index 1414f743e8a03..17b595385a84e 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts @@ -13,17 +13,13 @@ import { import { getServicesProjection } from '../../../common/projections/services'; import { mergeProjection } from '../../../common/projections/util/merge_projection'; import { PromiseReturnType } from '../../../typings/common'; -import { - Setup, - SetupTimeRange, - SetupUIFilters -} from '../helpers/setup_request'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { dedupeConnections } from './dedupe_connections'; import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids'; import { getTraceSampleIds } from './get_trace_sample_ids'; export interface IEnvOptions { - setup: Setup & SetupTimeRange & SetupUIFilters; + setup: Setup & SetupTimeRange; serviceName?: string; environment?: string; } @@ -77,7 +73,9 @@ async function getConnectionData({ async function getServicesData(options: IEnvOptions) { const { setup } = options; - const projection = getServicesProjection({ setup }); + const projection = getServicesProjection({ + setup: { ...setup, uiFiltersES: [] } + }); const { filter } = projection.body.query.bool; diff --git a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts index 9cb1a61e1d76f..5e2ab82239d9f 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts @@ -4,11 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { uniq, take, sortBy } from 'lodash'; -import { - Setup, - SetupUIFilters, - SetupTimeRange -} from '../helpers/setup_request'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { rangeFilter } from '../helpers/range_filter'; import { ESFilter } from '../../../typings/elasticsearch'; import { @@ -28,7 +24,7 @@ export async function getTraceSampleIds({ }: { serviceName?: string; environment?: string; - setup: Setup & SetupTimeRange & SetupUIFilters; + setup: Setup & SetupTimeRange; }) { const { start, end, client, indices, config } = setup; diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index a61a61e3ccaac..6838717cbc6da 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -14,7 +14,7 @@ import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceMap } from '../lib/service_map/get_service_map'; import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; import { createRoute } from './create_route'; -import { rangeRt, uiFiltersRt } from './default_api_types'; +import { rangeRt } from './default_api_types'; export const serviceMapRoute = createRoute(() => ({ path: '/api/apm/service-map', @@ -24,7 +24,6 @@ export const serviceMapRoute = createRoute(() => ({ environment: t.string, serviceName: t.string }), - uiFiltersRt, rangeRt ]) }, From 34bfc1bfc7d0a912e9f33f3282e5601c2ac3690e Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Tue, 24 Mar 2020 21:59:27 +0100 Subject: [PATCH 33/56] Bump core-js and jimp (#61124) This commit bumps the following dependencies: - core-js (prod-dependency): 3.2.1 -> 3.6.4 - jimp (dev-dependency): 0.8.4 -> 0.9.6 --- package.json | 4 +- yarn.lock | 418 +++++++++++++++++++++++++++------------------------ 2 files changed, 225 insertions(+), 197 deletions(-) diff --git a/package.json b/package.json index 9bb9b505f54a2..9c8cc6538269f 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,7 @@ "color": "1.0.3", "commander": "3.0.2", "compare-versions": "3.5.1", - "core-js": "^3.2.1", + "core-js": "^3.6.4", "css-loader": "^3.4.2", "d3": "3.5.17", "d3-cloud": "1.2.5", @@ -443,7 +443,7 @@ "jest": "^24.9.0", "jest-cli": "^24.9.0", "jest-raw-loader": "^1.0.1", - "jimp": "0.8.4", + "jimp": "^0.9.6", "json5": "^1.0.1", "karma": "3.1.4", "karma-chrome-launcher": "2.2.0", diff --git a/yarn.lock b/yarn.lock index f88db13f4ead1..5c988ccd3e736 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2416,257 +2416,284 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" -"@jimp/bmp@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.8.4.tgz#3246e0c6b073b3e2d9b61075ac0146d9124c9277" - integrity sha512-Cf/V+SUyEVxCCP8q1emkarCHJ8NkLFcLp41VMqBihoR4ke0TIPfCSdgW/JXbM/28vvZ5a2bvMe6uOll6cFggvA== +"@jimp/bmp@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.9.6.tgz#379e261615d7c1f3b52af4d5a0f324666de53d7d" + integrity sha512-T2Fh/k/eN6cDyOx0KQ4y56FMLo8+mKNhBh7GXMQXLK2NNZ0ckpFo3VHDBZ3HnaFeVTZXF/atLiR9CfnXH+rLxA== dependencies: - "@jimp/utils" "^0.8.4" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" bmp-js "^0.1.0" - core-js "^2.5.7" + core-js "^3.4.1" -"@jimp/core@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/core/-/core-0.8.4.tgz#fbdb3cb0823301381736e988674f14c282dc5c63" - integrity sha512-3fK5UEOEQsfSDhsrAgBT6W8Up51qkeCj9RVjusxUaEGmix34PO/KTVfzURlu6NOpOUvtfNXsCq9xS7cxBTWSCA== +"@jimp/core@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/core/-/core-0.9.6.tgz#a553f801bd436526d36e8982b99e58e8afc3d17a" + integrity sha512-sQO04S+HZNid68a9ehb4BC2lmW6iZ5JgU9tC+thC2Lhix+N/XKDJcBJ6HevbLgeTzuIAw24C5EKuUeO3C+rE5w== dependencies: - "@jimp/utils" "^0.8.4" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" any-base "^1.1.0" buffer "^5.2.0" - core-js "^2.5.7" + core-js "^3.4.1" exif-parser "^0.1.12" file-type "^9.0.0" load-bmfont "^1.3.1" - mkdirp "0.5.1" + mkdirp "^0.5.1" phin "^2.9.1" pixelmatch "^4.0.2" tinycolor2 "^1.4.1" -"@jimp/custom@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/custom/-/custom-0.8.4.tgz#abd61281ce12194ae23046ee71d60b754b515bc8" - integrity sha512-iS/RB3QQKpm4QS8lxxtQzvYDMph9YvOn3d68gMM4pDKn95n3nt5/ySHFv6fQq/yzfox1OPdeYaXbOLvC3+ofqw== +"@jimp/custom@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/custom/-/custom-0.9.6.tgz#3d8a19d6ed717f0f1aa3f1b8f42fa374f43bc715" + integrity sha512-ZYKgrBZVoQwvIGlQSO7MFmn7Jn8a9X5g1g+KOTDO9Q0s4vnxdPTtr/qUjG9QYX6zW/6AK4LaIsDinDrrKDnOag== dependencies: - "@jimp/core" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/core" "^0.9.6" + core-js "^3.4.1" -"@jimp/gif@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/gif/-/gif-0.8.4.tgz#1429a71ed3b055f73d63c9b195fa7f0a46e947b5" - integrity sha512-YpHZ7aWzmrviY7YigXRolHs6oBhGJItRry8fh3zebAgKth06GMv58ce84yXXOKX4yQ+QGd6GgOWzePx+KMP9TA== +"@jimp/gif@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/gif/-/gif-0.9.6.tgz#0a7b1e521daca635b02259f941bdb3600569d8e6" + integrity sha512-Z2muC2On8KHEVrWKCCM0L2eua9kw4bQETzT7gmVsizc8MXAKdS8AyVV9T3ZrImiI0o5UkAN/u0cPi1U2pSiD8Q== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" omggif "^1.0.9" -"@jimp/jpeg@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/jpeg/-/jpeg-0.8.4.tgz#effde867116f88f59ac20b44b1a526b11caca026" - integrity sha512-7exKk3LNPKJgsFzUPL+mOJtIEHcLp6yU9sVbULffVDjVUun6/Are2tCX8rCXZq28yiUhofzr61k5UqjkKFJXrA== +"@jimp/jpeg@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/jpeg/-/jpeg-0.9.6.tgz#fb90bdc0111966987c5ba59cdca7040be86ead41" + integrity sha512-igSe0pIX3le/CKdvqW4vLXMxoFjTLjEaW6ZHt/h63OegaEa61TzJ2OM7j7DxrEHcMCMlkhUc9Bapk57MAefCTQ== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" jpeg-js "^0.3.4" -"@jimp/plugin-blit@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-blit/-/plugin-blit-0.8.4.tgz#991b4199cc5506f0faae22b821b14ec93fbce1bb" - integrity sha512-H9bpetmOUgEHpkDSRzbXLMXQhr34i8YicYV3EDeuHU8mKlAjtMbVpbp5ZN4mcadTz+EYdTdVNfQNsRCcIb5Oeg== +"@jimp/plugin-blit@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-blit/-/plugin-blit-0.9.6.tgz#7937e02e3514b95dbe4c70d444054847f6e9ce3c" + integrity sha512-zp7X6uDU1lCu44RaSY88aAvsSKbgqUrfDyWRX1wsamJvvZpRnp1WekWlGyydRtnlUBAGIpiHCHmyh/TJ2I4RWA== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-blur@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-blur/-/plugin-blur-0.8.4.tgz#460f79c45eda7f24adf624a691134d6192d3dbb4" - integrity sha512-gvEDWW7+MI9Hk1KKzuFliRdDPaofkxB4pRJ/n1hipDoOGcNYFqxx5FGNQ4wsGSDpQ+RiHZF+JGKKb+EIwHg+0Q== +"@jimp/plugin-blur@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-blur/-/plugin-blur-0.9.6.tgz#3d74b18c27e9eae11b956ffe26290ece6d250813" + integrity sha512-xEi63hvzewUp7kzw+PI3f9CIrgZbphLI4TDDHWNYuS70RvhTuplbR6RMHD/zFhosrANCkJGr5OZJlrJnsCg6ug== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-color@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-color/-/plugin-color-0.8.4.tgz#a9aa525421ea50bf2c1baec7618f73b7e4fc3464" - integrity sha512-DHCGMxInCI1coXMIfdZJ5G/4hpt5yZLNB5+oUIxT4aClzyhUjqD4xOcnO7hlPY6LuX8+FX7cYMHhdMfhTXB3Dg== +"@jimp/plugin-color@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-color/-/plugin-color-0.9.6.tgz#d0fca0ed4c2c48fd6f929ef4a03cebf9c1342e14" + integrity sha512-o1HSoqBVUUAsWbqSXnpiHU0atKWy/Q1GUbZ3F5GWt/0OSDyl9RWM82V9axT2vePZHInKjIaimhnx1gGj8bfxkQ== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" tinycolor2 "^1.4.1" -"@jimp/plugin-contain@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-contain/-/plugin-contain-0.8.4.tgz#2db8c12de910490cd74f339e9414a968b8c9328e" - integrity sha512-3wwLXig5LkOMg5FrNZrX/r99ehaA+0s3dkro3CiRg0Ez6Y0fz067so+HdsmqmoG78WY/dCdgdps/xLOW2VV4DQ== +"@jimp/plugin-contain@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-contain/-/plugin-contain-0.9.6.tgz#7d7bbd5e9c2fa4391a3d63620e13a28f51e1e7e8" + integrity sha512-Xz467EN1I104yranET4ff1ViVKMtwKLg1uRe8j3b5VOrjtiXpDbjirNZjP3HTlv8IEUreWNz4BK7ZtfHSptufA== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-cover@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-cover/-/plugin-cover-0.8.4.tgz#a09bfbbe88129754ca35e281707bc5ed3f3f0c63" - integrity sha512-U0xmSfGLmw0Ieiw00CM8DQ+XoQVBxbjsLE5To8EejnyLx5X+oNZ8r7E5EsQaushUlzij95IqMCloo+nCGhdYMw== +"@jimp/plugin-cover@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-cover/-/plugin-cover-0.9.6.tgz#2853de7f8302f655ae8e95f51ab25a0ed77e3756" + integrity sha512-Ocr27AvtvH4ZT/9EWZgT3+HQV9fG5njwh2CYMHbdpx09O62Asj6pZ4QI0kKzOcux1oLgv59l7a93pEfMOfkfwQ== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-crop@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-crop/-/plugin-crop-0.8.4.tgz#ca5bd359c4e4b2374777bae6130e8b94552932fa" - integrity sha512-Neqs0K4cr7SU9nSte2qvGVh/8+K9ArH8mH1fWhZw4Zq8qD9NicX+g5hqmpmeSjOKD73t/jOmwvBevfJDu2KKSA== +"@jimp/plugin-crop@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-crop/-/plugin-crop-0.9.6.tgz#82e539af2a2417783abbd143124a57672ff4cc31" + integrity sha512-d9rNdmz3+eYLbSKcTyyp+b8Nmhf6HySnimDXlTej4UP6LDtkq2VAyVaJ12fz9x6dfd8qcXOBXMozSfNCcgpXYA== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-displace@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-displace/-/plugin-displace-0.8.4.tgz#c6d5cff889e52cb64194979967e6bd7fff4d5d1b" - integrity sha512-qKCwAP2lAO3R8ofYaEF/Gh+sfcjzZLtEiYHzjx/mYvPpXS6Yvkvl28aUH8pwdJYT+QYGelHmOne0RJvjsac1NQ== +"@jimp/plugin-displace@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-displace/-/plugin-displace-0.9.6.tgz#67564d081dc6b19007248ca222d025fd6f90c03b" + integrity sha512-SWpbrxiHmUYBVWtDDMjaG3eRDBASrTPaad7l07t73/+kmU6owAKWQW6KtVs05MYSJgXz7Ggdr0fhEn9AYLH1Rg== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-dither@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-dither/-/plugin-dither-0.8.4.tgz#a2320d6a8c467cf7697109e0c5ed4ee3d3898b73" - integrity sha512-19+y5VAO6d0keRne9eJCdOeB9X0LFuRdRSjgwl/57JtREeoPj+iKBg6REBl4atiSGd7/UCFg3wRtFOw24XFKgw== +"@jimp/plugin-dither@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-dither/-/plugin-dither-0.9.6.tgz#dc48669cf51f3933761aa9137e99ebfa000b8cce" + integrity sha512-abm1GjfYK7ru/PoxH9fAUmhl+meHhGEClbVvjjMMe5g2S0BSTvMJl3SrkQD/FMkRLniaS/Qci6aQhIi+8rZmSw== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-flip@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-flip/-/plugin-flip-0.8.4.tgz#08bf46470c3c0b4890691f554c28ccf17813746f" - integrity sha512-1BtKtc8cANuGgiWyOmltQZaR3Y5Og/GS/db8wBpFNLJ33Ir5UAGN2raDtx4EYEd5okuRVFj3OP+wAZl69m72LQ== +"@jimp/plugin-flip@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-flip/-/plugin-flip-0.9.6.tgz#f81f9b886da8cd56e23dd04d5aa359f2b94f939e" + integrity sha512-KFZTzAzQQ5bct3ii7gysOhWrTKVdUOghkkoSzLi+14nO3uS/dxiu8fPeH1m683ligbdnuM/b22OuLwEwrboTHA== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-gaussian@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-gaussian/-/plugin-gaussian-0.8.4.tgz#f3be12c5f16c5670959ab711e69b2963f66f7b4f" - integrity sha512-qYcVmiJn8l8uDZqk4FlB/qTV8fJgiJAh/xc/WKNEp2E8qFEgxoIPeimPHO8cJorEHqlh8I8l24OZkTkkEKaFfw== +"@jimp/plugin-gaussian@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-gaussian/-/plugin-gaussian-0.9.6.tgz#6c93897ee0ff979466184d7d0ec0fbc95c679be4" + integrity sha512-WXKLtJKWchXfWHT5HIOq1HkPKpbH7xBLWPgVRxw00NV/6I8v4xT63A7/Nag78m00JgjwwiE7eK2tLGDbbrPYig== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-invert@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-invert/-/plugin-invert-0.8.4.tgz#fd4577beba2973f663164f5ee454b2172ca56b34" - integrity sha512-OQ/dFDbBUmEd935Gitl5Pmgz+nLVyszwS0RqL6+G1U9EHYBeiHDrmY2sj7NgDjDEJYlRLxGlBRsTIPHzF3tdNw== +"@jimp/plugin-invert@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-invert/-/plugin-invert-0.9.6.tgz#4b3fa7b81ea976b09b82b3db59ee00ac3093d2fd" + integrity sha512-Pab/cupZrYxeRp07N4L5a4C/3ksTN9k6Knm/o2G5C789OF0rYsGGLcnBR/6h69nPizRZHBYdXCEyXYgujlIFiw== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-mask@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-mask/-/plugin-mask-0.8.4.tgz#0dfe02a14530c3bddfc258e83bd3c979e53d15ef" - integrity sha512-uqLdRGShHwCd9RHv8bMntTfDNDI2pcEeE7+F868P6PngWLKrzQCpuAyTnK6WK0ZN95fSsgy7TzCoesYk+FchkQ== +"@jimp/plugin-mask@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-mask/-/plugin-mask-0.9.6.tgz#d70be0030ab3294b191f5b487fb655d776820b19" + integrity sha512-ikypRoDJkbxXlo6gW+EZOcTiLDIt0DrPwOFMt1bvL8UV2QPgX+GJ685IYwhIfEhBf/GSNFgB/NYsVvuSufTGeg== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-normalize@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-normalize/-/plugin-normalize-0.8.4.tgz#aa2c3131082b6ceef2fb6222323db9f7d837447c" - integrity sha512-+ihgQeVD8syWxw12F5ngUUdtlIcGDqH7hEoHcwVVGOFfaJqR4YBQR4FM3QLFFFdi2X/uK2nGJt9cMh0UaINEgw== +"@jimp/plugin-normalize@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-normalize/-/plugin-normalize-0.9.6.tgz#c9128412a53485d91236a1da241f3166e572be4a" + integrity sha512-V3GeuAJ1NeL7qsLoDjnypJq24RWDCwbXpKhtxB+Yg9zzgOCkmb041p7ysxbcpkuJsRpKLNABZeNCCqd83bRawA== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-print@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-print/-/plugin-print-0.8.4.tgz#c110d6e7632e3c9cf47ce395e36b0f3c1461a9ca" - integrity sha512-Wg5tZI3hW5DG9Caz4wg4ZolS3Lvv4MFAxORPAeWeahDpHs38XZ7ydJ0KR39p2oWJPP0yIFv1fETYpU7BiJPRRw== +"@jimp/plugin-print@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-print/-/plugin-print-0.9.6.tgz#fea31ffeafee18ae7b5cfd6fa131bb205abfee51" + integrity sha512-gKkqZZPQtMSufHOL0mtJm5d/KI2O6+0kUpOBVSYdGedtPXA61kmVnsOd3wwajIMlXA3E0bDxLXLdAguWqjjGgw== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" load-bmfont "^1.4.0" -"@jimp/plugin-resize@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-resize/-/plugin-resize-0.8.4.tgz#6690f50c98cfd89ac3682b58ba9623e7c46e0be6" - integrity sha512-z9tumvsQja/YFTSeGvofYLvVws8LZYLYVW8l17hBETzfZQdVEvPOdWKkXqsAsK5uY9m8M5rH7kR8NZbCDVbyzA== +"@jimp/plugin-resize@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-resize/-/plugin-resize-0.9.6.tgz#7fb939c8a42e2a3639d661cc7ab24057598693bd" + integrity sha512-r5wJcVII7ZWMuY2l6WSbHPG6gKMFemtCHmJRXGUu+/ZhPGBz3IFluycBpHkWW3OB+jfvuyv1EGQWHU50N1l8Og== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-rotate@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-rotate/-/plugin-rotate-0.8.4.tgz#bf3ea70d10123f1372507b74d06bfefb40b3e526" - integrity sha512-PVxpt3DjqaUnHP6Nd3tzZjl4SYe/FYXszGTshtx51AMuvZLnpvekrrclYyc7Dc1Ry3kx3ma6UuLCvmf85hrdmw== +"@jimp/plugin-rotate@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-rotate/-/plugin-rotate-0.9.6.tgz#06d725155e5cdb615133f57a52f5a860a9d03f3e" + integrity sha512-B2nm/eO2nbvn1DgmnzMd79yt3V6kffhRNrKoo2VKcKFiVze1vGP3MD3fVyw5U1PeqwAFu7oTICFnCf9wKDWSqg== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-scale@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-scale/-/plugin-scale-0.8.4.tgz#2de9cc80d49f6a36e4177b22e0ab1d4305331c2e" - integrity sha512-PrBTOMJ5n4gbIvRNxWfc1MdgHw4vd5r1UOHRVuc6ZQ9Z/FueBuvIidnz7GBRHbsRm3IjckvsLfEL1nIK0Kqh3A== +"@jimp/plugin-scale@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-scale/-/plugin-scale-0.9.6.tgz#3fa939c1a4f44383e12afeb7c434eb41f20e4a1c" + integrity sha512-DLsLB5S3mh9+TZY5ycwfLgOJvUcoS7bP0Mi3I8vE1J91qmA+TXoWFFgrIVgnEPw5jSKzNTt8WhykQ0x2lKXncw== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugins@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugins/-/plugins-0.8.4.tgz#af24c0686aec327f3abcc50ba5bbae1df2113fb0" - integrity sha512-Vd0oCe0bj7c+crHL6ee178q2c1o50UnbCmc0imHYg7M+pY8S1kl4ubZWwkAg2W96FCarGrm9eqPvCUyAdFOi9w== - dependencies: - "@jimp/plugin-blit" "^0.8.4" - "@jimp/plugin-blur" "^0.8.4" - "@jimp/plugin-color" "^0.8.4" - "@jimp/plugin-contain" "^0.8.4" - "@jimp/plugin-cover" "^0.8.4" - "@jimp/plugin-crop" "^0.8.4" - "@jimp/plugin-displace" "^0.8.4" - "@jimp/plugin-dither" "^0.8.4" - "@jimp/plugin-flip" "^0.8.4" - "@jimp/plugin-gaussian" "^0.8.4" - "@jimp/plugin-invert" "^0.8.4" - "@jimp/plugin-mask" "^0.8.4" - "@jimp/plugin-normalize" "^0.8.4" - "@jimp/plugin-print" "^0.8.4" - "@jimp/plugin-resize" "^0.8.4" - "@jimp/plugin-rotate" "^0.8.4" - "@jimp/plugin-scale" "^0.8.4" - core-js "^2.5.7" +"@jimp/plugins@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugins/-/plugins-0.9.6.tgz#a1cfdf9f3e1adf5b124686486343888a16c8fd27" + integrity sha512-eQI29e+K+3L/fb5GbPgsBdoftvaYetSOO2RL5z+Gjk6R4EF4QFRo63YcFl+f72Kc1b0JTOoDxClvn/s5GMV0tg== + dependencies: + "@babel/runtime" "^7.7.2" + "@jimp/plugin-blit" "^0.9.6" + "@jimp/plugin-blur" "^0.9.6" + "@jimp/plugin-color" "^0.9.6" + "@jimp/plugin-contain" "^0.9.6" + "@jimp/plugin-cover" "^0.9.6" + "@jimp/plugin-crop" "^0.9.6" + "@jimp/plugin-displace" "^0.9.6" + "@jimp/plugin-dither" "^0.9.6" + "@jimp/plugin-flip" "^0.9.6" + "@jimp/plugin-gaussian" "^0.9.6" + "@jimp/plugin-invert" "^0.9.6" + "@jimp/plugin-mask" "^0.9.6" + "@jimp/plugin-normalize" "^0.9.6" + "@jimp/plugin-print" "^0.9.6" + "@jimp/plugin-resize" "^0.9.6" + "@jimp/plugin-rotate" "^0.9.6" + "@jimp/plugin-scale" "^0.9.6" + core-js "^3.4.1" timm "^1.6.1" -"@jimp/png@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/png/-/png-0.8.4.tgz#d150ddaaebafcda83d820390f62a4d3c409acc03" - integrity sha512-DLj260SwQr9ZNhSto1BacXGNRhIQiLNOESPoq5DGjbqiPCmYNxE7CPlXB1BVh0T3AmZBjnZkZORU0Y9wTi3gJw== +"@jimp/png@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/png/-/png-0.9.6.tgz#00ed7e6fb783b94f2f1a9fadf9a42bd75f70cc7f" + integrity sha512-9vhOG2xylcDqPbBf4lzpa2Sa1WNJrEZNGvPvWcM+XVhqYa8+DJBLYkoBlpI/qWIYA+eVWDnLF3ygtGj8CElICw== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" pngjs "^3.3.3" -"@jimp/tiff@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/tiff/-/tiff-0.8.4.tgz#bc18c32cef996ad986a92bb7d926d6abd1db9d10" - integrity sha512-SQmf1B/TbCtbwzJReLw/lzGqbeu8MOfT+wkaia0XWS72H6bEW66PTQKhB4/3uzC/Xnmsep1WNQITlwcWdgc36Q== +"@jimp/tiff@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/tiff/-/tiff-0.9.6.tgz#9ff12122e727ee15f27f40a710516102a636f66b" + integrity sha512-pKKEMqPzX9ak8mek2iVVoW34+h/TSWUyI4NjbYWJMQ2WExfuvEJvLocy9Q9xi6HqRuJmUxgNIiC5iZM1PDEEfg== dependencies: - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + core-js "^3.4.1" utif "^2.0.1" -"@jimp/types@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/types/-/types-0.8.4.tgz#01df00a5adb955cb4ba79df1288408faa3bb40ed" - integrity sha512-BCehQ5hrTOGDGdeROwXOYqgFGAzJPkuXmVJXgMgBoW1YjoGWhXJ5iShaJ/l7DRErrdezoWUdAhTFlV5bJf51dg== - dependencies: - "@jimp/bmp" "^0.8.4" - "@jimp/gif" "^0.8.4" - "@jimp/jpeg" "^0.8.4" - "@jimp/png" "^0.8.4" - "@jimp/tiff" "^0.8.4" - core-js "^2.5.7" +"@jimp/types@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/types/-/types-0.9.6.tgz#7be7f415ad93be733387c03b8a228c587a868a18" + integrity sha512-PSjdbLZ8d50En+Wf1XkWFfrXaf/GqyrxxgIwFWPbL+wrW4pmbYovfxSLCY61s8INsOFOft9dzzllhLBtg1aQ6A== + dependencies: + "@babel/runtime" "^7.7.2" + "@jimp/bmp" "^0.9.6" + "@jimp/gif" "^0.9.6" + "@jimp/jpeg" "^0.9.6" + "@jimp/png" "^0.9.6" + "@jimp/tiff" "^0.9.6" + core-js "^3.4.1" timm "^1.6.1" -"@jimp/utils@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/utils/-/utils-0.8.4.tgz#a6bbdc13dba99b95d4cabf0bde87b1bcd2230d25" - integrity sha512-6Cwplao7IgwhFRijMvvyjdV7Sa7Fw71vS1aDsUDCVpi3XHsiLUM+nPTno6OKjzg2z2EufuolWPEvuq/GSte4lA== +"@jimp/utils@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/utils/-/utils-0.9.6.tgz#a3e6c29e835e2b9ea9f3899c9d3d230dc63bd518" + integrity sha512-kzxcp0i4ecSdMXFEmtH+NYdBQysINEUTsrjm7v0zH8t/uwaEMOG46I16wo/iPBXJkUeNdL2rbXoGoxxoeSfrrA== dependencies: - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + core-js "^3.4.1" "@mapbox/extent@0.4.0": version "0.4.0" @@ -10219,10 +10246,10 @@ core-js@^2.2.0, core-js@^2.4.0, core-js@^2.5.0, core-js@^2.5.1, core-js@^2.5.3, resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2" integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A== -core-js@^3.0.1, core-js@^3.0.4, core-js@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.2.1.tgz#cd41f38534da6cc59f7db050fe67307de9868b09" - integrity sha512-Qa5XSVefSVPRxy2XfUC13WbvqkxhkwB3ve+pgCQveNgYzbM/UxZeu1dcOX/xr4UmfUd+muuvsaxilQzCyUurMw== +core-js@^3.0.1, core-js@^3.0.4, core-js@^3.2.1, core-js@^3.4.1, core-js@^3.6.4: + version "3.6.4" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.4.tgz#440a83536b458114b9cb2ac1580ba377dc470647" + integrity sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw== core-util-is@1.0.2, core-util-is@^1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -18479,15 +18506,16 @@ jest@^24.9.0: import-local "^2.0.0" jest-cli "^24.9.0" -jimp@0.8.4: - version "0.8.4" - resolved "https://registry.yarnpkg.com/jimp/-/jimp-0.8.4.tgz#9c7c6ee4c8992e585a60914c62aee0c5e5c7955b" - integrity sha512-xCPvd2HIH8iR7+gWVnivzXwiQGnLBmLDpaEj5M0vQf3uur5MuLCOWbBduAdk6r3ur8X0kwgM4eEM0i7o+k9x9g== +jimp@^0.9.6: + version "0.9.6" + resolved "https://registry.yarnpkg.com/jimp/-/jimp-0.9.6.tgz#abf381daf193a4fa335cb4ee0e22948049251eb9" + integrity sha512-DBDHYeNVqVpoPkcvo0PKTNGvD+i7NYvkKTsl0I3k7ql36uN8wGTptRg0HtgQyYE/bhGSLI6Lq5qLwewaOPXNfg== dependencies: - "@jimp/custom" "^0.8.4" - "@jimp/plugins" "^0.8.4" - "@jimp/types" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/custom" "^0.9.6" + "@jimp/plugins" "^0.9.6" + "@jimp/types" "^0.9.6" + core-js "^3.4.1" regenerator-runtime "^0.13.3" jit-grunt@0.10.0: From e251310000bcad8649b4993f547e156237714732 Mon Sep 17 00:00:00 2001 From: Srikanth Suresh <4140019+srikwit@users.noreply.github.com> Date: Wed, 25 Mar 2020 00:01:19 +0300 Subject: [PATCH 34/56] [SR] Updating href for source-only repository documentation (#60951) --- .../snapshot_restore/public/application/constants/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/snapshot_restore/public/application/constants/index.ts b/x-pack/plugins/snapshot_restore/public/application/constants/index.ts index 481516479df4e..9c8fb3d288d24 100644 --- a/x-pack/plugins/snapshot_restore/public/application/constants/index.ts +++ b/x-pack/plugins/snapshot_restore/public/application/constants/index.ts @@ -17,7 +17,7 @@ export enum REPOSITORY_DOC_PATHS { default = 'modules-snapshots.html', fs = 'modules-snapshots.html#_shared_file_system_repository', url = 'modules-snapshots.html#_read_only_url_repository', - source = 'modules-snapshots.html#_source_only_repository', + source = 'snapshots-register-repository.html#snapshots-source-only-repository', s3 = 'repository-s3.html', hdfs = 'repository-hdfs.html', azure = 'repository-azure.html', From 498abb4152c8d22d1bee15b9c357b8daea3261d6 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Tue, 24 Mar 2020 16:22:11 -0500 Subject: [PATCH 35/56] Revert "Drilldowns (#59632)" (#61136) This reverts commit 5abb2c8c7dbc6dfca05059261708f800ca1807bb. --- .github/CODEOWNERS | 1 - examples/ui_action_examples/public/plugin.ts | 2 +- examples/ui_actions_explorer/public/app.tsx | 3 +- .../ui_actions_explorer/public/plugin.tsx | 16 +- .../public/overlays/flyout/flyout_service.tsx | 1 - src/dev/storybook/aliases.ts | 4 +- .../public/embeddable/visualize_embeddable.ts | 8 +- .../visualize_embeddable_factory.tsx | 10 +- .../public/np_ready/public/mocks.ts | 5 +- .../public/np_ready/public/plugin.ts | 6 +- .../public/actions/replace_panel_action.tsx | 2 +- src/plugins/dashboard/public/plugin.tsx | 4 +- .../public/tests/dashboard_container.test.tsx | 2 +- src/plugins/data/public/plugin.ts | 9 +- .../public/lib/actions/edit_panel_action.ts | 2 +- .../public/lib/embeddables/embeddable.tsx | 73 +- .../embeddable_action_storage.test.ts | 128 ++-- .../embeddables/embeddable_action_storage.ts | 53 +- .../public/lib/embeddables/i_embeddable.ts | 16 +- .../lib/panel/embeddable_panel.test.tsx | 14 +- .../public/lib/panel/embeddable_panel.tsx | 38 +- .../customize_title/customize_panel_action.ts | 8 +- .../panel_actions/inspect_panel_action.ts | 2 +- .../panel_actions/remove_panel_action.ts | 2 +- .../lib/panel/panel_header/panel_header.tsx | 9 +- .../create_state_container_react_helpers.ts | 69 +- .../common/state_containers/types.ts | 2 +- src/plugins/kibana_utils/index.ts | 20 - .../ui_actions/public/actions/action.ts | 26 +- .../action_definition.ts} | 47 +- .../public/actions/action_factory.ts | 71 -- .../actions/action_factory_definition.ts | 46 -- .../public/actions/action_internal.test.ts | 33 - .../public/actions/action_internal.ts | 58 -- .../public/actions/create_action.ts | 14 +- .../actions/dynamic_action_manager.test.ts | 646 ------------------ .../public/actions/dynamic_action_manager.ts | 284 -------- .../actions/dynamic_action_manager_state.ts | 111 --- .../public/actions/dynamic_action_storage.ts | 102 --- .../ui_actions/public/actions/index.ts | 6 - .../ui_actions/public/actions/types.ts | 24 - .../build_eui_context_menu_panels.tsx | 61 +- src/plugins/ui_actions/public/index.ts | 22 +- src/plugins/ui_actions/public/mocks.ts | 18 +- src/plugins/ui_actions/public/plugin.ts | 8 +- .../public/service/ui_actions_service.test.ts | 114 +--- .../public/service/ui_actions_service.ts | 122 +--- .../tests/execute_trigger_actions.test.ts | 10 +- .../public/tests/get_trigger_actions.test.ts | 9 +- .../get_trigger_compatible_actions.test.ts | 6 +- .../public/tests/test_samples/index.ts | 1 - .../public/triggers/select_range_trigger.ts | 2 +- .../public/triggers/trigger_internal.ts | 1 - .../public/triggers/value_click_trigger.ts | 2 +- src/plugins/ui_actions/public/types.ts | 6 +- .../ui_actions/public/util/configurable.ts | 60 -- src/plugins/ui_actions/public/util/index.ts | 21 - src/plugins/ui_actions/scripts/storybook.js | 26 - .../public/np_ready/public/plugin.tsx | 3 +- .../public/sample_panel_action.tsx | 3 +- .../public/sample_panel_link.ts | 3 +- x-pack/.i18nrc.json | 1 - .../action_wizard/action_wizard.scss | 5 + .../action_wizard/action_wizard.story.tsx | 24 +- .../action_wizard/action_wizard.test.tsx | 13 +- .../action_wizard/action_wizard.tsx | 95 +-- .../public/components/action_wizard/i18n.ts | 2 +- .../public/components/action_wizard/index.ts | 2 +- .../components/action_wizard/test_data.tsx | 218 +++--- .../public/custom_time_range_action.tsx | 2 +- .../advanced_ui_actions/public/index.ts | 14 - .../advanced_ui_actions/public/plugin.ts | 28 +- .../action_factory_service/action_factory.ts | 11 - .../action_factory_definition.ts | 11 - .../services/action_factory_service/index.ts | 8 - .../advanced_ui_actions/public/util/index.ts | 10 - x-pack/plugins/dashboard_enhanced/README.md | 1 - x-pack/plugins/dashboard_enhanced/kibana.json | 8 - .../public/components/README.md | 5 - .../dashboard_drilldown_config.story.tsx | 54 -- .../dashboard_drilldown_config.test.tsx | 11 - .../dashboard_drilldown_config.tsx | 69 -- .../dashboard_drilldown_config/i18n.ts | 14 - .../dashboard_drilldown_config/index.ts | 7 - .../public/components/index.ts | 7 - .../dashboard_enhanced/public/index.ts | 19 - .../dashboard_enhanced/public/mocks.ts | 27 - .../dashboard_enhanced/public/plugin.ts | 50 -- .../flyout_create_drilldown.test.tsx | 124 ---- .../flyout_create_drilldown.tsx | 74 -- .../actions/flyout_create_drilldown/index.ts | 11 - .../flyout_edit_drilldown.test.tsx | 102 --- .../flyout_edit_drilldown.tsx | 71 -- .../actions/flyout_edit_drilldown/index.tsx | 11 - .../flyout_edit_drilldown/menu_item.test.tsx | 37 - .../flyout_edit_drilldown/menu_item.tsx | 30 - .../drilldowns/actions/test_helpers.ts | 28 - .../dashboard_drilldowns_services.ts | 60 -- .../collect_config.test.tsx | 9 - .../collect_config.tsx | 55 -- .../constants.ts | 7 - .../drilldown.test.tsx | 20 - .../drilldown.tsx | 52 -- .../dashboard_to_dashboard_drilldown/i18n.ts | 11 - .../dashboard_to_dashboard_drilldown/index.ts | 16 - .../dashboard_to_dashboard_drilldown/types.ts | 22 - .../public/services/drilldowns/index.ts | 7 - .../public/services/index.ts | 7 - .../dashboard_enhanced/server/config.ts | 23 - .../dashboard_enhanced/server/index.ts | 12 - x-pack/plugins/drilldowns/kibana.json | 5 +- .../actions/flyout_create_drilldown/index.tsx | 52 ++ .../actions/flyout_edit_drilldown/index.tsx | 72 ++ .../public}/actions/index.ts | 0 ...nnected_flyout_manage_drilldowns.story.tsx | 43 -- ...onnected_flyout_manage_drilldowns.test.tsx | 221 ------ .../connected_flyout_manage_drilldowns.tsx | 332 --------- .../i18n.ts | 88 --- .../index.ts | 7 - .../test_data.ts | 89 --- .../drilldown_hello_bar.story.tsx | 16 +- .../drilldown_hello_bar.tsx | 58 +- .../components/drilldown_hello_bar/i18n.ts | 29 - .../drilldown_picker.story.tsx} | 10 +- .../drilldown_picker/drilldown_picker.tsx | 21 + .../components/drilldown_picker/index.tsx} | 2 +- .../flyout_create_drilldown.story.tsx | 24 + .../flyout_create_drilldown.tsx | 34 + .../flyout_create_drilldown}/i18n.ts | 6 +- .../flyout_create_drilldown}/index.ts | 2 +- .../flyout_drilldown_wizard.story.tsx | 70 -- .../flyout_drilldown_wizard.tsx | 139 ---- .../flyout_drilldown_wizard/i18n.ts | 42 -- .../flyout_drilldown_wizard/index.ts | 7 - .../flyout_frame/flyout_frame.story.tsx | 7 - .../flyout_frame/flyout_frame.test.tsx | 4 +- .../components/flyout_frame/flyout_frame.tsx | 31 +- .../public/components/flyout_frame/i18n.ts | 6 +- .../flyout_list_manage_drilldowns.story.tsx | 22 - .../flyout_list_manage_drilldowns.tsx | 46 -- .../flyout_list_manage_drilldowns/i18n.ts | 14 - .../flyout_list_manage_drilldowns/index.ts | 7 - .../form_create_drilldown.story.tsx | 34 + .../form_create_drilldown.test.tsx} | 20 +- .../form_create_drilldown.tsx | 52 ++ .../i18n.ts | 4 +- .../index.tsx | 2 +- .../form_drilldown_wizard.story.tsx | 29 - .../form_drilldown_wizard.tsx | 79 --- .../components/list_manage_drilldowns/i18n.ts | 36 - .../list_manage_drilldowns/index.tsx | 7 - .../list_manage_drilldowns.story.tsx | 19 - .../list_manage_drilldowns.test.tsx | 70 -- .../list_manage_drilldowns.tsx | 116 ---- x-pack/plugins/drilldowns/public/index.ts | 10 +- x-pack/plugins/drilldowns/public/mocks.ts | 12 +- x-pack/plugins/drilldowns/public/plugin.ts | 54 +- .../public/service/drilldown_service.ts | 32 + .../public/{services => service}/index.ts | 0 .../public/services/drilldown_service.ts | 79 --- x-pack/plugins/drilldowns/public/types.ts | 120 ---- x-pack/plugins/reporting/public/plugin.tsx | 3 +- .../translations/translations/ja-JP.json | 1 + .../translations/translations/zh-CN.json | 1 + 164 files changed, 898 insertions(+), 5368 deletions(-) delete mode 100644 src/plugins/kibana_utils/index.ts rename src/plugins/ui_actions/public/{util/presentable.ts => actions/action_definition.ts} (50%) delete mode 100644 src/plugins/ui_actions/public/actions/action_factory.ts delete mode 100644 src/plugins/ui_actions/public/actions/action_factory_definition.ts delete mode 100644 src/plugins/ui_actions/public/actions/action_internal.test.ts delete mode 100644 src/plugins/ui_actions/public/actions/action_internal.ts delete mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts delete mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_manager.ts delete mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts delete mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_storage.ts delete mode 100644 src/plugins/ui_actions/public/actions/types.ts delete mode 100644 src/plugins/ui_actions/public/util/configurable.ts delete mode 100644 src/plugins/ui_actions/public/util/index.ts delete mode 100644 src/plugins/ui_actions/scripts/storybook.js delete mode 100644 x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts delete mode 100644 x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts delete mode 100644 x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts delete mode 100644 x-pack/plugins/advanced_ui_actions/public/util/index.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/README.md delete mode 100644 x-pack/plugins/dashboard_enhanced/kibana.json delete mode 100644 x-pack/plugins/dashboard_enhanced/public/components/README.md delete mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/components/index.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/index.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/mocks.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/plugin.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/public/services/index.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/server/config.ts delete mode 100644 x-pack/plugins/dashboard_enhanced/server/index.ts create mode 100644 x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx create mode 100644 x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx rename x-pack/plugins/{dashboard_enhanced/public/services/drilldowns => drilldowns/public}/actions/index.ts (100%) delete mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts rename x-pack/plugins/{dashboard_enhanced/scripts/storybook.js => drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx} (53%) create mode 100644 x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx rename x-pack/plugins/{advanced_ui_actions/public/components/index.ts => drilldowns/public/components/drilldown_picker/index.tsx} (87%) create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx rename x-pack/plugins/{dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown => drilldowns/public/components/flyout_create_drilldown}/i18n.ts (64%) rename x-pack/plugins/{advanced_ui_actions/public/services => drilldowns/public/components/flyout_create_drilldown}/index.ts (84%) delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts create mode 100644 x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx rename x-pack/plugins/drilldowns/public/components/{form_drilldown_wizard/form_drilldown_wizard.test.tsx => form_create_drilldown/form_create_drilldown.test.tsx} (70%) create mode 100644 x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx rename x-pack/plugins/drilldowns/public/components/{form_drilldown_wizard => form_create_drilldown}/i18n.ts (89%) rename x-pack/plugins/drilldowns/public/components/{form_drilldown_wizard => form_create_drilldown}/index.tsx (85%) delete mode 100644 x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx create mode 100644 x-pack/plugins/drilldowns/public/service/drilldown_service.ts rename x-pack/plugins/drilldowns/public/{services => service}/index.ts (100%) delete mode 100644 x-pack/plugins/drilldowns/public/services/drilldown_service.ts delete mode 100644 x-pack/plugins/drilldowns/public/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6519bf9c493f9..f9f43b804fc92 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,7 +3,6 @@ # For more info, see https://help.github.com/articles/about-codeowners/ # App -/x-pack/legacy/plugins/dashboard_enhanced/ @elastic/kibana-app /x-pack/legacy/plugins/lens/ @elastic/kibana-app /x-pack/legacy/plugins/graph/ @elastic/kibana-app /src/legacy/server/url_shortening/ @elastic/kibana-app diff --git a/examples/ui_action_examples/public/plugin.ts b/examples/ui_action_examples/public/plugin.ts index d053f7e82862c..c47746d4b3fd6 100644 --- a/examples/ui_action_examples/public/plugin.ts +++ b/examples/ui_action_examples/public/plugin.ts @@ -46,7 +46,7 @@ export class UiActionExamplesPlugin })); uiActions.registerAction(helloWorldAction); - uiActions.addTriggerAction(helloWorldTrigger.id, helloWorldAction); + uiActions.attachAction(helloWorldTrigger.id, helloWorldAction); } public start() {} diff --git a/examples/ui_actions_explorer/public/app.tsx b/examples/ui_actions_explorer/public/app.tsx index f08b8bb29bdd3..462f5c3bf88ba 100644 --- a/examples/ui_actions_explorer/public/app.tsx +++ b/examples/ui_actions_explorer/public/app.tsx @@ -95,7 +95,8 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => { ); }, }); - uiActionsApi.addTriggerAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); + uiActionsApi.registerAction(dynamicAction); + uiActionsApi.attachAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); setConfirmationText( `You've successfully added a new action: ${dynamicAction.getDisplayName( {} diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx index de86b51aee3a8..f1895905a45e1 100644 --- a/examples/ui_actions_explorer/public/plugin.tsx +++ b/examples/ui_actions_explorer/public/plugin.tsx @@ -79,21 +79,21 @@ export class UiActionsExplorerPlugin implements Plugin (await startServices)[1].uiActions) ); - deps.uiActions.addTriggerAction( + deps.uiActions.attachAction( USER_TRIGGER, createEditUserAction(async () => (await startServices)[0].overlays.openModal) ); - deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, viewInMapsAction); - deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, lookUpWeatherAction); - deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, showcasePluggability); - deps.uiActions.addTriggerAction(PHONE_TRIGGER, makePhoneCallAction); - deps.uiActions.addTriggerAction(PHONE_TRIGGER, showcasePluggability); - deps.uiActions.addTriggerAction(USER_TRIGGER, showcasePluggability); + deps.uiActions.attachAction(COUNTRY_TRIGGER, viewInMapsAction); + deps.uiActions.attachAction(COUNTRY_TRIGGER, lookUpWeatherAction); + deps.uiActions.attachAction(COUNTRY_TRIGGER, showcasePluggability); + deps.uiActions.attachAction(PHONE_TRIGGER, makePhoneCallAction); + deps.uiActions.attachAction(PHONE_TRIGGER, showcasePluggability); + deps.uiActions.attachAction(USER_TRIGGER, showcasePluggability); core.application.register({ id: 'uiActionsExplorer', diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index 444430175d4f2..b609b2ce1d741 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -91,7 +91,6 @@ export interface OverlayFlyoutStart { export interface OverlayFlyoutOpenOptions { className?: string; closeButtonAriaLabel?: string; - ownFocus?: boolean; 'data-test-subj'?: string; } diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 370abc120d475..8ed64f004c9be 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -18,14 +18,12 @@ */ export const storybookAliases = { - advanced_ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', apm: 'x-pack/legacy/plugins/apm/scripts/storybook.js', canvas: 'x-pack/legacy/plugins/canvas/scripts/storybook_new.js', codeeditor: 'src/plugins/kibana_react/public/code_editor/scripts/storybook.ts', - dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/scripts/storybook.js', drilldowns: 'x-pack/plugins/drilldowns/scripts/storybook.js', embeddable: 'src/plugins/embeddable/scripts/storybook.js', infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js', siem: 'x-pack/legacy/plugins/siem/scripts/storybook.js', - ui_actions: 'src/plugins/ui_actions/scripts/storybook.js', + ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', }; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts index 4b21be83f1722..342824bade3dd 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts @@ -45,7 +45,6 @@ import { PersistedState } from '../../../../../../../plugins/visualizations/publ import { buildPipeline } from '../legacy/build_pipeline'; import { Vis } from '../vis'; import { getExpressions, getUiActions } from '../services'; -import { VisualizationsStartDeps } from '../plugin'; import { VIS_EVENT_TO_TRIGGER } from './events'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -57,7 +56,6 @@ export interface VisualizeEmbeddableConfiguration { editable: boolean; appState?: { save(): void }; uiState?: PersistedState; - uiActions?: VisualizationsStartDeps['uiActions']; } export interface VisualizeInput extends EmbeddableInput { @@ -96,7 +94,7 @@ export class VisualizeEmbeddable extends Embeddable { public readonly type = VISUALIZE_EMBEDDABLE_TYPE; - constructor( - private readonly getUiActions: () => Promise< - Pick['uiActions'] - > - ) { + constructor() { super({ savedObjectMetaData: { name: i18n.translate('visualizations.savedObjectName', { defaultMessage: 'Visualization' }), @@ -119,8 +114,6 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< const indexPattern = vis.data.indexPattern; const indexPatterns = indexPattern ? [indexPattern] : []; - const uiActions = await this.getUiActions(); - const editable = await this.isEditable(); return new VisualizeEmbeddable( getTimeFilter(), @@ -131,7 +124,6 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< editable, appState: input.appState, uiState: input.uiState, - uiActions, }, input, parent diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts index dcd11c920f17c..17f777e4e80e1 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts @@ -17,7 +17,7 @@ * under the License. */ -import { CoreSetup, PluginInitializerContext } from '../../../../../../core/public'; +import { PluginInitializerContext } from '../../../../../../core/public'; import { VisualizationsSetup, VisualizationsStart } from './'; import { VisualizationsPlugin } from './plugin'; import { coreMock } from '../../../../../../core/public/mocks'; @@ -26,7 +26,6 @@ import { expressionsPluginMock } from '../../../../../../plugins/expressions/pub import { dataPluginMock } from '../../../../../../plugins/data/public/mocks'; import { usageCollectionPluginMock } from '../../../../../../plugins/usage_collection/public/mocks'; import { uiActionsPluginMock } from '../../../../../../plugins/ui_actions/public/mocks'; -import { VisualizationsStartDeps } from './plugin'; const createSetupContract = (): VisualizationsSetup => ({ createBaseVisualization: jest.fn(), @@ -49,7 +48,7 @@ const createStartContract = (): VisualizationsStart => ({ const createInstance = async () => { const plugin = new VisualizationsPlugin({} as PluginInitializerContext); - const setup = plugin.setup(coreMock.createSetup() as CoreSetup, { + const setup = plugin.setup(coreMock.createSetup(), { data: dataPluginMock.createSetupContract(), expressions: expressionsPluginMock.createSetupContract(), embeddable: embeddablePluginMock.createSetupContract(), diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts index c826841e2bcf3..3ade6cee0d4d2 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts @@ -111,7 +111,7 @@ export class VisualizationsPlugin constructor(initializerContext: PluginInitializerContext) {} public setup( - core: CoreSetup, + core: CoreSetup, { expressions, embeddable, usageCollection, data }: VisualizationsSetupDeps ): VisualizationsSetup { setUISettings(core.uiSettings); @@ -120,9 +120,7 @@ export class VisualizationsPlugin expressions.registerFunction(visualizationFunction); expressions.registerRenderer(visualizationRenderer); - const embeddableFactory = new VisualizeEmbeddableFactory( - async () => (await core.getStartServices())[1].uiActions - ); + const embeddableFactory = new VisualizeEmbeddableFactory(); embeddable.registerEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE, embeddableFactory); return { diff --git a/src/plugins/dashboard/public/actions/replace_panel_action.tsx b/src/plugins/dashboard/public/actions/replace_panel_action.tsx index 4e20aa3c35088..21ec961917d17 100644 --- a/src/plugins/dashboard/public/actions/replace_panel_action.tsx +++ b/src/plugins/dashboard/public/actions/replace_panel_action.tsx @@ -37,7 +37,7 @@ export interface ReplacePanelActionContext { export class ReplacePanelAction implements ActionByType { public readonly type = ACTION_REPLACE_PANEL; public readonly id = ACTION_REPLACE_PANEL; - public order = 3; + public order = 11; constructor( private core: CoreStart, diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 3d67435e6d8f7..df3c312c7ae1b 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -87,7 +87,7 @@ export class DashboardEmbeddableContainerPublicPlugin ): Setup { const expandPanelAction = new ExpandPanelAction(); uiActions.registerAction(expandPanelAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction); const startServices = core.getStartServices(); if (share) { @@ -146,7 +146,7 @@ export class DashboardEmbeddableContainerPublicPlugin plugins.embeddable.getEmbeddableFactories ); uiActions.registerAction(changeViewAction); - uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, changeViewAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction); const savedDashboardLoader = createSavedDashboardLoader({ savedObjectsClient: core.savedObjects.client, indexPatterns, diff --git a/src/plugins/dashboard/public/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/tests/dashboard_container.test.tsx index 4aede3f3442fb..a81d80b440e04 100644 --- a/src/plugins/dashboard/public/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/tests/dashboard_container.test.tsx @@ -49,7 +49,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { const editModeAction = createEditModeAction(); uiActionsSetup.registerAction(editModeAction); - uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); + uiActionsSetup.attachAction(CONTEXT_MENU_TRIGGER, editModeAction); setup.registerEmbeddableFactory( CONTACT_CARD_EMBEDDABLE, new ContactCardEmbeddableFactory({} as any, (() => null) as any, {} as any) diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index ea2e85947aa12..fc5dde94fa851 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -109,12 +109,12 @@ export class DataPublicPlugin implements Plugin { public readonly type = ACTION_EDIT_PANEL; public readonly id = ACTION_EDIT_PANEL; - public order = 50; + public order = 15; constructor(private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']) {} diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index 35973cc16cf9b..eb10c16806640 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -16,35 +16,23 @@ * specific language governing permissions and limitations * under the License. */ -import { cloneDeep, isEqual } from 'lodash'; +import { isEqual, cloneDeep } from 'lodash'; import * as Rx from 'rxjs'; -import { Adapters, ViewMode } from '../types'; +import { Adapters } from '../types'; import { IContainer } from '../containers'; -import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable'; +import { IEmbeddable, EmbeddableInput, EmbeddableOutput } from './i_embeddable'; +import { ViewMode } from '../types'; import { TriggerContextMapping } from '../ui_actions'; import { EmbeddableActionStorage } from './embeddable_action_storage'; -import { - UiActionsDynamicActionManager, - UiActionsStart, -} from '../../../../../plugins/ui_actions/public'; -import { EmbeddableContext } from '../triggers'; function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) { return input.hidePanelTitles ? '' : input.title === undefined ? output.defaultTitle : input.title; } -export interface EmbeddableParams { - uiActions?: UiActionsStart; -} - export abstract class Embeddable< TEmbeddableInput extends EmbeddableInput = EmbeddableInput, TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput > implements IEmbeddable { - static runtimeId: number = 0; - - public readonly runtimeId = Embeddable.runtimeId++; - public readonly parent?: IContainer; public readonly isContainer: boolean = false; public abstract readonly type: string; @@ -60,34 +48,15 @@ export abstract class Embeddable< // to update input when the parent changes. private parentSubscription?: Rx.Subscription; - private storageSubscription?: Rx.Subscription; - // TODO: Rename to destroyed. private destoyed: boolean = false; - private storage = new EmbeddableActionStorage((this as unknown) as Embeddable); - - private cachedDynamicActions?: UiActionsDynamicActionManager; - public get dynamicActions(): UiActionsDynamicActionManager | undefined { - if (!this.params.uiActions) return undefined; - if (!this.cachedDynamicActions) { - this.cachedDynamicActions = new UiActionsDynamicActionManager({ - isCompatible: async (context: unknown) => - (context as EmbeddableContext).embeddable.runtimeId === this.runtimeId, - storage: this.storage, - uiActions: this.params.uiActions, - }); - } - - return this.cachedDynamicActions; + private __actionStorage?: EmbeddableActionStorage; + public get actionStorage(): EmbeddableActionStorage { + return this.__actionStorage || (this.__actionStorage = new EmbeddableActionStorage(this)); } - constructor( - input: TEmbeddableInput, - output: TEmbeddableOutput, - parent?: IContainer, - public readonly params: EmbeddableParams = {} - ) { + constructor(input: TEmbeddableInput, output: TEmbeddableOutput, parent?: IContainer) { this.id = input.id; this.output = { title: getPanelTitle(input, output), @@ -111,18 +80,6 @@ export abstract class Embeddable< this.onResetInput(newInput); }); } - - if (this.dynamicActions) { - this.dynamicActions.start().catch(error => { - /* eslint-disable */ - console.log('Failed to start embeddable dynamic actions', this); - console.error(error); - /* eslint-enable */ - }); - this.storageSubscription = this.input$.subscribe(() => { - this.storage.reload$.next(); - }); - } } public getIsContainer(): this is IContainer { @@ -201,20 +158,6 @@ export abstract class Embeddable< */ public destroy(): void { this.destoyed = true; - - if (this.dynamicActions) { - this.dynamicActions.stop().catch(error => { - /* eslint-disable */ - console.log('Failed to stop embeddable dynamic actions', this); - console.error(error); - /* eslint-enable */ - }); - } - - if (this.storageSubscription) { - this.storageSubscription.unsubscribe(); - } - if (this.parentSubscription) { this.parentSubscription.unsubscribe(); } diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts index 83fd3f184e098..56facc37fc666 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts @@ -20,8 +20,7 @@ import { Embeddable } from './embeddable'; import { EmbeddableInput } from './i_embeddable'; import { ViewMode } from '../types'; -import { EmbeddableActionStorage } from './embeddable_action_storage'; -import { UiActionsSerializedEvent } from '../../../../ui_actions/public'; +import { EmbeddableActionStorage, SerializedEvent } from './embeddable_action_storage'; import { of } from '../../../../kibana_utils/common'; class TestEmbeddable extends Embeddable { @@ -43,9 +42,9 @@ describe('EmbeddableActionStorage', () => { test('can add event to embeddable', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -58,40 +57,23 @@ describe('EmbeddableActionStorage', () => { expect(events2).toEqual([event]); }); - test('does not merge .getInput() into .updateInput()', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { - eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], - action: {} as any, - }; - - const spy = jest.spyOn(embeddable, 'updateInput'); - - await storage.create(event); - - expect(spy.mock.calls[0][0].id).toBe(undefined); - expect(spy.mock.calls[0][0].viewMode).toBe(undefined); - }); - test('can create multiple events', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; - const event3: UiActionsSerializedEvent = { + const event3: SerializedEvent = { eventId: 'EVENT_ID3', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -113,9 +95,9 @@ describe('EmbeddableActionStorage', () => { test('throws when creating an event with the same ID', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -140,16 +122,16 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'foo', } as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'bar', } as any, @@ -166,30 +148,30 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'foo', } as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'bar', } as any, }; - const event22: UiActionsSerializedEvent = { + const event22: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'baz', } as any, }; - const event3: UiActionsSerializedEvent = { + const event3: SerializedEvent = { eventId: 'EVENT_ID3', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'qux', } as any, @@ -217,9 +199,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -235,14 +217,14 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -267,9 +249,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -284,23 +266,23 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'foo', } as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'bar', } as any, }; - const event3: UiActionsSerializedEvent = { + const event3: SerializedEvent = { eventId: 'EVENT_ID3', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'qux', } as any, @@ -345,9 +327,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -373,9 +355,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -401,9 +383,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -420,19 +402,19 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID1', action: {} as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID2', action: {} as any, }; - const event3: UiActionsSerializedEvent = { + const event3: SerializedEvent = { eventId: 'EVENT_ID3', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID3', action: {} as any, }; @@ -476,7 +458,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID1', action: {} as any, }); @@ -484,7 +466,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID1', action: {} as any, }); @@ -520,15 +502,15 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID1', action: {} as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID1', action: {} as any, }; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts index fad5b4d535d6c..520f92840c5f9 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts @@ -17,20 +17,32 @@ * under the License. */ -import { - UiActionsAbstractActionStorage, - UiActionsSerializedEvent, -} from '../../../../ui_actions/public'; import { Embeddable } from '..'; -export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { - constructor(private readonly embbeddable: Embeddable) { - super(); - } +/** + * Below two interfaces are here temporarily, they will move to `ui_actions` + * plugin once #58216 is merged. + */ +export interface SerializedEvent { + eventId: string; + triggerId: string; + action: unknown; +} +export interface ActionStorage { + create(event: SerializedEvent): Promise; + update(event: SerializedEvent): Promise; + remove(eventId: string): Promise; + read(eventId: string): Promise; + count(): Promise; + list(): Promise; +} - async create(event: UiActionsSerializedEvent) { +export class EmbeddableActionStorage implements ActionStorage { + constructor(private readonly embbeddable: Embeddable) {} + + async create(event: SerializedEvent) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as UiActionsSerializedEvent[]; + const events = (input.events || []) as SerializedEvent[]; const exists = !!events.find(({ eventId }) => eventId === event.eventId); if (exists) { @@ -41,13 +53,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { } this.embbeddable.updateInput({ + ...input, events: [...events, event], }); } - async update(event: UiActionsSerializedEvent) { + async update(event: SerializedEvent) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as UiActionsSerializedEvent[]; + const events = (input.events || []) as SerializedEvent[]; const index = events.findIndex(({ eventId }) => eventId === event.eventId); if (index === -1) { @@ -59,13 +72,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { } this.embbeddable.updateInput({ + ...input, events: [...events.slice(0, index), event, ...events.slice(index + 1)], }); } async remove(eventId: string) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as UiActionsSerializedEvent[]; + const events = (input.events || []) as SerializedEvent[]; const index = events.findIndex(event => eventId === event.eventId); if (index === -1) { @@ -77,13 +91,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { } this.embbeddable.updateInput({ + ...input, events: [...events.slice(0, index), ...events.slice(index + 1)], }); } - async read(eventId: string): Promise { + async read(eventId: string): Promise { const input = this.embbeddable.getInput(); - const events = (input.events || []) as UiActionsSerializedEvent[]; + const events = (input.events || []) as SerializedEvent[]; const event = events.find(ev => eventId === ev.eventId); if (!event) { @@ -98,10 +113,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { private __list() { const input = this.embbeddable.getInput(); - return (input.events || []) as UiActionsSerializedEvent[]; + return (input.events || []) as SerializedEvent[]; + } + + async count(): Promise { + return this.__list().length; } - async list(): Promise { + async list(): Promise { return this.__list(); } } diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 9a4452aceba00..6345c34b0dda2 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -18,7 +18,6 @@ */ import { Observable } from 'rxjs'; -import { UiActionsDynamicActionManager } from '../../../../../plugins/ui_actions/public'; import { Adapters } from '../types'; import { IContainer } from '../containers/i_container'; import { ViewMode } from '../types'; @@ -34,7 +33,7 @@ export interface EmbeddableInput { /** * Reserved key for `ui_actions` events. */ - events?: Array<{ eventId: string }>; + events?: unknown; /** * List of action IDs that this embeddable should not render. @@ -83,19 +82,6 @@ export interface IEmbeddable< **/ readonly id: string; - /** - * Unique ID an embeddable is assigned each time it is initialized. This ID - * is different for different instances of the same embeddable. For example, - * if the same dashboard is rendered twice on the screen, all embeddable - * instances will have a unique `runtimeId`. - */ - readonly runtimeId?: number; - - /** - * Default implementation of dynamic action API for embeddables. - */ - dynamicActions?: UiActionsDynamicActionManager; - /** * A functional representation of the isContainer variable, but helpful for typescript to * know the shape if this returns true diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 83d3d5e10761b..757d4e6bfddef 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -44,7 +44,7 @@ import { import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks'; import { EuiBadge } from '@elastic/eui'; -const actionRegistry = new Map(); +const actionRegistry = new Map>(); const triggerRegistry = new Map(); const embeddableFactories = new Map(); const getEmbeddableFactory = (id: string) => embeddableFactories.get(id); @@ -213,17 +213,13 @@ const renderInEditModeAndOpenContextMenu = async ( }; test('HelloWorldContainer in edit mode hides disabledActions', async () => { - const action = { + const action: Action = { id: 'FOO', type: 'FOO' as ActionType, getIconType: () => undefined, getDisplayName: () => 'foo', isCompatible: async () => true, execute: async () => {}, - order: 10, - getHref: () => { - return undefined; - }, }; const getActions = () => Promise.resolve([action]); @@ -249,17 +245,13 @@ test('HelloWorldContainer in edit mode hides disabledActions', async () => { }); test('HelloWorldContainer hides disabled badges', async () => { - const action = { + const action: Action = { id: 'BAR', type: 'BAR' as ActionType, getIconType: () => undefined, getDisplayName: () => 'bar', isCompatible: async () => true, execute: async () => {}, - order: 10, - getHref: () => { - return undefined; - }, }; const getActions = () => Promise.resolve([action]); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index c6537f2d94994..b95060a73252f 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -38,14 +38,6 @@ import { EditPanelAction } from '../actions'; import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal'; import { EmbeddableStart } from '../../plugin'; -const sortByOrderField = ( - { order: orderA }: { order?: number }, - { order: orderB }: { order?: number } -) => (orderB || 0) - (orderA || 0); - -const removeById = (disabledActions: string[]) => ({ id }: { id: string }) => - disabledActions.indexOf(id) === -1; - interface Props { embeddable: IEmbeddable; getActions: UiActionsService['getTriggerCompatibleActions']; @@ -65,14 +57,12 @@ interface State { hidePanelTitles: boolean; closeContextMenu: boolean; badges: Array>; - eventCount?: number; } export class EmbeddablePanel extends React.Component { private embeddableRoot: React.RefObject; private parentSubscription?: Subscription; private subscription?: Subscription; - private eventCountSubscription?: Subscription; private mounted: boolean = false; private generateId = htmlIdGenerator(); @@ -146,9 +136,6 @@ export class EmbeddablePanel extends React.Component { if (this.subscription) { this.subscription.unsubscribe(); } - if (this.eventCountSubscription) { - this.eventCountSubscription.unsubscribe(); - } if (this.parentSubscription) { this.parentSubscription.unsubscribe(); } @@ -190,7 +177,6 @@ export class EmbeddablePanel extends React.Component { badges={this.state.badges} embeddable={this.props.embeddable} headerId={headerId} - eventCount={this.state.eventCount} /> )}
@@ -202,15 +188,6 @@ export class EmbeddablePanel extends React.Component { if (this.embeddableRoot.current) { this.props.embeddable.render(this.embeddableRoot.current); } - - const dynamicActions = this.props.embeddable.dynamicActions; - if (dynamicActions) { - this.setState({ eventCount: dynamicActions.state.get().events.length }); - this.eventCountSubscription = dynamicActions.state.state$.subscribe(({ events }) => { - if (!this.mounted) return; - this.setState({ eventCount: events.length }); - }); - } } closeMyContextMenuPanel = () => { @@ -224,14 +201,13 @@ export class EmbeddablePanel extends React.Component { }; private getActionContextMenuPanel = async () => { - let regularActions = await this.props.getActions(CONTEXT_MENU_TRIGGER, { + let actions = await this.props.getActions(CONTEXT_MENU_TRIGGER, { embeddable: this.props.embeddable, }); const { disabledActions } = this.props.embeddable.getInput(); if (disabledActions) { - const removeDisabledActions = removeById(disabledActions); - regularActions = regularActions.filter(removeDisabledActions); + actions = actions.filter(action => disabledActions.indexOf(action.id) === -1); } const createGetUserData = (overlays: OverlayStart) => @@ -270,10 +246,16 @@ export class EmbeddablePanel extends React.Component { new EditPanelAction(this.props.getEmbeddableFactory), ]; - const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField); + const sorted = actions + .concat(extraActions) + .sort((a: Action, b: Action) => { + const bOrder = b.order || 0; + const aOrder = a.order || 0; + return bOrder - aOrder; + }); return await buildContextMenuForActions({ - actions: sortedActions, + actions: sorted, actionContext: { embeddable: this.props.embeddable }, closeMenu: this.closeMyContextMenuPanel, }); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts index 36957c3b79491..c0e43c0538833 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts @@ -33,13 +33,15 @@ interface ActionContext { export class CustomizePanelTitleAction implements Action { public readonly type = ACTION_CUSTOMIZE_PANEL; public id = ACTION_CUSTOMIZE_PANEL; - public order = 40; + public order = 10; - constructor(private readonly getDataFromUser: GetUserData) {} + constructor(private readonly getDataFromUser: GetUserData) { + this.order = 10; + } public getDisplayName() { return i18n.translate('embeddableApi.customizePanel.action.displayName', { - defaultMessage: 'Edit panel title', + defaultMessage: 'Customize panel', }); } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts index ae9645767b267..d04f35715537c 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts @@ -31,7 +31,7 @@ interface ActionContext { export class InspectPanelAction implements Action { public readonly type = ACTION_INSPECT_PANEL; public readonly id = ACTION_INSPECT_PANEL; - public order = 20; + public order = 10; constructor(private readonly inspector: InspectorStartContract) {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts index a6d4128f3f106..ee7948f3d6a4a 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts @@ -41,7 +41,7 @@ function hasExpandedPanelInput( export class RemovePanelAction implements Action { public readonly type = REMOVE_PANEL_ACTION; public readonly id = REMOVE_PANEL_ACTION; - public order = 1; + public order = 5; constructor() {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 2a856af7ae916..99516a1d21d6f 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -23,7 +23,6 @@ import { EuiIcon, EuiToolTip, EuiScreenReaderOnly, - EuiNotificationBadge, } from '@elastic/eui'; import classNames from 'classnames'; import React from 'react'; @@ -41,7 +40,6 @@ export interface PanelHeaderProps { badges: Array>; embeddable: IEmbeddable; headerId?: string; - eventCount?: number; } function renderBadges(badges: Array>, embeddable: IEmbeddable) { @@ -92,7 +90,6 @@ export function PanelHeader({ badges, embeddable, headerId, - eventCount, }: PanelHeaderProps) { const viewDescription = getViewDescription(embeddable); const showTitle = !isViewMode || (title && !hidePanelTitles) || viewDescription !== ''; @@ -150,11 +147,7 @@ export function PanelHeader({ )} {renderBadges(badges, embeddable)}
- {!isViewMode && !!eventCount && ( - - {eventCount} - - )} + >( - container: Container -): UnboxState => useObservable(container.state$, container.get()); - -/** - * Apply selector to state container to extract only needed information. Will - * re-render your component only when the section changes. - * - * @param container State container which state to track. - * @param selector Function used to pick parts of state. - * @param comparator Comparator function used to memoize previous result, to not - * re-render React component if state did not change. By default uses - * `fast-deep-equal` package. - */ -export const useContainerSelector = , Result>( - container: Container, - selector: (state: UnboxState) => Result, - comparator: Comparator = defaultComparator -): Result => { - const { state$, get } = container; - const lastValueRef = useRef(get()); - const [value, setValue] = React.useState(() => { - const newValue = selector(get()); - lastValueRef.current = newValue; - return newValue; - }); - useLayoutEffect(() => { - const subscription = state$.subscribe((currentState: UnboxState) => { - const newValue = selector(currentState); - if (!comparator(lastValueRef.current, newValue)) { - lastValueRef.current = newValue; - setValue(newValue); - } - }); - return () => subscription.unsubscribe(); - }, [state$, comparator]); - return value; -}; - export const createStateContainerReactHelpers = >() => { const context = React.createContext(null as any); const useContainer = (): Container => useContext(context); const useState = (): UnboxState => { - const container = useContainer(); - return useContainerState(container); + const { state$, get } = useContainer(); + const value = useObservable(state$, get()); + return value; }; const useTransitions: () => Container['transitions'] = () => useContainer().transitions; @@ -84,8 +41,24 @@ export const createStateContainerReactHelpers = ) => Result, comparator: Comparator = defaultComparator ): Result => { - const container = useContainer(); - return useContainerSelector(container, selector, comparator); + const { state$, get } = useContainer(); + const lastValueRef = useRef(get()); + const [value, setValue] = React.useState(() => { + const newValue = selector(get()); + lastValueRef.current = newValue; + return newValue; + }); + useLayoutEffect(() => { + const subscription = state$.subscribe((currentState: UnboxState) => { + const newValue = selector(currentState); + if (!comparator(lastValueRef.current, newValue)) { + lastValueRef.current = newValue; + setValue(newValue); + } + }); + return () => subscription.unsubscribe(); + }, [state$, comparator]); + return value; }; const connect: Connect> = mapStateToProp => component => props => diff --git a/src/plugins/kibana_utils/common/state_containers/types.ts b/src/plugins/kibana_utils/common/state_containers/types.ts index 29ffa4cd486b5..26a29bc470e8a 100644 --- a/src/plugins/kibana_utils/common/state_containers/types.ts +++ b/src/plugins/kibana_utils/common/state_containers/types.ts @@ -43,7 +43,7 @@ export interface BaseStateContainer { export interface StateContainer< State extends BaseState, - PureTransitions extends object = object, + PureTransitions extends object, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; diff --git a/src/plugins/kibana_utils/index.ts b/src/plugins/kibana_utils/index.ts deleted file mode 100644 index 14d6e52dc0465..0000000000000 --- a/src/plugins/kibana_utils/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export { createStateContainer, StateContainer, of } from './common'; diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index 15f1d6dd79289..2b2fc004a84c6 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -19,12 +19,10 @@ import { UiComponent } from 'src/plugins/kibana_utils/common'; import { ActionType, ActionContextMapping } from '../types'; -import { Presentable } from '../util/presentable'; export type ActionByType = Action; -export interface Action - extends Partial> { +export interface Action { /** * Determined the order when there is more than one action matched to a trigger. * Higher numbers are displayed first. @@ -65,30 +63,12 @@ export interface Action isCompatible(context: Context): Promise; /** - * Executes the action. + * If this returns something truthy, this is used in addition to the `execute` method when clicked. */ - execute(context: Context): Promise; -} - -/** - * A convenience interface used to register an action. - */ -export interface ActionDefinition - extends Partial> { - /** - * ID of the action that uniquely identifies this action in the actions registry. - */ - readonly id: string; - - /** - * ID of the factory for this action. Used to construct dynamic actions. - */ - readonly type?: ActionType; + getHref?(context: Context): string | undefined; /** * Executes the action. */ execute(context: Context): Promise; } - -export type ActionContext = A extends ActionDefinition ? Context : never; diff --git a/src/plugins/ui_actions/public/util/presentable.ts b/src/plugins/ui_actions/public/actions/action_definition.ts similarity index 50% rename from src/plugins/ui_actions/public/util/presentable.ts rename to src/plugins/ui_actions/public/actions/action_definition.ts index 945fd2065ce78..c590cf8f34ee0 100644 --- a/src/plugins/ui_actions/public/util/presentable.ts +++ b/src/plugins/ui_actions/public/actions/action_definition.ts @@ -18,46 +18,55 @@ */ import { UiComponent } from 'src/plugins/kibana_utils/common'; +import { ActionType, ActionContextMapping } from '../types'; -/** - * Represents something that can be displayed to user in UI. - */ -export interface Presentable { +export interface ActionDefinition { /** - * ID that uniquely identifies this object. + * Determined the order when there is more than one action matched to a trigger. + * Higher numbers are displayed first. */ - readonly id: string; + order?: number; /** - * Determines the display order in relation to other items. Higher numbers are - * displayed first. + * A unique identifier for this action instance. */ - readonly order: number; + id?: string; /** - * `UiComponent` to render when displaying this entity as a context menu item. - * If not provided, `getDisplayName` will be used instead. + * The action type is what determines the context shape. */ - readonly MenuItem?: UiComponent<{ context: Context }>; + readonly type: T; /** * Optional EUI icon type that can be displayed along with the title. */ - getIconType(context: Context): string | undefined; + getIconType?(context: ActionContextMapping[T]): string; /** * Returns a title to be displayed to the user. + * @param context + */ + getDisplayName?(context: ActionContextMapping[T]): string; + + /** + * `UiComponent` to render when displaying this action as a context menu item. + * If not provided, `getDisplayName` will be used instead. + */ + MenuItem?: UiComponent<{ context: ActionContextMapping[T] }>; + + /** + * Returns a promise that resolves to true if this action is compatible given the context, + * otherwise resolves to false. */ - getDisplayName(context: Context): string; + isCompatible?(context: ActionContextMapping[T]): Promise; /** - * This method should return a link if this item can be clicked on. + * If this returns something truthy, this is used in addition to the `execute` method when clicked. */ - getHref?(context: Context): string | undefined; + getHref?(context: ActionContextMapping[T]): string | undefined; /** - * Returns a promise that resolves to true if this item is compatible given - * the context and should be displayed to user, otherwise resolves to false. + * Executes the action. */ - isCompatible(context: Context): Promise; + execute(context: ActionContextMapping[T]): Promise; } diff --git a/src/plugins/ui_actions/public/actions/action_factory.ts b/src/plugins/ui_actions/public/actions/action_factory.ts deleted file mode 100644 index bc0ec844d00f5..0000000000000 --- a/src/plugins/ui_actions/public/actions/action_factory.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { uiToReactComponent } from '../../../kibana_react/public'; -import { Presentable } from '../util/presentable'; -import { ActionDefinition } from './action'; -import { ActionFactoryDefinition } from './action_factory_definition'; -import { Configurable } from '../util'; -import { SerializedAction } from './types'; - -export class ActionFactory< - Config extends object = object, - FactoryContext extends object = object, - ActionContext extends object = object -> implements Presentable, Configurable { - constructor( - protected readonly def: ActionFactoryDefinition - ) {} - - public readonly id = this.def.id; - public readonly order = this.def.order || 0; - public readonly MenuItem? = this.def.MenuItem; - public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; - - public readonly CollectConfig = this.def.CollectConfig; - public readonly ReactCollectConfig = uiToReactComponent(this.CollectConfig); - public readonly createConfig = this.def.createConfig; - public readonly isConfigValid = this.def.isConfigValid; - - public getIconType(context: FactoryContext): string | undefined { - if (!this.def.getIconType) return undefined; - return this.def.getIconType(context); - } - - public getDisplayName(context: FactoryContext): string { - if (!this.def.getDisplayName) return ''; - return this.def.getDisplayName(context); - } - - public async isCompatible(context: FactoryContext): Promise { - if (!this.def.isCompatible) return true; - return await this.def.isCompatible(context); - } - - public getHref(context: FactoryContext): string | undefined { - if (!this.def.getHref) return undefined; - return this.def.getHref(context); - } - - public create( - serializedAction: Omit, 'factoryId'> - ): ActionDefinition { - return this.def.create(serializedAction); - } -} diff --git a/src/plugins/ui_actions/public/actions/action_factory_definition.ts b/src/plugins/ui_actions/public/actions/action_factory_definition.ts deleted file mode 100644 index 7ac94a41e7076..0000000000000 --- a/src/plugins/ui_actions/public/actions/action_factory_definition.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { ActionDefinition } from './action'; -import { Presentable, Configurable } from '../util'; -import { SerializedAction } from './types'; - -/** - * This is a convenience interface for registering new action factories. - */ -export interface ActionFactoryDefinition< - Config extends object = object, - FactoryContext extends object = object, - ActionContext extends object = object -> extends Partial>, Configurable { - /** - * Unique ID of the action factory. This ID is used to identify this action - * factory in the registry as well as to construct actions of this type and - * identify this action factory when presenting it to the user in UI. - */ - id: string; - - /** - * This method should return a definition of a new action, normally used to - * register it in `ui_actions` registry. - */ - create( - serializedAction: Omit, 'factoryId'> - ): ActionDefinition; -} diff --git a/src/plugins/ui_actions/public/actions/action_internal.test.ts b/src/plugins/ui_actions/public/actions/action_internal.test.ts deleted file mode 100644 index b14346180c274..0000000000000 --- a/src/plugins/ui_actions/public/actions/action_internal.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { ActionDefinition } from './action'; -import { ActionInternal } from './action_internal'; - -const defaultActionDef: ActionDefinition = { - id: 'test-action', - execute: jest.fn(), -}; - -describe('ActionInternal', () => { - test('can instantiate from action definition', () => { - const action = new ActionInternal(defaultActionDef); - expect(action.id).toBe('test-action'); - }); -}); diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts deleted file mode 100644 index 245ded991c032..0000000000000 --- a/src/plugins/ui_actions/public/actions/action_internal.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Action, ActionContext as Context, ActionDefinition } from './action'; -import { Presentable } from '../util/presentable'; -import { uiToReactComponent } from '../../../kibana_react/public'; -import { ActionType } from '../types'; - -export class ActionInternal - implements Action>, Presentable> { - constructor(public readonly definition: A) {} - - public readonly id: string = this.definition.id; - public readonly type: ActionType = this.definition.type || ''; - public readonly order: number = this.definition.order || 0; - public readonly MenuItem? = this.definition.MenuItem; - public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; - - public execute(context: Context) { - return this.definition.execute(context); - } - - public getIconType(context: Context): string | undefined { - if (!this.definition.getIconType) return undefined; - return this.definition.getIconType(context); - } - - public getDisplayName(context: Context): string { - if (!this.definition.getDisplayName) return `Action: ${this.id}`; - return this.definition.getDisplayName(context); - } - - public async isCompatible(context: Context): Promise { - if (!this.definition.isCompatible) return true; - return await this.definition.isCompatible(context); - } - - public getHref(context: Context): string | undefined { - if (!this.definition.getHref) return undefined; - return this.definition.getHref(context); - } -} diff --git a/src/plugins/ui_actions/public/actions/create_action.ts b/src/plugins/ui_actions/public/actions/create_action.ts index 8f1cd23715d3f..90a9415c0b497 100644 --- a/src/plugins/ui_actions/public/actions/create_action.ts +++ b/src/plugins/ui_actions/public/actions/create_action.ts @@ -17,19 +17,11 @@ * under the License. */ -import { ActionContextMapping } from '../types'; import { ActionByType } from './action'; import { ActionType } from '../types'; -import { ActionDefinition } from './action'; +import { ActionDefinition } from './action_definition'; -interface ActionDefinitionByType - extends Omit, 'id'> { - id?: string; -} - -export function createAction( - action: ActionDefinitionByType -): ActionByType { +export function createAction(action: ActionDefinition): ActionByType { return { getIconType: () => undefined, order: 0, @@ -38,5 +30,5 @@ export function createAction( getDisplayName: () => '', getHref: () => undefined, ...action, - } as ActionByType; + }; } diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts deleted file mode 100644 index 2574a9e529ebf..0000000000000 --- a/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts +++ /dev/null @@ -1,646 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { DynamicActionManager } from './dynamic_action_manager'; -import { ActionStorage, MemoryActionStorage, SerializedEvent } from './dynamic_action_storage'; -import { UiActionsService } from '../service'; -import { ActionFactoryDefinition } from './action_factory_definition'; -import { ActionRegistry } from '../types'; -import { SerializedAction } from './types'; -import { of } from '../../../kibana_utils'; - -const actionFactoryDefinition1: ActionFactoryDefinition = { - id: 'ACTION_FACTORY_1', - CollectConfig: {} as any, - createConfig: () => ({}), - isConfigValid: (() => true) as any, - create: ({ name }) => ({ - id: '', - execute: async () => {}, - getDisplayName: () => name, - }), -}; - -const actionFactoryDefinition2: ActionFactoryDefinition = { - id: 'ACTION_FACTORY_2', - CollectConfig: {} as any, - createConfig: () => ({}), - isConfigValid: (() => true) as any, - create: ({ name }) => ({ - id: '', - execute: async () => {}, - getDisplayName: () => name, - }), -}; - -const event1: SerializedEvent = { - eventId: 'EVENT_ID_1', - triggers: ['VALUE_CLICK_TRIGGER'], - action: { - factoryId: actionFactoryDefinition1.id, - name: 'Action 1', - config: {}, - }, -}; - -const event2: SerializedEvent = { - eventId: 'EVENT_ID_2', - triggers: ['VALUE_CLICK_TRIGGER'], - action: { - factoryId: actionFactoryDefinition1.id, - name: 'Action 2', - config: {}, - }, -}; - -const event3: SerializedEvent = { - eventId: 'EVENT_ID_3', - triggers: ['VALUE_CLICK_TRIGGER'], - action: { - factoryId: actionFactoryDefinition2.id, - name: 'Action 3', - config: {}, - }, -}; - -const setup = (events: readonly SerializedEvent[] = []) => { - const isCompatible = async () => true; - const storage: ActionStorage = new MemoryActionStorage(events); - const actions: ActionRegistry = new Map(); - const uiActions = new UiActionsService({ - actions, - }); - const manager = new DynamicActionManager({ - isCompatible, - storage, - uiActions, - }); - - uiActions.registerTrigger({ - id: 'VALUE_CLICK_TRIGGER', - }); - - return { - isCompatible, - actions, - storage, - uiActions, - manager, - }; -}; - -describe('DynamicActionManager', () => { - test('can instantiate', () => { - const { manager } = setup([event1]); - expect(manager).toBeInstanceOf(DynamicActionManager); - }); - - describe('.start()', () => { - test('instantiates stored events', async () => { - const { manager, actions, uiActions } = setup([event1]); - const create1 = jest.fn(); - const create2 = jest.fn(); - - uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); - uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); - - expect(create1).toHaveBeenCalledTimes(0); - expect(create2).toHaveBeenCalledTimes(0); - expect(actions.size).toBe(0); - - await manager.start(); - - expect(create1).toHaveBeenCalledTimes(1); - expect(create2).toHaveBeenCalledTimes(0); - expect(actions.size).toBe(1); - }); - - test('does nothing when no events stored', async () => { - const { manager, actions, uiActions } = setup(); - const create1 = jest.fn(); - const create2 = jest.fn(); - - uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); - uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); - - expect(create1).toHaveBeenCalledTimes(0); - expect(create2).toHaveBeenCalledTimes(0); - expect(actions.size).toBe(0); - - await manager.start(); - - expect(create1).toHaveBeenCalledTimes(0); - expect(create2).toHaveBeenCalledTimes(0); - expect(actions.size).toBe(0); - }); - - test('UI state is empty before manager starts', async () => { - const { manager } = setup([event1]); - - expect(manager.state.get()).toMatchObject({ - events: [], - isFetchingEvents: false, - fetchCount: 0, - }); - }); - - test('loads events into UI state', async () => { - const { manager, uiActions } = setup([event1, event2, event3]); - - uiActions.registerActionFactory(actionFactoryDefinition1); - uiActions.registerActionFactory(actionFactoryDefinition2); - - await manager.start(); - - expect(manager.state.get()).toMatchObject({ - events: [event1, event2, event3], - isFetchingEvents: false, - fetchCount: 1, - }); - }); - - test('sets isFetchingEvents to true while fetching events', async () => { - const { manager, uiActions } = setup([event1, event2, event3]); - - uiActions.registerActionFactory(actionFactoryDefinition1); - uiActions.registerActionFactory(actionFactoryDefinition2); - - const promise = manager.start().catch(() => {}); - - expect(manager.state.get().isFetchingEvents).toBe(true); - - await promise; - - expect(manager.state.get().isFetchingEvents).toBe(false); - }); - - test('throws if storage threw', async () => { - const { manager, storage } = setup([event1]); - - storage.list = async () => { - throw new Error('baz'); - }; - - const [, error] = await of(manager.start()); - - expect(error).toEqual(new Error('baz')); - }); - - test('sets UI state error if error happened during initial fetch', async () => { - const { manager, storage } = setup([event1]); - - storage.list = async () => { - throw new Error('baz'); - }; - - await of(manager.start()); - - expect(manager.state.get().fetchError!.message).toBe('baz'); - }); - }); - - describe('.stop()', () => { - test('removes events from UI actions registry', async () => { - const { manager, actions, uiActions } = setup([event1, event2]); - const create1 = jest.fn(); - const create2 = jest.fn(); - - uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); - uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); - - expect(actions.size).toBe(0); - - await manager.start(); - - expect(actions.size).toBe(2); - - await manager.stop(); - - expect(actions.size).toBe(0); - }); - }); - - describe('.createEvent()', () => { - describe('when storage succeeds', () => { - test('stores new event in storage', async () => { - const { manager, storage, uiActions } = setup([]); - - uiActions.registerActionFactory(actionFactoryDefinition1); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - expect(await storage.count()).toBe(0); - - await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); - - expect(await storage.count()).toBe(1); - - const [event] = await storage.list(); - - expect(event).toMatchObject({ - eventId: expect.any(String), - triggers: ['VALUE_CLICK_TRIGGER'], - action: { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }, - }); - }); - - test('adds event to UI state', async () => { - const { manager, uiActions } = setup([]); - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(manager.state.get().events.length).toBe(0); - - await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); - - expect(manager.state.get().events.length).toBe(1); - }); - - test('optimistically adds event to UI state', async () => { - const { manager, uiActions } = setup([]); - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(manager.state.get().events.length).toBe(0); - - const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e); - - expect(manager.state.get().events.length).toBe(1); - - await promise; - - expect(manager.state.get().events.length).toBe(1); - }); - - test('instantiates event in actions service', async () => { - const { manager, uiActions, actions } = setup([]); - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(actions.size).toBe(0); - - await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); - - expect(actions.size).toBe(1); - }); - }); - - describe('when storage fails', () => { - test('throws an error', async () => { - const { manager, storage, uiActions } = setup([]); - - storage.create = async () => { - throw new Error('foo'); - }; - - uiActions.registerActionFactory(actionFactoryDefinition1); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - const [, error] = await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); - - expect(error).toEqual(new Error('foo')); - }); - - test('does not add even to UI state', async () => { - const { manager, storage, uiActions } = setup([]); - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - storage.create = async () => { - throw new Error('foo'); - }; - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); - - expect(manager.state.get().events.length).toBe(0); - }); - - test('optimistically adds event to UI state and then removes it', async () => { - const { manager, storage, uiActions } = setup([]); - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - storage.create = async () => { - throw new Error('foo'); - }; - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(manager.state.get().events.length).toBe(0); - - const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e); - - expect(manager.state.get().events.length).toBe(1); - - await promise; - - expect(manager.state.get().events.length).toBe(0); - }); - - test('does not instantiate event in actions service', async () => { - const { manager, storage, uiActions, actions } = setup([]); - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - storage.create = async () => { - throw new Error('foo'); - }; - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(actions.size).toBe(0); - - await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); - - expect(actions.size).toBe(0); - }); - }); - }); - - describe('.updateEvent()', () => { - describe('when storage succeeds', () => { - test('un-registers old event from ui actions service and registers the new one', async () => { - const { manager, actions, uiActions } = setup([event3]); - - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - expect(actions.size).toBe(1); - - const registeredAction1 = actions.values().next().value; - - expect(registeredAction1.getDisplayName()).toBe('Action 3'); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); - - expect(actions.size).toBe(1); - - const registeredAction2 = actions.values().next().value; - - expect(registeredAction2.getDisplayName()).toBe('foo'); - }); - - test('updates event in storage', async () => { - const { manager, storage, uiActions } = setup([event3]); - const storageUpdateSpy = jest.spyOn(storage, 'update'); - - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - expect(storageUpdateSpy).toHaveBeenCalledTimes(0); - - await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); - - expect(storageUpdateSpy).toHaveBeenCalledTimes(1); - expect(storageUpdateSpy.mock.calls[0][0]).toMatchObject({ - eventId: expect.any(String), - triggers: ['VALUE_CLICK_TRIGGER'], - action: { - factoryId: actionFactoryDefinition2.id, - }, - }); - }); - - test('updates event in UI state', async () => { - const { manager, uiActions } = setup([event3]); - - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - expect(manager.state.get().events[0].action.name).toBe('Action 3'); - - await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); - - expect(manager.state.get().events[0].action.name).toBe('foo'); - }); - - test('optimistically updates event in UI state', async () => { - const { manager, uiActions } = setup([event3]); - - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - expect(manager.state.get().events[0].action.name).toBe('Action 3'); - - const promise = manager - .updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']) - .catch(e => e); - - expect(manager.state.get().events[0].action.name).toBe('foo'); - - await promise; - }); - }); - - describe('when storage fails', () => { - test('throws error', async () => { - const { manager, storage, uiActions } = setup([event3]); - - storage.update = () => { - throw new Error('bar'); - }; - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - const [, error] = await of( - manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']) - ); - - expect(error).toEqual(new Error('bar')); - }); - - test('keeps the old action in actions registry', async () => { - const { manager, storage, actions, uiActions } = setup([event3]); - - storage.update = () => { - throw new Error('bar'); - }; - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - expect(actions.size).toBe(1); - - const registeredAction1 = actions.values().next().value; - - expect(registeredAction1.getDisplayName()).toBe('Action 3'); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])); - - expect(actions.size).toBe(1); - - const registeredAction2 = actions.values().next().value; - - expect(registeredAction2.getDisplayName()).toBe('Action 3'); - }); - - test('keeps old event in UI state', async () => { - const { manager, storage, uiActions } = setup([event3]); - - storage.update = () => { - throw new Error('bar'); - }; - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - expect(manager.state.get().events[0].action.name).toBe('Action 3'); - - await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])); - - expect(manager.state.get().events[0].action.name).toBe('Action 3'); - }); - }); - }); - - describe('.deleteEvents()', () => { - describe('when storage succeeds', () => { - test('removes all actions from uiActions service', async () => { - const { manager, actions, uiActions } = setup([event2, event1]); - - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(actions.size).toBe(2); - - await manager.deleteEvents([event1.eventId, event2.eventId]); - - expect(actions.size).toBe(0); - }); - - test('removes all events from storage', async () => { - const { manager, uiActions, storage } = setup([event2, event1]); - - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(await storage.list()).toEqual([event2, event1]); - - await manager.deleteEvents([event1.eventId, event2.eventId]); - - expect(await storage.list()).toEqual([]); - }); - - test('removes all events from UI state', async () => { - const { manager, uiActions } = setup([event2, event1]); - - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(manager.state.get().events).toEqual([event2, event1]); - - await manager.deleteEvents([event1.eventId, event2.eventId]); - - expect(manager.state.get().events).toEqual([]); - }); - }); - }); -}); diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts deleted file mode 100644 index 97eb5b05fbbc2..0000000000000 --- a/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts +++ /dev/null @@ -1,284 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { v4 as uuidv4 } from 'uuid'; -import { Subscription } from 'rxjs'; -import { ActionStorage, SerializedEvent } from './dynamic_action_storage'; -import { UiActionsService } from '../service'; -import { SerializedAction } from './types'; -import { TriggerContextMapping } from '../types'; -import { ActionDefinition } from './action'; -import { defaultState, transitions, selectors, State } from './dynamic_action_manager_state'; -import { StateContainer, createStateContainer } from '../../../kibana_utils'; - -const compareEvents = ( - a: ReadonlyArray<{ eventId: string }>, - b: ReadonlyArray<{ eventId: string }> -) => { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) if (a[i].eventId !== b[i].eventId) return false; - return true; -}; - -export type DynamicActionManagerState = State; - -export interface DynamicActionManagerParams { - storage: ActionStorage; - uiActions: Pick< - UiActionsService, - 'registerAction' | 'attachAction' | 'unregisterAction' | 'detachAction' | 'getActionFactory' - >; - isCompatible: (context: C) => Promise; -} - -export class DynamicActionManager { - static idPrefixCounter = 0; - - private readonly idPrefix = `D_ACTION_${DynamicActionManager.idPrefixCounter++}_`; - private stopped: boolean = false; - private reloadSubscription?: Subscription; - - /** - * UI State of the dynamic action manager. - */ - protected readonly ui = createStateContainer(defaultState, transitions, selectors); - - constructor(protected readonly params: DynamicActionManagerParams) {} - - protected getEvent(eventId: string): SerializedEvent { - const oldEvent = this.ui.selectors.getEvent(eventId); - if (!oldEvent) throw new Error(`Could not find event [eventId = ${eventId}].`); - return oldEvent; - } - - /** - * We prefix action IDs with a unique `.idPrefix`, so we can render the - * same dashboard twice on the screen. - */ - protected generateActionId(eventId: string): string { - return this.idPrefix + eventId; - } - - protected reviveAction(event: SerializedEvent) { - const { eventId, triggers, action } = event; - const { uiActions, isCompatible } = this.params; - - const actionId = this.generateActionId(eventId); - const factory = uiActions.getActionFactory(event.action.factoryId); - const actionDefinition: ActionDefinition = { - ...factory.create(action as SerializedAction), - id: actionId, - isCompatible, - }; - - uiActions.registerAction(actionDefinition); - for (const trigger of triggers) uiActions.attachAction(trigger as any, actionId); - } - - protected killAction({ eventId, triggers }: SerializedEvent) { - const { uiActions } = this.params; - const actionId = this.generateActionId(eventId); - - for (const trigger of triggers) uiActions.detachAction(trigger as any, actionId); - uiActions.unregisterAction(actionId); - } - - private syncId = 0; - - /** - * This function is called every time stored events might have changed not by - * us. For example, when in edit mode on dashboard user presses "back" button - * in the browser, then contents of storage changes. - */ - private onSync = () => { - if (this.stopped) return; - - (async () => { - const syncId = ++this.syncId; - const events = await this.params.storage.list(); - - if (this.stopped) return; - if (syncId !== this.syncId) return; - if (compareEvents(events, this.ui.get().events)) return; - - for (const event of this.ui.get().events) this.killAction(event); - for (const event of events) this.reviveAction(event); - this.ui.transitions.finishFetching(events); - })().catch(error => { - /* eslint-disable */ - console.log('Dynamic action manager storage reload failed.'); - console.error(error); - /* eslint-enable */ - }); - }; - - // Public API: --------------------------------------------------------------- - - /** - * Read-only state container of dynamic action manager. Use it to perform all - * *read* operations. - */ - public readonly state: StateContainer = this.ui; - - /** - * 1. Loads all events from @type {DynamicActionStorage} storage. - * 2. Creates actions for each event in `ui_actions` registry. - * 3. Adds events to UI state. - * 4. Does nothing if dynamic action manager was stopped or if event fetching - * is already taking place. - */ - public async start() { - if (this.stopped) return; - if (this.ui.get().isFetchingEvents) return; - - this.ui.transitions.startFetching(); - try { - const events = await this.params.storage.list(); - for (const event of events) this.reviveAction(event); - this.ui.transitions.finishFetching(events); - } catch (error) { - this.ui.transitions.failFetching(error instanceof Error ? error : { message: String(error) }); - throw error; - } - - if (this.params.storage.reload$) { - this.reloadSubscription = this.params.storage.reload$.subscribe(this.onSync); - } - } - - /** - * 1. Removes all events from `ui_actions` registry. - * 2. Puts dynamic action manager is stopped state. - */ - public async stop() { - this.stopped = true; - const events = await this.params.storage.list(); - - for (const event of events) { - this.killAction(event); - } - - if (this.reloadSubscription) { - this.reloadSubscription.unsubscribe(); - } - } - - /** - * Creates a new event. - * - * 1. Stores event in @type {DynamicActionStorage} storage. - * 2. Optimistically adds it to UI state, and rolls back on failure. - * 3. Adds action to `ui_actions` registry. - * - * @param action Dynamic action for which to create an event. - * @param triggers List of triggers to which action should react. - */ - public async createEvent( - action: SerializedAction, - triggers: Array - ) { - const event: SerializedEvent = { - eventId: uuidv4(), - triggers, - action, - }; - - this.ui.transitions.addEvent(event); - try { - await this.params.storage.create(event); - this.reviveAction(event); - } catch (error) { - this.ui.transitions.removeEvent(event.eventId); - throw error; - } - } - - /** - * Updates an existing event. Fails if event with given `eventId` does not - * exit. - * - * 1. Updates the event in @type {DynamicActionStorage} storage. - * 2. Optimistically replaces the old event by the new one in UI state, and - * rolls back on failure. - * 3. Replaces action in `ui_actions` registry with the new event. - * - * - * @param eventId ID of the event to replace. - * @param action New action for which to create the event. - * @param triggers List of triggers to which action should react. - */ - public async updateEvent( - eventId: string, - action: SerializedAction, - triggers: Array - ) { - const event: SerializedEvent = { - eventId, - triggers, - action, - }; - const oldEvent = this.getEvent(eventId); - this.killAction(oldEvent); - - this.reviveAction(event); - this.ui.transitions.replaceEvent(event); - - try { - await this.params.storage.update(event); - } catch (error) { - this.killAction(event); - this.reviveAction(oldEvent); - this.ui.transitions.replaceEvent(oldEvent); - throw error; - } - } - - /** - * Removes existing event. Throws if event does not exist. - * - * 1. Removes the event from @type {DynamicActionStorage} storage. - * 2. Optimistically removes event from UI state, and puts it back on failure. - * 3. Removes associated action from `ui_actions` registry. - * - * @param eventId ID of the event to remove. - */ - public async deleteEvent(eventId: string) { - const event = this.getEvent(eventId); - - this.killAction(event); - this.ui.transitions.removeEvent(eventId); - - try { - await this.params.storage.remove(eventId); - } catch (error) { - this.reviveAction(event); - this.ui.transitions.addEvent(event); - throw error; - } - } - - /** - * Deletes multiple events at once. - * - * @param eventIds List of event IDs. - */ - public async deleteEvents(eventIds: string[]) { - await Promise.all(eventIds.map(this.deleteEvent.bind(this))); - } -} diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts deleted file mode 100644 index 636af076ea39f..0000000000000 --- a/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { SerializedEvent } from './dynamic_action_storage'; - -/** - * This interface represents the state of @type {DynamicActionManager} at any - * point in time. - */ -export interface State { - /** - * Whether dynamic action manager is currently in process of fetching events - * from storage. - */ - readonly isFetchingEvents: boolean; - - /** - * Number of times event fetching has been completed. - */ - readonly fetchCount: number; - - /** - * Error received last time when fetching events. - */ - readonly fetchError?: { - message: string; - }; - - /** - * List of all fetched events. - */ - readonly events: readonly SerializedEvent[]; -} - -export interface Transitions { - startFetching: (state: State) => () => State; - finishFetching: (state: State) => (events: SerializedEvent[]) => State; - failFetching: (state: State) => (error: { message: string }) => State; - addEvent: (state: State) => (event: SerializedEvent) => State; - removeEvent: (state: State) => (eventId: string) => State; - replaceEvent: (state: State) => (event: SerializedEvent) => State; -} - -export interface Selectors { - getEvent: (state: State) => (eventId: string) => SerializedEvent | null; -} - -export const defaultState: State = { - isFetchingEvents: false, - fetchCount: 0, - events: [], -}; - -export const transitions: Transitions = { - startFetching: state => () => ({ ...state, isFetchingEvents: true }), - - finishFetching: state => events => ({ - ...state, - isFetchingEvents: false, - fetchCount: state.fetchCount + 1, - fetchError: undefined, - events, - }), - - failFetching: state => ({ message }) => ({ - ...state, - isFetchingEvents: false, - fetchCount: state.fetchCount + 1, - fetchError: { message }, - }), - - addEvent: state => (event: SerializedEvent) => ({ - ...state, - events: [...state.events, event], - }), - - removeEvent: state => (eventId: string) => ({ - ...state, - events: state.events ? state.events.filter(event => event.eventId !== eventId) : state.events, - }), - - replaceEvent: state => event => { - const index = state.events.findIndex(({ eventId }) => eventId === event.eventId); - if (index === -1) return state; - - return { - ...state, - events: [...state.events.slice(0, index), event, ...state.events.slice(index + 1)], - }; - }, -}; - -export const selectors: Selectors = { - getEvent: state => eventId => state.events.find(event => event.eventId === eventId) || null, -}; diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts b/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts deleted file mode 100644 index 28550a671782e..0000000000000 --- a/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/* eslint-disable max-classes-per-file */ - -import { Observable, Subject } from 'rxjs'; -import { SerializedAction } from './types'; - -/** - * Serialized representation of event-action pair, used to persist in storage. - */ -export interface SerializedEvent { - eventId: string; - triggers: string[]; - action: SerializedAction; -} - -/** - * This CRUD interface needs to be implemented by dynamic action users if they - * want to persist the dynamic actions. It has a default implementation in - * Embeddables, however one can use the dynamic actions without Embeddables, - * in that case they have to implement this interface. - */ -export interface ActionStorage { - create(event: SerializedEvent): Promise; - update(event: SerializedEvent): Promise; - remove(eventId: string): Promise; - read(eventId: string): Promise; - count(): Promise; - list(): Promise; - - /** - * Triggered every time events changed in storage and should be re-loaded. - */ - readonly reload$?: Observable; -} - -export abstract class AbstractActionStorage implements ActionStorage { - public readonly reload$: Observable & Pick, 'next'> = new Subject(); - - public async count(): Promise { - return (await this.list()).length; - } - - public async read(eventId: string): Promise { - const events = await this.list(); - const event = events.find(ev => ev.eventId === eventId); - if (!event) throw new Error(`Event [eventId = ${eventId}] not found.`); - return event; - } - - abstract create(event: SerializedEvent): Promise; - abstract update(event: SerializedEvent): Promise; - abstract remove(eventId: string): Promise; - abstract list(): Promise; -} - -/** - * This is an in-memory implementation of ActionStorage. It is used in testing, - * but can also be used production code to store events in memory. - */ -export class MemoryActionStorage extends AbstractActionStorage { - constructor(public events: readonly SerializedEvent[] = []) { - super(); - } - - public async list() { - return this.events.map(event => ({ ...event })); - } - - public async create(event: SerializedEvent) { - this.events = [...this.events, { ...event }]; - } - - public async update(event: SerializedEvent) { - const index = this.events.findIndex(({ eventId }) => eventId === event.eventId); - if (index < 0) throw new Error(`Event [eventId = ${event.eventId}] not found`); - this.events = [...this.events.slice(0, index), { ...event }, ...this.events.slice(index + 1)]; - } - - public async remove(eventId: string) { - const index = this.events.findIndex(ev => eventId === ev.eventId); - if (index < 0) throw new Error(`Event [eventId = ${eventId}] not found`); - this.events = [...this.events.slice(0, index), ...this.events.slice(index + 1)]; - } -} diff --git a/src/plugins/ui_actions/public/actions/index.ts b/src/plugins/ui_actions/public/actions/index.ts index 0ddba197aced6..64bfd368e3dfa 100644 --- a/src/plugins/ui_actions/public/actions/index.ts +++ b/src/plugins/ui_actions/public/actions/index.ts @@ -18,11 +18,5 @@ */ export * from './action'; -export * from './action_internal'; -export * from './action_factory_definition'; -export * from './action_factory'; export * from './create_action'; export * from './incompatible_action_error'; -export * from './dynamic_action_storage'; -export * from './dynamic_action_manager'; -export * from './types'; diff --git a/src/plugins/ui_actions/public/actions/types.ts b/src/plugins/ui_actions/public/actions/types.ts deleted file mode 100644 index 465f091e45ef1..0000000000000 --- a/src/plugins/ui_actions/public/actions/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export interface SerializedAction { - readonly factoryId: string; - readonly name: string; - readonly config: Config; -} diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index ec58261d9e4f7..3dce2c1f4c257 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -24,25 +24,19 @@ import { i18n } from '@kbn/i18n'; import { uiToReactComponent } from '../../../kibana_react/public'; import { Action } from '../actions'; -export const defaultTitle = i18n.translate('uiActions.actionPanel.title', { - defaultMessage: 'Options', -}); - /** * Transforms an array of Actions to the shape EuiContextMenuPanel expects. */ -export async function buildContextMenuForActions({ +export async function buildContextMenuForActions({ actions, actionContext, - title = defaultTitle, closeMenu, }: { - actions: Array>; - actionContext: Context; - title?: string; + actions: Array>; + actionContext: A; closeMenu: () => void; }): Promise { - const menuItems = await buildEuiContextMenuPanelItems({ + const menuItems = await buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, @@ -50,7 +44,9 @@ export async function buildContextMenuForActions({ return { id: 'mainMenu', - title, + title: i18n.translate('uiActions.actionPanel.title', { + defaultMessage: 'Options', + }), items: menuItems, }; } @@ -58,41 +54,49 @@ export async function buildContextMenuForActions({ /** * Transform an array of Actions into the shape needed to build an EUIContextMenu */ -async function buildEuiContextMenuPanelItems({ +async function buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, }: { - actions: Array>; - actionContext: Context; + actions: Array>; + actionContext: A; closeMenu: () => void; }) { - const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length); - const promises = actions.map(async (action, index) => { + const items: EuiContextMenuPanelItemDescriptor[] = []; + const promises = actions.map(async action => { const isCompatible = await action.isCompatible(actionContext); if (!isCompatible) { return; } - items[index] = convertPanelActionToContextMenuItem({ - action, - actionContext, - closeMenu, - }); + items.push( + convertPanelActionToContextMenuItem({ + action, + actionContext, + closeMenu, + }) + ); }); await Promise.all(promises); - return items.filter(Boolean); + return items; } -function convertPanelActionToContextMenuItem({ +/** + * + * @param {ContextMenuAction} action + * @param {Embeddable} embeddable + * @return {EuiContextMenuPanelItemDescriptor} + */ +function convertPanelActionToContextMenuItem({ action, actionContext, closeMenu, }: { - action: Action; - actionContext: Context; + action: Action; + actionContext: A; closeMenu: () => void; }): EuiContextMenuPanelItemDescriptor { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { @@ -111,11 +115,8 @@ function convertPanelActionToContextMenuItem({ closeMenu(); }; - if (action.getHref) { - const href = action.getHref(actionContext); - if (href) { - menuPanelItem.href = action.getHref(actionContext); - } + if (action.getHref && action.getHref(actionContext)) { + menuPanelItem.href = action.getHref(actionContext); } return menuPanelItem; diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 9265d35bad9a9..49b6bd5e17699 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -26,26 +26,8 @@ export function plugin(initializerContext: PluginInitializerContext) { export { UiActionsSetup, UiActionsStart } from './plugin'; export { UiActionsServiceParams, UiActionsService } from './service'; -export { - Action, - ActionDefinition as UiActionsActionDefinition, - ActionFactoryDefinition as UiActionsActionFactoryDefinition, - ActionInternal as UiActionsActionInternal, - ActionStorage as UiActionsActionStorage, - AbstractActionStorage as UiActionsAbstractActionStorage, - createAction, - DynamicActionManager, - DynamicActionManagerState, - IncompatibleActionError, - SerializedAction as UiActionsSerializedAction, - SerializedEvent as UiActionsSerializedEvent, -} from './actions'; +export { Action, createAction, IncompatibleActionError } from './actions'; export { buildContextMenuForActions } from './context_menu'; -export { - Presentable as UiActionsPresentable, - Configurable as UiActionsConfigurable, - CollectConfigProps as UiActionsCollectConfigProps, -} from './util'; export { Trigger, TriggerContext, @@ -57,4 +39,4 @@ export { applyFilterTrigger, } from './triggers'; export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types'; -export { ActionByType, DynamicActionManager as UiActionsDynamicActionManager } from './actions'; +export { ActionByType } from './actions'; diff --git a/src/plugins/ui_actions/public/mocks.ts b/src/plugins/ui_actions/public/mocks.ts index 4de38eb5421e9..c1be6b2626525 100644 --- a/src/plugins/ui_actions/public/mocks.ts +++ b/src/plugins/ui_actions/public/mocks.ts @@ -28,13 +28,10 @@ export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { - addTriggerAction: jest.fn(), attachAction: jest.fn(), detachAction: jest.fn(), registerAction: jest.fn(), - registerActionFactory: jest.fn(), registerTrigger: jest.fn(), - unregisterAction: jest.fn(), }; return setupContract; }; @@ -42,21 +39,16 @@ const createSetupContract = (): Setup => { const createStartContract = (): Start => { const startContract: Start = { attachAction: jest.fn(), - unregisterAction: jest.fn(), - addTriggerAction: jest.fn(), - clear: jest.fn(), + registerAction: jest.fn(), + registerTrigger: jest.fn(), + getAction: jest.fn(), detachAction: jest.fn(), executeTriggerActions: jest.fn(), - fork: jest.fn(), - getAction: jest.fn(), - getActionFactories: jest.fn(), - getActionFactory: jest.fn(), getTrigger: jest.fn(), getTriggerActions: jest.fn((id: TriggerId) => []), getTriggerCompatibleActions: jest.fn(), - registerAction: jest.fn(), - registerActionFactory: jest.fn(), - registerTrigger: jest.fn(), + clear: jest.fn(), + fork: jest.fn(), }; return startContract; diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index 88a5cb04eac6f..928e57937a9b5 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -23,13 +23,7 @@ import { selectRangeTrigger, valueClickTrigger, applyFilterTrigger } from './tri export type UiActionsSetup = Pick< UiActionsService, - | 'addTriggerAction' - | 'attachAction' - | 'detachAction' - | 'registerAction' - | 'registerActionFactory' - | 'registerTrigger' - | 'unregisterAction' + 'attachAction' | 'detachAction' | 'registerAction' | 'registerTrigger' >; export type UiActionsStart = PublicMethodsOf; diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts index 41e2b57d53dd8..bdf71a25e6dbc 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts @@ -18,13 +18,7 @@ */ import { UiActionsService } from './ui_actions_service'; -import { - Action, - ActionInternal, - createAction, - ActionFactoryDefinition, - ActionFactory, -} from '../actions'; +import { Action, createAction } from '../actions'; import { createHelloWorldAction } from '../tests/test_samples'; import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types'; import { Trigger } from '../triggers'; @@ -108,21 +102,6 @@ describe('UiActionsService', () => { type: 'test' as ActionType, }); }); - - test('return action instance', () => { - const service = new UiActionsService(); - const action = service.registerAction({ - id: 'test', - execute: async () => {}, - getDisplayName: () => 'test', - getIconType: () => '', - isCompatible: async () => true, - type: 'test' as ActionType, - }); - - expect(action).toBeInstanceOf(ActionInternal); - expect(action.id).toBe('test'); - }); }); describe('.getTriggerActions()', () => { @@ -160,14 +139,13 @@ describe('UiActionsService', () => { expect(list0).toHaveLength(0); - service.addTriggerAction(FOO_TRIGGER, action1); + service.attachAction(FOO_TRIGGER, action1); const list1 = service.getTriggerActions(FOO_TRIGGER); expect(list1).toHaveLength(1); - expect(list1[0]).toBeInstanceOf(ActionInternal); - expect(list1[0].id).toBe(action1.id); + expect(list1).toEqual([action1]); - service.addTriggerAction(FOO_TRIGGER, action2); + service.attachAction(FOO_TRIGGER, action2); const list2 = service.getTriggerActions(FOO_TRIGGER); expect(list2).toHaveLength(2); @@ -186,7 +164,7 @@ describe('UiActionsService', () => { service.registerAction(helloWorldAction); expect(actions.size - length).toBe(1); - expect(actions.get(helloWorldAction.id)!.id).toBe(helloWorldAction.id); + expect(actions.get(helloWorldAction.id)).toBe(helloWorldAction); }); test('getTriggerCompatibleActions returns attached actions', async () => { @@ -200,7 +178,7 @@ describe('UiActionsService', () => { title: 'My trigger', }; service.registerTrigger(testTrigger); - service.addTriggerAction(MY_TRIGGER, helloWorldAction); + service.attachAction(MY_TRIGGER, helloWorldAction); const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, { hi: 'there', @@ -226,7 +204,7 @@ describe('UiActionsService', () => { }; service.registerTrigger(testTrigger); - service.addTriggerAction(testTrigger.id, action); + service.attachAction(testTrigger.id, action); const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, { accept: true, @@ -310,7 +288,7 @@ describe('UiActionsService', () => { id: FOO_TRIGGER, }); service1.registerAction(testAction1); - service1.addTriggerAction(FOO_TRIGGER, testAction1); + service1.attachAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); @@ -331,14 +309,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.addTriggerAction(FOO_TRIGGER, testAction1); + service1.attachAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service2.addTriggerAction(FOO_TRIGGER, testAction2); + service2.attachAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); @@ -352,14 +330,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.addTriggerAction(FOO_TRIGGER, testAction1); + service1.attachAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service1.addTriggerAction(FOO_TRIGGER, testAction2); + service1.attachAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); @@ -414,7 +392,7 @@ describe('UiActionsService', () => { } as any; service.registerTrigger(trigger); - service.addTriggerAction(MY_TRIGGER, action); + service.attachAction(MY_TRIGGER, action); const actions = service.getTriggerActions(trigger.id); @@ -422,7 +400,7 @@ describe('UiActionsService', () => { expect(actions[0].id).toBe(ACTION_HELLO_WORLD); }); - test('can detach an action from a trigger', () => { + test('can detach an action to a trigger', () => { const service = new UiActionsService(); const trigger: Trigger = { @@ -435,7 +413,7 @@ describe('UiActionsService', () => { service.registerTrigger(trigger); service.registerAction(action); - service.addTriggerAction(trigger.id, action); + service.attachAction(trigger.id, action); service.detachAction(trigger.id, action.id); const actions2 = service.getTriggerActions(trigger.id); @@ -467,7 +445,7 @@ describe('UiActionsService', () => { } as any; service.registerAction(action); - expect(() => service.addTriggerAction('i do not exist' as TriggerId, action)).toThrowError( + expect(() => service.attachAction('i do not exist' as TriggerId, action)).toThrowError( 'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = ACTION_HELLO_WORLD].' ); }); @@ -497,64 +475,4 @@ describe('UiActionsService', () => { ); }); }); - - describe('action factories', () => { - const factoryDefinition1: ActionFactoryDefinition = { - id: 'test-factory-1', - CollectConfig: {} as any, - createConfig: () => ({}), - isConfigValid: () => true, - create: () => ({} as any), - }; - const factoryDefinition2: ActionFactoryDefinition = { - id: 'test-factory-2', - CollectConfig: {} as any, - createConfig: () => ({}), - isConfigValid: () => true, - create: () => ({} as any), - }; - - test('.getActionFactories() returns empty array if no action factories registered', () => { - const service = new UiActionsService(); - - const factories = service.getActionFactories(); - - expect(factories).toEqual([]); - }); - - test('can register and retrieve an action factory', () => { - const service = new UiActionsService(); - - service.registerActionFactory(factoryDefinition1); - - const factory = service.getActionFactory(factoryDefinition1.id); - - expect(factory).toBeInstanceOf(ActionFactory); - expect(factory.id).toBe(factoryDefinition1.id); - }); - - test('can retrieve all action factories', () => { - const service = new UiActionsService(); - - service.registerActionFactory(factoryDefinition1); - service.registerActionFactory(factoryDefinition2); - - const factories = service.getActionFactories(); - const factoriesSorted = [...factories].sort((f1, f2) => (f1.id > f2.id ? 1 : -1)); - - expect(factoriesSorted.length).toBe(2); - expect(factoriesSorted[0].id).toBe(factoryDefinition1.id); - expect(factoriesSorted[1].id).toBe(factoryDefinition2.id); - }); - - test('throws when retrieving action factory that does not exist', () => { - const service = new UiActionsService(); - - service.registerActionFactory(factoryDefinition1); - - expect(() => service.getActionFactory('UNKNOWN_ID')).toThrowError( - 'Action factory [actionFactoryId = UNKNOWN_ID] does not exist.' - ); - }); - }); }); diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index 8bd3bb34fbbd8..f7718e63773f5 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -24,17 +24,8 @@ import { TriggerId, TriggerContextMapping, ActionType, - ActionFactoryRegistry, } from '../types'; -import { - ActionInternal, - Action, - ActionByType, - ActionFactory, - ActionDefinition, - ActionFactoryDefinition, - ActionContext, -} from '../actions'; +import { Action, ActionByType } from '../actions'; import { Trigger, TriggerContext } from '../triggers/trigger'; import { TriggerInternal } from '../triggers/trigger_internal'; import { TriggerContract } from '../triggers/trigger_contract'; @@ -47,25 +38,21 @@ export interface UiActionsServiceParams { * A 1-to-N mapping from `Trigger` to zero or more `Action`. */ readonly triggerToActions?: TriggerToActionsRegistry; - readonly actionFactories?: ActionFactoryRegistry; } export class UiActionsService { protected readonly triggers: TriggerRegistry; protected readonly actions: ActionRegistry; protected readonly triggerToActions: TriggerToActionsRegistry; - protected readonly actionFactories: ActionFactoryRegistry; constructor({ triggers = new Map(), actions = new Map(), triggerToActions = new Map(), - actionFactories = new Map(), }: UiActionsServiceParams = {}) { this.triggers = triggers; this.actions = actions; this.triggerToActions = triggerToActions; - this.actionFactories = actionFactories; } public readonly registerTrigger = (trigger: Trigger) => { @@ -89,44 +76,49 @@ export class UiActionsService { return trigger.contract; }; - public readonly registerAction = ( - definition: A - ): ActionInternal => { - if (this.actions.has(definition.id)) { - throw new Error(`Action [action.id = ${definition.id}] already registered.`); + public readonly registerAction = (action: ActionByType) => { + if (this.actions.has(action.id)) { + throw new Error(`Action [action.id = ${action.id}] already registered.`); } - const action = new ActionInternal(definition); - this.actions.set(action.id, action); - - return action; }; - public readonly unregisterAction = (actionId: string): void => { - if (!this.actions.has(actionId)) { - throw new Error(`Action [action.id = ${actionId}] is not registered.`); + public readonly getAction = (id: string): ActionByType => { + if (!this.actions.has(id)) { + throw new Error(`Action [action.id = ${id}] not registered.`); } - this.actions.delete(actionId); + return this.actions.get(id) as ActionByType; }; - public readonly attachAction = ( - triggerId: TriggerId, - actionId: string + public readonly attachAction = ( + triggerId: TType, + // The action can accept partial or no context, but if it needs context not provided + // by this type of trigger, typescript will complain. yay! + action: ActionByType & Action ): void => { + if (!this.actions.has(action.id)) { + this.registerAction(action); + } else { + const registeredAction = this.actions.get(action.id); + if (registeredAction !== action) { + throw new Error(`A different action instance with this id is already registered.`); + } + } + const trigger = this.triggers.get(triggerId); if (!trigger) { throw new Error( - `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].` + `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${action.id}].` ); } const actionIds = this.triggerToActions.get(triggerId); - if (!actionIds!.find(id => id === actionId)) { - this.triggerToActions.set(triggerId, [...actionIds!, actionId]); + if (!actionIds!.find(id => id === action.id)) { + this.triggerToActions.set(triggerId, [...actionIds!, action.id]); } }; @@ -147,26 +139,6 @@ export class UiActionsService { ); }; - public readonly addTriggerAction = ( - triggerId: TType, - // The action can accept partial or no context, but if it needs context not provided - // by this type of trigger, typescript will complain. yay! - action: ActionByType & Action - ): void => { - if (!this.actions.has(action.id)) this.registerAction(action); - this.attachAction(triggerId, action.id); - }; - - public readonly getAction = ( - id: string - ): Action> => { - if (!this.actions.has(id)) { - throw new Error(`Action [action.id = ${id}] not registered.`); - } - - return this.actions.get(id) as ActionInternal; - }; - public readonly getTriggerActions = ( triggerId: T ): Array> => { @@ -175,9 +147,9 @@ export class UiActionsService { const actionIds = this.triggerToActions.get(triggerId); - const actions = actionIds! - .map(actionId => this.actions.get(actionId) as ActionInternal) - .filter(Boolean); + const actions = actionIds!.map(actionId => this.actions.get(actionId)).filter(Boolean) as Array< + Action + >; return actions as Array>>; }; @@ -215,7 +187,6 @@ export class UiActionsService { this.actions.clear(); this.triggers.clear(); this.triggerToActions.clear(); - this.actionFactories.clear(); }; /** @@ -235,41 +206,4 @@ export class UiActionsService { return new UiActionsService({ triggers, actions, triggerToActions }); }; - - /** - * Register an action factory. Action factories are used to configure and - * serialize/deserialize dynamic actions. - */ - public readonly registerActionFactory = < - Config extends object = object, - FactoryContext extends object = object, - ActionContext extends object = object - >( - definition: ActionFactoryDefinition - ) => { - if (this.actionFactories.has(definition.id)) { - throw new Error(`ActionFactory [actionFactory.id = ${definition.id}] already registered.`); - } - - const actionFactory = new ActionFactory(definition); - - this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory); - }; - - public readonly getActionFactory = (actionFactoryId: string): ActionFactory => { - const actionFactory = this.actionFactories.get(actionFactoryId); - - if (!actionFactory) { - throw new Error(`Action factory [actionFactoryId = ${actionFactoryId}] does not exist.`); - } - - return actionFactory; - }; - - /** - * Returns an array of all action factories. - */ - public readonly getActionFactories = (): ActionFactory[] => { - return [...this.actionFactories.values()]; - }; } diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index ade21ee4b7d91..5b427f918c173 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -69,7 +69,7 @@ test('executes a single action mapped to a trigger', async () => { const action = createTestAction('test1', () => true); setup.registerTrigger(trigger); - setup.addTriggerAction(trigger.id, action); + setup.attachAction(trigger.id, action); const context = {}; const start = doStart(); @@ -109,7 +109,7 @@ test('does not execute an incompatible action', async () => { ); setup.registerTrigger(trigger); - setup.addTriggerAction(trigger.id, action); + setup.attachAction(trigger.id, action); const start = doStart(); const context = { @@ -130,8 +130,8 @@ test('shows a context menu when more than one action is mapped to a trigger', as const action2 = createTestAction('test2', () => true); setup.registerTrigger(trigger); - setup.addTriggerAction(trigger.id, action1); - setup.addTriggerAction(trigger.id, action2); + setup.attachAction(trigger.id, action1); + setup.attachAction(trigger.id, action2); expect(openContextMenu).toHaveBeenCalledTimes(0); @@ -155,7 +155,7 @@ test('passes whole action context to isCompatible()', async () => { }); setup.registerTrigger(trigger); - setup.addTriggerAction(trigger.id, action); + setup.attachAction(trigger.id, action); const start = doStart(); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts index 55ccac42ff255..f5a6a96fb41a4 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { ActionInternal, Action } from '../actions'; +import { Action } from '../actions'; import { uiActionsPluginMock } from '../mocks'; import { TriggerId, ActionType } from '../types'; @@ -47,14 +47,13 @@ test('returns actions set on trigger', () => { expect(list0).toHaveLength(0); - setup.addTriggerAction('trigger' as TriggerId, action1); + setup.attachAction('trigger' as TriggerId, action1); const list1 = start.getTriggerActions('trigger' as TriggerId); expect(list1).toHaveLength(1); - expect(list1[0]).toBeInstanceOf(ActionInternal); - expect(list1[0].id).toBe(action1.id); + expect(list1).toEqual([action1]); - setup.addTriggerAction('trigger' as TriggerId, action2); + setup.attachAction('trigger' as TriggerId, action2); const list2 = start.getTriggerActions('trigger' as TriggerId); expect(list2).toHaveLength(2); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts index 21dd17ed82e3f..c5e68e5d5ca5a 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { id: 'trigger' as TriggerId, title: 'trigger', }); - uiActions.setup.addTriggerAction('trigger' as TriggerId, action); + uiActions.setup.attachAction('trigger' as TriggerId, action); }); test('can register action', async () => { @@ -58,7 +58,7 @@ test('getTriggerCompatibleActions returns attached actions', async () => { title: 'My trigger', }; setup.registerTrigger(testTrigger); - setup.addTriggerAction('MY-TRIGGER' as TriggerId, helloWorldAction); + setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction); const start = doStart(); const actions = await start.getTriggerCompatibleActions('MY-TRIGGER' as TriggerId, {}); @@ -84,7 +84,7 @@ test('filters out actions not applicable based on the context', async () => { setup.registerTrigger(testTrigger); setup.registerAction(action1); - setup.addTriggerAction(testTrigger.id, action1); + setup.attachAction(testTrigger.id, action1); const start = doStart(); let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true }); diff --git a/src/plugins/ui_actions/public/tests/test_samples/index.ts b/src/plugins/ui_actions/public/tests/test_samples/index.ts index dfa71cec89595..7d63b1b6d5669 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/index.ts +++ b/src/plugins/ui_actions/public/tests/test_samples/index.ts @@ -16,5 +16,4 @@ * specific language governing permissions and limitations * under the License. */ - export { createHelloWorldAction } from './hello_world_action'; diff --git a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts index 9758508dc3dac..c638db0ce9dab 100644 --- a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts @@ -22,6 +22,6 @@ import { Trigger } from '.'; export const SELECT_RANGE_TRIGGER = 'SELECT_RANGE_TRIGGER'; export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = { id: SELECT_RANGE_TRIGGER, - title: '', + title: 'Select range', description: 'Applies a range filter', }; diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts index 9885ed3abe93b..5b670df354f78 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts @@ -72,7 +72,6 @@ export class TriggerInternal { const panel = await buildContextMenuForActions({ actions, actionContext: context, - title: this.trigger.title, closeMenu: () => session.close(), }); const session = openContextMenu([panel]); diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts index 2671584d105c8..ad32bdc1b564e 100644 --- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts @@ -22,6 +22,6 @@ import { Trigger } from '.'; export const VALUE_CLICK_TRIGGER = 'VALUE_CLICK_TRIGGER'; export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = { id: VALUE_CLICK_TRIGGER, - title: '', + title: 'Value clicked', description: 'Value was clicked', }; diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index 2cb4a8f26a879..c7e6d61e15f31 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -17,17 +17,15 @@ * under the License. */ -import { ActionInternal } from './actions/action_internal'; +import { ActionByType } from './actions/action'; import { TriggerInternal } from './triggers/trigger_internal'; -import { ActionFactory } from './actions'; import { EmbeddableVisTriggerContext, IEmbeddable } from '../../embeddable/public'; import { Filter } from '../../data/public'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; export type TriggerRegistry = Map>; -export type ActionRegistry = Map; +export type ActionRegistry = Map>; export type TriggerToActionsRegistry = Map; -export type ActionFactoryRegistry = Map; const DEFAULT_TRIGGER = ''; diff --git a/src/plugins/ui_actions/public/util/configurable.ts b/src/plugins/ui_actions/public/util/configurable.ts deleted file mode 100644 index d3a527a2183b1..0000000000000 --- a/src/plugins/ui_actions/public/util/configurable.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { UiComponent } from 'src/plugins/kibana_utils/common'; - -/** - * Represents something that can be configured by user using UI. - */ -export interface Configurable { - /** - * Create default config for this item, used when item is created for the first time. - */ - readonly createConfig: () => Config; - - /** - * Is this config valid. Used to validate user's input before saving. - */ - readonly isConfigValid: (config: Config) => boolean; - - /** - * `UiComponent` to be rendered when collecting configuration for this item. - */ - readonly CollectConfig: UiComponent>; -} - -/** - * Props provided to `CollectConfig` component on every re-render. - */ -export interface CollectConfigProps { - /** - * Current (latest) config of the item. - */ - config: Config; - - /** - * Callback called when user updates the config in UI. - */ - onConfig: (config: Config) => void; - - /** - * Context information about where component is being rendered. - */ - context: Context; -} diff --git a/src/plugins/ui_actions/public/util/index.ts b/src/plugins/ui_actions/public/util/index.ts deleted file mode 100644 index 53c6109cac4ca..0000000000000 --- a/src/plugins/ui_actions/public/util/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export * from './presentable'; -export * from './configurable'; diff --git a/src/plugins/ui_actions/scripts/storybook.js b/src/plugins/ui_actions/scripts/storybook.js deleted file mode 100644 index cb2eda610170d..0000000000000 --- a/src/plugins/ui_actions/scripts/storybook.js +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { join } from 'path'; - -// eslint-disable-next-line -require('@kbn/storybook').runStorybookCli({ - name: 'ui_actions', - storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], -}); diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx index 8ddb2e1a4803b..18ceec652392d 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx @@ -70,10 +70,11 @@ export class EmbeddableExplorerPublicPlugin const sayHelloAction = new SayHelloAction(alert); const sendMessageAction = createSendMessageAction(core.overlays); + plugins.uiActions.registerAction(helloWorldAction); plugins.uiActions.registerAction(sayHelloAction); plugins.uiActions.registerAction(sendMessageAction); - plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, helloWorldAction); + plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction); plugins.__LEGACY.onRenderComplete(() => { const root = document.getElementById(REACT_ROOT_ID); diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx index 7c7cc689d05e5..8395fddece2a4 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx @@ -62,4 +62,5 @@ function createSamplePanelAction() { } const action = createSamplePanelAction(); -npSetup.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); +npSetup.plugins.uiActions.registerAction(action); +npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts index e034fbe320608..4b09be4db8a60 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts @@ -33,4 +33,5 @@ export const createSamplePanelLink = (): Action => }); const action = createSamplePanelLink(); -npStart.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); +npStart.plugins.uiActions.registerAction(action); +npStart.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 784b5a5a42ace..2a28e349ace99 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -9,7 +9,6 @@ "xpack.beatsManagement": "legacy/plugins/beats_management", "xpack.canvas": "legacy/plugins/canvas", "xpack.crossClusterReplication": "legacy/plugins/cross_cluster_replication", - "xpack.dashboard": "plugins/dashboard_enhanced", "xpack.dashboardMode": "legacy/plugins/dashboard_mode", "xpack.data": "plugins/data_enhanced", "xpack.drilldowns": "plugins/drilldowns", diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss index 87ec3f8fc7ec1..2ba6f9baca90d 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss @@ -1,3 +1,8 @@ +.auaActionWizard__selectedActionFactoryContainer { + background-color: $euiColorLightestShade; + padding: $euiSize; +} + .auaActionWizard__actionFactoryItem { .euiKeyPadMenuItem__label { height: #{$euiSizeXL}; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx index 9c73f07289dc9..62f16890cade2 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx @@ -6,26 +6,28 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { Demo, dashboardFactory, urlFactory } from './test_data'; +import { dashboardDrilldownActionFactory, Demo, urlDrilldownActionFactory } from './test_data'; storiesOf('components/ActionWizard', module) - .add('default', () => ) + .add('default', () => ( + + )) .add('Only one factory is available', () => ( // to make sure layout doesn't break - + )) .add('Long list of action factories', () => ( // to make sure layout doesn't break )); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx index cc56714fcb2f8..aea47be693b8f 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx @@ -8,14 +8,21 @@ import React from 'react'; import { cleanup, fireEvent, render } from '@testing-library/react/pure'; import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global import { TEST_SUBJ_ACTION_FACTORY_ITEM, TEST_SUBJ_SELECTED_ACTION_FACTORY } from './action_wizard'; -import { dashboardFactory, dashboards, Demo, urlFactory } from './test_data'; +import { + dashboardDrilldownActionFactory, + dashboards, + Demo, + urlDrilldownActionFactory, +} from './test_data'; // TODO: afterEach is not available for it globally during setup // https://github.com/elastic/kibana/issues/59469 afterEach(cleanup); test('Pick and configure action', () => { - const screen = render(); + const screen = render( + + ); // check that all factories are displayed to pick expect(screen.getAllByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).toHaveLength(2); @@ -40,7 +47,7 @@ test('Pick and configure action', () => { }); test('If only one actions factory is available then actionFactory selection is emitted without user input', () => { - const screen = render(); + const screen = render(); // check that no factories are displayed to pick from expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument(); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx index 846f6d41eb30d..41ef863c00e44 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx @@ -16,23 +16,40 @@ import { } from '@elastic/eui'; import { txtChangeButton } from './i18n'; import './action_wizard.scss'; -import { ActionFactory } from '../../services'; -type ActionBaseConfig = object; -type ActionFactoryBaseContext = object; +// TODO: this interface is temporary for just moving forward with the component +// and it will be imported from the ../ui_actions when implemented properly +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ActionBaseConfig = {}; +export interface ActionFactory { + type: string; // TODO: type should be tied to Action and ActionByType + displayName: string; + iconType?: string; + wizard: React.FC>; + createConfig: () => Config; + isValid: (config: Config) => boolean; +} + +export interface ActionFactoryWizardProps { + config?: Config; + + /** + * Callback called when user updates the config in UI. + */ + onConfig: (config: Config) => void; +} export interface ActionWizardProps { /** * List of available action factories */ - actionFactories: ActionFactory[]; + actionFactories: Array>; // any here to be able to pass array of ActionFactory with different configs /** * Currently selected action factory * undefined - is allowed and means that non is selected */ currentActionFactory?: ActionFactory; - /** * Action factory selected changed * null - means user click "change" and removed action factory selection @@ -48,11 +65,6 @@ export interface ActionWizardProps { * config changed */ onConfigChange: (config: ActionBaseConfig) => void; - - /** - * Context will be passed into ActionFactory's methods - */ - context: ActionFactoryBaseContext; } export const ActionWizard: React.FC = ({ @@ -61,7 +73,6 @@ export const ActionWizard: React.FC = ({ onActionFactoryChange, onConfigChange, config, - context, }) => { // auto pick action factory if there is only 1 available if (!currentActionFactory && actionFactories.length === 1) { @@ -76,7 +87,6 @@ export const ActionWizard: React.FC = ({ onDeselect={() => { onActionFactoryChange(null); }} - context={context} config={config} onConfigChange={newConfig => { onConfigChange(newConfig); @@ -87,7 +97,6 @@ export const ActionWizard: React.FC = ({ return ( { onActionFactoryChange(actionFactory); @@ -96,11 +105,10 @@ export const ActionWizard: React.FC = ({ ); }; -interface SelectedActionFactoryProps { - actionFactory: ActionFactory; - config: ActionBaseConfig; - context: ActionFactoryBaseContext; - onConfigChange: (config: ActionBaseConfig) => void; +interface SelectedActionFactoryProps { + actionFactory: ActionFactory; + config: Config; + onConfigChange: (config: Config) => void; showDeselect: boolean; onDeselect: () => void; } @@ -113,28 +121,28 @@ const SelectedActionFactory: React.FC = ({ showDeselect, onConfigChange, config, - context, }) => { return (
- {actionFactory.getIconType(context) && ( + {actionFactory.iconType && ( - + )} -

{actionFactory.getDisplayName(context)}

+

{actionFactory.displayName}

{showDeselect && ( - onDeselect()}> + onDeselect()}> {txtChangeButton} @@ -143,11 +151,10 @@ const SelectedActionFactory: React.FC = ({
- + {actionFactory.wizard({ + config, + onConfig: onConfigChange, + })}
); @@ -155,7 +162,6 @@ const SelectedActionFactory: React.FC = ({ interface ActionFactorySelectorProps { actionFactories: ActionFactory[]; - context: ActionFactoryBaseContext; onActionFactorySelected: (actionFactory: ActionFactory) => void; } @@ -164,7 +170,6 @@ export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'action-factory-item'; const ActionFactorySelector: React.FC = ({ actionFactories, onActionFactorySelected, - context, }) => { if (actionFactories.length === 0) { // this is not user facing, as it would be impossible to get into this state @@ -173,23 +178,19 @@ const ActionFactorySelector: React.FC = ({ } return ( - - {[...actionFactories] - .sort((f1, f2) => f1.order - f2.order) - .map(actionFactory => ( - - onActionFactorySelected(actionFactory)} - > - {actionFactory.getIconType(context) && ( - - )} - - - ))} + + {actionFactories.map(actionFactory => ( + onActionFactorySelected(actionFactory)} + > + {actionFactory.iconType && } + + ))} ); }; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts index a315184bf68ef..641f25176264a 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts @@ -9,6 +9,6 @@ import { i18n } from '@kbn/i18n'; export const txtChangeButton = i18n.translate( 'xpack.advancedUiActions.components.actionWizard.changeButton', { - defaultMessage: 'Change', + defaultMessage: 'change', } ); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts index a189afbf956ee..ed224248ec4cd 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ActionWizard } from './action_wizard'; +export { ActionFactory, ActionWizard } from './action_wizard'; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx index 167cb130fdb4a..8ecdde681069e 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx @@ -6,161 +6,124 @@ import React, { useState } from 'react'; import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; -import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; -import { ActionWizard } from './action_wizard'; -import { ActionFactoryDefinition, ActionFactory } from '../../services'; -import { CollectConfigProps } from '../../util'; - -type ActionBaseConfig = object; +import { ActionFactory, ActionBaseConfig, ActionWizard } from './action_wizard'; export const dashboards = [ { id: 'dashboard1', title: 'Dashboard 1' }, { id: 'dashboard2', title: 'Dashboard 2' }, ]; -interface DashboardDrilldownConfig { +export const dashboardDrilldownActionFactory: ActionFactory<{ dashboardId?: string; - useCurrentFilters: boolean; - useCurrentDateRange: boolean; -} - -function DashboardDrilldownCollectConfig(props: CollectConfigProps) { - const config = props.config ?? { - dashboardId: undefined, - useCurrentFilters: true, - useCurrentDateRange: true, - }; - return ( - <> - - ({ value: id, text: title }))} - value={config.dashboardId} - onChange={e => { - props.onConfig({ ...config, dashboardId: e.target.value }); - }} - /> - - - - props.onConfig({ - ...config, - useCurrentFilters: !config.useCurrentFilters, - }) - } - /> - - - - props.onConfig({ - ...config, - useCurrentDateRange: !config.useCurrentDateRange, - }) - } - /> - - - ); -} - -export const dashboardDrilldownActionFactory: ActionFactoryDefinition< - DashboardDrilldownConfig, - any, - any -> = { - id: 'Dashboard', - getDisplayName: () => 'Go to Dashboard', - getIconType: () => 'dashboardApp', + useCurrentDashboardFilters: boolean; + useCurrentDashboardDataRange: boolean; +}> = { + type: 'Dashboard', + displayName: 'Go to Dashboard', + iconType: 'dashboardApp', createConfig: () => { return { dashboardId: undefined, - useCurrentFilters: true, - useCurrentDateRange: true, + useCurrentDashboardDataRange: true, + useCurrentDashboardFilters: true, }; }, - isConfigValid: (config: DashboardDrilldownConfig): config is DashboardDrilldownConfig => { + isValid: config => { if (!config.dashboardId) return false; return true; }, - CollectConfig: reactToUiComponent(DashboardDrilldownCollectConfig), - - isCompatible(context?: object): Promise { - return Promise.resolve(true); + wizard: props => { + const config = props.config ?? { + dashboardId: undefined, + useCurrentDashboardDataRange: true, + useCurrentDashboardFilters: true, + }; + return ( + <> + + ({ value: id, text: title }))} + value={config.dashboardId} + onChange={e => { + props.onConfig({ ...config, dashboardId: e.target.value }); + }} + /> + + + + props.onConfig({ + ...config, + useCurrentDashboardFilters: !config.useCurrentDashboardFilters, + }) + } + /> + + + + props.onConfig({ + ...config, + useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange, + }) + } + /> + + + ); }, - order: 0, - create: () => ({ - id: 'test', - execute: async () => alert('Navigate to dashboard!'), - }), }; -export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory); - -interface UrlDrilldownConfig { - url: string; - openInNewTab: boolean; -} -function UrlDrilldownCollectConfig(props: CollectConfigProps) { - const config = props.config ?? { - url: '', - openInNewTab: false, - }; - return ( - <> - - props.onConfig({ ...config, url: event.target.value })} - /> - - - props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} - /> - - - ); -} -export const urlDrilldownActionFactory: ActionFactoryDefinition = { - id: 'Url', - getDisplayName: () => 'Go to URL', - getIconType: () => 'link', +export const urlDrilldownActionFactory: ActionFactory<{ url: string; openInNewTab: boolean }> = { + type: 'Url', + displayName: 'Go to URL', + iconType: 'link', createConfig: () => { return { url: '', openInNewTab: false, }; }, - isConfigValid: (config: UrlDrilldownConfig): config is UrlDrilldownConfig => { + isValid: config => { if (!config.url) return false; return true; }, - CollectConfig: reactToUiComponent(UrlDrilldownCollectConfig), - - order: 10, - isCompatible(context?: object): Promise { - return Promise.resolve(true); + wizard: props => { + const config = props.config ?? { + url: '', + openInNewTab: false, + }; + return ( + <> + + props.onConfig({ ...config, url: event.target.value })} + /> + + + props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + + + ); }, - create: () => null as any, }; -export const urlFactory = new ActionFactory(urlDrilldownActionFactory); - export function Demo({ actionFactories }: { actionFactories: Array> }) { const [state, setState] = useState<{ currentActionFactory?: ActionFactory; @@ -194,15 +157,14 @@ export function Demo({ actionFactories }: { actionFactories: Array

-
Action Factory Id: {state.currentActionFactory?.id}
+
Action Factory Type: {state.currentActionFactory?.type}
Action Factory Config: {JSON.stringify(state.config)}
Is config valid:{' '} - {JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)} + {JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)}
); diff --git a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx index c0cd8d5540db2..325a5ddc10179 100644 --- a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx @@ -44,7 +44,7 @@ export class CustomTimeRangeAction implements ActionByType { + implements Plugin { constructor(initializerContext: PluginInitializerContext) {} - public setup(core: CoreSetup, { uiActions }: SetupDependencies): SetupContract { - return { - ...uiActions, - }; - } + public setup(core: CoreSetup, { uiActions }: SetupDependencies): Setup {} - public start(core: CoreStart, { uiActions }: StartDependencies): StartContract { + public start(core: CoreStart, { uiActions }: StartDependencies): Start { const dateFormat = core.uiSettings.get('dateFormat') as string; const commonlyUsedRanges = core.uiSettings.get('timepicker:quickRanges') as CommonlyUsedRange[]; const { openModal } = createReactOverlays(core); @@ -72,18 +66,16 @@ export class AdvancedUiActionsPublicPlugin dateFormat, commonlyUsedRanges, }); - uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, timeRangeAction); + uiActions.registerAction(timeRangeAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction); const timeRangeBadge = new CustomTimeRangeBadge({ openModal, dateFormat, commonlyUsedRanges, }); - uiActions.addTriggerAction(PANEL_BADGE_TRIGGER, timeRangeBadge); - - return { - ...uiActions, - }; + uiActions.registerAction(timeRangeBadge); + uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge); } public stop() {} diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts deleted file mode 100644 index 66e2a4eafa880..0000000000000 --- a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts +++ /dev/null @@ -1,11 +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. - */ - -/* eslint-disable */ - -export { - ActionFactory -} from '../../../../../../src/plugins/ui_actions/public/actions/action_factory'; diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts deleted file mode 100644 index f8669a4bf813f..0000000000000 --- a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts +++ /dev/null @@ -1,11 +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. - */ - -/* eslint-disable */ - -export { - ActionFactoryDefinition -} from '../../../../../../src/plugins/ui_actions/public/actions/action_factory_definition'; diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts deleted file mode 100644 index db5bb3aa62a16..0000000000000 --- a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts +++ /dev/null @@ -1,8 +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. - */ - -export * from './action_factory_definition'; -export * from './action_factory'; diff --git a/x-pack/plugins/advanced_ui_actions/public/util/index.ts b/x-pack/plugins/advanced_ui_actions/public/util/index.ts deleted file mode 100644 index fd3ab89973348..0000000000000 --- a/x-pack/plugins/advanced_ui_actions/public/util/index.ts +++ /dev/null @@ -1,10 +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. - */ - -export { - UiActionsConfigurable as Configurable, - UiActionsCollectConfigProps as CollectConfigProps, -} from '../../../../../src/plugins/ui_actions/public'; diff --git a/x-pack/plugins/dashboard_enhanced/README.md b/x-pack/plugins/dashboard_enhanced/README.md deleted file mode 100644 index d9296ae158621..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/README.md +++ /dev/null @@ -1 +0,0 @@ -# X-Pack part of Dashboard app diff --git a/x-pack/plugins/dashboard_enhanced/kibana.json b/x-pack/plugins/dashboard_enhanced/kibana.json deleted file mode 100644 index acbca5c33295c..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/kibana.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": "dashboardEnhanced", - "version": "kibana", - "server": true, - "ui": true, - "requiredPlugins": ["uiActions", "embeddable", "dashboard", "drilldowns"], - "configPath": ["xpack", "dashboardEnhanced"] -} diff --git a/x-pack/plugins/dashboard_enhanced/public/components/README.md b/x-pack/plugins/dashboard_enhanced/public/components/README.md deleted file mode 100644 index 8081f8a2451cf..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/components/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Presentation React components - -Here we keep reusable *presentation* (aka *dumb*) React components—these -components should not be connected to state and ideally should not know anything -about Kibana. diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx deleted file mode 100644 index 8e204b044a136..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx +++ /dev/null @@ -1,54 +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. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { DashboardDrilldownConfig } from '.'; - -export const dashboards = [ - { id: 'dashboard1', title: 'Dashboard 1' }, - { id: 'dashboard2', title: 'Dashboard 2' }, - { id: 'dashboard3', title: 'Dashboard 3' }, -]; - -const InteractiveDemo: React.FC = () => { - const [activeDashboardId, setActiveDashboardId] = React.useState('dashboard1'); - const [currentFilters, setCurrentFilters] = React.useState(false); - const [keepRange, setKeepRange] = React.useState(false); - - return ( - setActiveDashboardId(id)} - onCurrentFiltersToggle={() => setCurrentFilters(old => !old)} - onKeepRangeToggle={() => setKeepRange(old => !old)} - /> - ); -}; - -storiesOf('components/DashboardDrilldownConfig', module) - .add('default', () => ( - console.log('onDashboardSelect', e)} - /> - )) - .add('with switches', () => ( - console.log('onDashboardSelect', e)} - onCurrentFiltersToggle={() => console.log('onCurrentFiltersToggle')} - onKeepRangeToggle={() => console.log('onKeepRangeToggle')} - /> - )) - .add('interactive demo', () => ); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx deleted file mode 100644 index 911ff6f632635..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx +++ /dev/null @@ -1,11 +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. - */ - -test.todo('renders list of dashboards'); -test.todo('renders correct selected dashboard'); -test.todo('can change dashboard'); -test.todo('can toggle "use current filters" switch'); -test.todo('can toggle "date range" switch'); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx deleted file mode 100644 index b45ba602b9bb1..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx +++ /dev/null @@ -1,69 +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 from 'react'; -import { EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; -import { txtChooseDestinationDashboard } from './i18n'; - -export interface DashboardItem { - id: string; - title: string; -} - -export interface DashboardDrilldownConfigProps { - activeDashboardId?: string; - dashboards: DashboardItem[]; - currentFilters?: boolean; - keepRange?: boolean; - onDashboardSelect: (dashboardId: string) => void; - onCurrentFiltersToggle?: () => void; - onKeepRangeToggle?: () => void; -} - -export const DashboardDrilldownConfig: React.FC = ({ - activeDashboardId, - dashboards, - currentFilters, - keepRange, - onDashboardSelect, - onCurrentFiltersToggle, - onKeepRangeToggle, -}) => { - // TODO: use i18n below. - return ( - <> - - ({ value: id, text: title }))} - value={activeDashboardId} - onChange={e => onDashboardSelect(e.target.value)} - /> - - {!!onCurrentFiltersToggle && ( - - - - )} - {!!onKeepRangeToggle && ( - - - - )} - - ); -}; diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts deleted file mode 100644 index 38fe6dd150853..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts +++ /dev/null @@ -1,14 +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 { i18n } from '@kbn/i18n'; - -export const txtChooseDestinationDashboard = i18n.translate( - 'xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard', - { - defaultMessage: 'Choose destination dashboard', - } -); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts deleted file mode 100644 index b9a64a3cc17e6..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export * from './dashboard_drilldown_config'; diff --git a/x-pack/plugins/dashboard_enhanced/public/components/index.ts b/x-pack/plugins/dashboard_enhanced/public/components/index.ts deleted file mode 100644 index b9a64a3cc17e6..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/components/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export * from './dashboard_drilldown_config'; diff --git a/x-pack/plugins/dashboard_enhanced/public/index.ts b/x-pack/plugins/dashboard_enhanced/public/index.ts deleted file mode 100644 index 53540a4a1ad2e..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/index.ts +++ /dev/null @@ -1,19 +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 { PluginInitializerContext } from 'src/core/public'; -import { DashboardEnhancedPlugin } from './plugin'; - -export { - SetupContract as DashboardEnhancedSetupContract, - SetupDependencies as DashboardEnhancedSetupDependencies, - StartContract as DashboardEnhancedStartContract, - StartDependencies as DashboardEnhancedStartDependencies, -} from './plugin'; - -export function plugin(context: PluginInitializerContext) { - return new DashboardEnhancedPlugin(context); -} diff --git a/x-pack/plugins/dashboard_enhanced/public/mocks.ts b/x-pack/plugins/dashboard_enhanced/public/mocks.ts deleted file mode 100644 index 67dc1fd97d521..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/mocks.ts +++ /dev/null @@ -1,27 +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 { DashboardEnhancedSetupContract, DashboardEnhancedStartContract } from '.'; - -export type Setup = jest.Mocked; -export type Start = jest.Mocked; - -const createSetupContract = (): Setup => { - const setupContract: Setup = {}; - - return setupContract; -}; - -const createStartContract = (): Start => { - const startContract: Start = {}; - - return startContract; -}; - -export const dashboardEnhancedPluginMock = { - createSetupContract, - createStartContract, -}; diff --git a/x-pack/plugins/dashboard_enhanced/public/plugin.ts b/x-pack/plugins/dashboard_enhanced/public/plugin.ts deleted file mode 100644 index 30b3f3c080f49..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/plugin.ts +++ /dev/null @@ -1,50 +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 { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; -import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; -import { DashboardDrilldownsService } from './services'; -import { DrilldownsSetup, DrilldownsStart } from '../../drilldowns/public'; - -export interface SetupDependencies { - uiActions: UiActionsSetup; - drilldowns: DrilldownsSetup; -} - -export interface StartDependencies { - uiActions: UiActionsStart; - drilldowns: DrilldownsStart; -} - -// eslint-disable-next-line -export interface SetupContract {} - -// eslint-disable-next-line -export interface StartContract {} - -export class DashboardEnhancedPlugin - implements Plugin { - public readonly drilldowns = new DashboardDrilldownsService(); - public readonly config: { drilldowns: { enabled: boolean } }; - - constructor(protected readonly context: PluginInitializerContext) { - this.config = context.config.get(); - } - - public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { - this.drilldowns.bootstrap(core, plugins, { - enableDrilldowns: this.config.drilldowns.enabled, - }); - - return {}; - } - - public start(core: CoreStart, plugins: StartDependencies): StartContract { - return {}; - } - - public stop() {} -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx deleted file mode 100644 index 31ee9e29938cb..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx +++ /dev/null @@ -1,124 +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 { - FlyoutCreateDrilldownAction, - OpenFlyoutAddDrilldownParams, -} from './flyout_create_drilldown'; -import { coreMock } from '../../../../../../../../src/core/public/mocks'; -import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; -import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; -import { uiActionsPluginMock } from '../../../../../../../../src/plugins/ui_actions/public/mocks'; -import { TriggerContextMapping } from '../../../../../../../../src/plugins/ui_actions/public'; -import { MockEmbeddable } from '../test_helpers'; - -const overlays = coreMock.createStart().overlays; -const drilldowns = drilldownsPluginMock.createStartContract(); -const uiActions = uiActionsPluginMock.createStartContract(); - -const actionParams: OpenFlyoutAddDrilldownParams = { - drilldowns: () => Promise.resolve(drilldowns), - overlays: () => Promise.resolve(overlays), -}; - -test('should create', () => { - expect(() => new FlyoutCreateDrilldownAction(actionParams)).not.toThrow(); -}); - -test('title is a string', () => { - expect(typeof new FlyoutCreateDrilldownAction(actionParams).getDisplayName() === 'string').toBe( - true - ); -}); - -test('icon exists', () => { - expect(typeof new FlyoutCreateDrilldownAction(actionParams).getIconType() === 'string').toBe( - true - ); -}); - -describe('isCompatible', () => { - const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); - - function checkCompatibility(params: { - isEdit: boolean; - withUiActions: boolean; - isValueClickTriggerSupported: boolean; - }): Promise { - return drilldownAction.isCompatible({ - embeddable: new MockEmbeddable( - { id: '', viewMode: params.isEdit ? ViewMode.EDIT : ViewMode.VIEW }, - { - supportedTriggers: (params.isValueClickTriggerSupported - ? ['VALUE_CLICK_TRIGGER'] - : []) as Array, - uiActions: params.withUiActions ? uiActions : undefined, // dynamic actions support - } - ), - }); - } - - test("compatible if dynamicUiActions enabled, 'VALUE_CLICK_TRIGGER' is supported, in edit mode", async () => { - expect( - await checkCompatibility({ - withUiActions: true, - isEdit: true, - isValueClickTriggerSupported: true, - }) - ).toBe(true); - }); - - test('not compatible if dynamicUiActions disabled', async () => { - expect( - await checkCompatibility({ - withUiActions: false, - isEdit: true, - isValueClickTriggerSupported: true, - }) - ).toBe(false); - }); - - test("not compatible if 'VALUE_CLICK_TRIGGER' is not supported", async () => { - expect( - await checkCompatibility({ - withUiActions: true, - isEdit: true, - isValueClickTriggerSupported: false, - }) - ).toBe(false); - }); - - test('not compatible if in view mode', async () => { - expect( - await checkCompatibility({ - withUiActions: true, - isEdit: false, - isValueClickTriggerSupported: true, - }) - ).toBe(false); - }); -}); - -describe('execute', () => { - const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); - test('throws error if no dynamicUiActions', async () => { - await expect( - drilldownAction.execute({ - embeddable: new MockEmbeddable({ id: '' }, {}), - }) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Can't execute FlyoutCreateDrilldownAction without dynamicActionsManager"` - ); - }); - - test('should open flyout', async () => { - const spy = jest.spyOn(overlays, 'openFlyout'); - await drilldownAction.execute({ - embeddable: new MockEmbeddable({ id: '' }, { uiActions }), - }); - expect(spy).toBeCalled(); - }); -}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx deleted file mode 100644 index 00e74ea570a11..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx +++ /dev/null @@ -1,74 +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 from 'react'; -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; -import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; -import { DrilldownsStart } from '../../../../../../drilldowns/public'; -import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; - -export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; - -export interface OpenFlyoutAddDrilldownParams { - overlays: () => Promise; - drilldowns: () => Promise; -} - -export class FlyoutCreateDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; - public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; - public order = 12; - - constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} - - public getDisplayName() { - return i18n.translate('xpack.dashboard.FlyoutCreateDrilldownAction.displayName', { - defaultMessage: 'Create drilldown', - }); - } - - public getIconType() { - return 'plusInCircle'; - } - - private isEmbeddableCompatible(context: EmbeddableContext) { - if (!context.embeddable.dynamicActions) return false; - const supportedTriggers = context.embeddable.supportedTriggers(); - if (!supportedTriggers || !supportedTriggers.length) return false; - return supportedTriggers.indexOf('VALUE_CLICK_TRIGGER') > -1; - } - - public async isCompatible(context: EmbeddableContext) { - const isEditMode = context.embeddable.getInput().viewMode === 'edit'; - return isEditMode && this.isEmbeddableCompatible(context); - } - - public async execute(context: EmbeddableContext) { - const overlays = await this.params.overlays(); - const drilldowns = await this.params.drilldowns(); - const dynamicActionManager = context.embeddable.dynamicActions; - - if (!dynamicActionManager) { - throw new Error(`Can't execute FlyoutCreateDrilldownAction without dynamicActionsManager`); - } - - const handle = overlays.openFlyout( - toMountPoint( - handle.close()} - placeContext={context} - viewMode={'create'} - dynamicActionManager={dynamicActionManager} - /> - ), - { - ownFocus: true, - } - ); - } -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts deleted file mode 100644 index 4d2db209fc961..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts +++ /dev/null @@ -1,11 +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. - */ - -export { - FlyoutCreateDrilldownAction, - OpenFlyoutAddDrilldownParams, - OPEN_FLYOUT_ADD_DRILLDOWN, -} from './flyout_create_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx deleted file mode 100644 index a3f11eb976f90..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx +++ /dev/null @@ -1,102 +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 { FlyoutEditDrilldownAction, FlyoutEditDrilldownParams } from './flyout_edit_drilldown'; -import { coreMock } from '../../../../../../../../src/core/public/mocks'; -import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; -import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; -import { uiActionsPluginMock } from '../../../../../../../../src/plugins/ui_actions/public/mocks'; -import { MockEmbeddable } from '../test_helpers'; - -const overlays = coreMock.createStart().overlays; -const drilldowns = drilldownsPluginMock.createStartContract(); -const uiActions = uiActionsPluginMock.createStartContract(); - -const actionParams: FlyoutEditDrilldownParams = { - drilldowns: () => Promise.resolve(drilldowns), - overlays: () => Promise.resolve(overlays), -}; - -test('should create', () => { - expect(() => new FlyoutEditDrilldownAction(actionParams)).not.toThrow(); -}); - -test('title is a string', () => { - expect(typeof new FlyoutEditDrilldownAction(actionParams).getDisplayName() === 'string').toBe( - true - ); -}); - -test('icon exists', () => { - expect(typeof new FlyoutEditDrilldownAction(actionParams).getIconType() === 'string').toBe(true); -}); - -test('MenuItem exists', () => { - expect(new FlyoutEditDrilldownAction(actionParams).MenuItem).toBeDefined(); -}); - -describe('isCompatible', () => { - const drilldownAction = new FlyoutEditDrilldownAction(actionParams); - - function checkCompatibility(params: { - isEdit: boolean; - withUiActions: boolean; - }): Promise { - return drilldownAction.isCompatible({ - embeddable: new MockEmbeddable( - { - id: '', - viewMode: params.isEdit ? ViewMode.EDIT : ViewMode.VIEW, - }, - { - uiActions: params.withUiActions ? uiActions : undefined, // dynamic actions support - } - ), - }); - } - - // TODO: need proper DynamicActionsMock and ActionFactory mock - test.todo('compatible if dynamicUiActions enabled, in edit view, and have at least 1 drilldown'); - - test('not compatible if dynamicUiActions disabled', async () => { - expect( - await checkCompatibility({ - withUiActions: false, - isEdit: true, - }) - ).toBe(false); - }); - - test('not compatible if no drilldowns', async () => { - expect( - await checkCompatibility({ - withUiActions: true, - isEdit: true, - }) - ).toBe(false); - }); -}); - -describe('execute', () => { - const drilldownAction = new FlyoutEditDrilldownAction(actionParams); - test('throws error if no dynamicUiActions', async () => { - await expect( - drilldownAction.execute({ - embeddable: new MockEmbeddable({ id: '' }, {}), - }) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Can't execute FlyoutEditDrilldownAction without dynamicActionsManager"` - ); - }); - - test('should open flyout', async () => { - const spy = jest.spyOn(overlays, 'openFlyout'); - await drilldownAction.execute({ - embeddable: new MockEmbeddable({ id: '' }, { uiActions }), - }); - expect(spy).toBeCalled(); - }); -}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx deleted file mode 100644 index 816b757592a72..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx +++ /dev/null @@ -1,71 +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 from 'react'; -import { CoreStart } from 'src/core/public'; -import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; -import { - reactToUiComponent, - toMountPoint, -} from '../../../../../../../../src/plugins/kibana_react/public'; -import { EmbeddableContext, ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; -import { DrilldownsStart } from '../../../../../../drilldowns/public'; -import { txtDisplayName } from './i18n'; -import { MenuItem } from './menu_item'; - -export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; - -export interface FlyoutEditDrilldownParams { - overlays: () => Promise; - drilldowns: () => Promise; -} - -export class FlyoutEditDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; - public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; - public order = 10; - - constructor(protected readonly params: FlyoutEditDrilldownParams) {} - - public getDisplayName() { - return txtDisplayName; - } - - public getIconType() { - return 'list'; - } - - MenuItem = reactToUiComponent(MenuItem); - - public async isCompatible({ embeddable }: EmbeddableContext) { - if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false; - if (!embeddable.dynamicActions) return false; - return embeddable.dynamicActions.state.get().events.length > 0; - } - - public async execute(context: EmbeddableContext) { - const overlays = await this.params.overlays(); - const drilldowns = await this.params.drilldowns(); - const dynamicActionManager = context.embeddable.dynamicActions; - if (!dynamicActionManager) { - throw new Error(`Can't execute FlyoutEditDrilldownAction without dynamicActionsManager`); - } - - const handle = overlays.openFlyout( - toMountPoint( - handle.close()} - placeContext={context} - viewMode={'manage'} - dynamicActionManager={dynamicActionManager} - /> - ), - { - ownFocus: true, - } - ); - } -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx deleted file mode 100644 index 3e1b37f270708..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx +++ /dev/null @@ -1,11 +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. - */ - -export { - FlyoutEditDrilldownAction, - FlyoutEditDrilldownParams, - OPEN_FLYOUT_EDIT_DRILLDOWN, -} from './flyout_edit_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx deleted file mode 100644 index be693fadf9282..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx +++ /dev/null @@ -1,37 +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 from 'react'; -import { render, cleanup, act } from '@testing-library/react/pure'; -import { MenuItem } from './menu_item'; -import { createStateContainer } from '../../../../../../../../src/plugins/kibana_utils/common'; -import { DynamicActionManager } from '../../../../../../../../src/plugins/ui_actions/public'; -import { IEmbeddable } from '../../../../../../../../src/plugins/embeddable/public/lib/embeddables'; -import '@testing-library/jest-dom'; - -afterEach(cleanup); - -test('', () => { - const state = createStateContainer<{ events: object[] }>({ events: [] }); - const { getByText, queryByText } = render( - - ); - - expect(getByText(/manage drilldowns/i)).toBeInTheDocument(); - expect(queryByText('0')).not.toBeInTheDocument(); - - act(() => { - state.set({ events: [{}] }); - }); - - expect(queryByText('1')).toBeInTheDocument(); -}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx deleted file mode 100644 index 4f99fca511b07..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx +++ /dev/null @@ -1,30 +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 from 'react'; -import { EuiNotificationBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; -import { txtDisplayName } from './i18n'; -import { useContainerState } from '../../../../../../../../src/plugins/kibana_utils/common'; - -export const MenuItem: React.FC<{ context: EmbeddableContext }> = ({ context }) => { - if (!context.embeddable.dynamicActions) - throw new Error('Flyout edit drillldown context menu item requires `dynamicActions`'); - - const { events } = useContainerState(context.embeddable.dynamicActions.state); - const count = events.length; - - return ( - - {txtDisplayName} - {count > 0 && ( - - {count} - - )} - - ); -}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts deleted file mode 100644 index 9b156b0ba85b4..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts +++ /dev/null @@ -1,28 +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 { Embeddable, EmbeddableInput } from '../../../../../../../src/plugins/embeddable/public/'; -import { - TriggerContextMapping, - UiActionsStart, -} from '../../../../../../../src/plugins/ui_actions/public'; - -export class MockEmbeddable extends Embeddable { - public readonly type = 'mock'; - private readonly triggers: Array = []; - constructor( - initialInput: EmbeddableInput, - params: { uiActions?: UiActionsStart; supportedTriggers?: Array } - ) { - super(initialInput, {}, undefined, params); - this.triggers = params.supportedTriggers ?? []; - } - public render(node: HTMLElement) {} - public reload() {} - public supportedTriggers(): Array { - return this.triggers; - } -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts deleted file mode 100644 index 4bdf03dff3531..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts +++ /dev/null @@ -1,60 +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 { CoreSetup } from 'src/core/public'; -import { SetupDependencies } from '../../plugin'; -import { - CONTEXT_MENU_TRIGGER, - EmbeddableContext, -} from '../../../../../../src/plugins/embeddable/public'; -import { - FlyoutCreateDrilldownAction, - FlyoutEditDrilldownAction, - OPEN_FLYOUT_ADD_DRILLDOWN, - OPEN_FLYOUT_EDIT_DRILLDOWN, -} from './actions'; -import { DrilldownsStart } from '../../../../drilldowns/public'; -import { DashboardToDashboardDrilldown } from './dashboard_to_dashboard_drilldown'; - -declare module '../../../../../../src/plugins/ui_actions/public' { - export interface ActionContextMapping { - [OPEN_FLYOUT_ADD_DRILLDOWN]: EmbeddableContext; - [OPEN_FLYOUT_EDIT_DRILLDOWN]: EmbeddableContext; - } -} - -interface BootstrapParams { - enableDrilldowns: boolean; -} - -export class DashboardDrilldownsService { - bootstrap( - core: CoreSetup<{ drilldowns: DrilldownsStart }>, - plugins: SetupDependencies, - { enableDrilldowns }: BootstrapParams - ) { - if (enableDrilldowns) { - this.setupDrilldowns(core, plugins); - } - } - - setupDrilldowns(core: CoreSetup<{ drilldowns: DrilldownsStart }>, plugins: SetupDependencies) { - const overlays = async () => (await core.getStartServices())[0].overlays; - const drilldowns = async () => (await core.getStartServices())[1].drilldowns; - const savedObjects = async () => (await core.getStartServices())[0].savedObjects.client; - - const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays, drilldowns }); - plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); - - const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays, drilldowns }); - plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); - - const dashboardToDashboardDrilldown = new DashboardToDashboardDrilldown({ - savedObjects, - }); - plugins.drilldowns.registerDrilldown(dashboardToDashboardDrilldown); - } -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx deleted file mode 100644 index 95101605ce468..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx +++ /dev/null @@ -1,9 +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. - */ - -test.todo('displays all dashboard in a list'); -test.todo('does not display dashboard on which drilldown is being created'); -test.todo('updates config object correctly'); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx deleted file mode 100644 index e463cc38b6fbf..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx +++ /dev/null @@ -1,55 +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, { useState, useEffect } from 'react'; -import { CollectConfigProps } from './types'; -import { DashboardDrilldownConfig } from '../../../components/dashboard_drilldown_config'; -import { Params } from './drilldown'; - -export interface CollectConfigContainerProps extends CollectConfigProps { - params: Params; -} - -export const CollectConfigContainer: React.FC = ({ - config, - onConfig, - params: { savedObjects }, -}) => { - const [dashboards] = useState([ - { id: 'dashboard1', title: 'Dashboard 1' }, - { id: 'dashboard2', title: 'Dashboard 2' }, - { id: 'dashboard3', title: 'Dashboard 3' }, - { id: 'dashboard4', title: 'Dashboard 4' }, - ]); - - useEffect(() => { - // TODO: Load dashboards... - }, [savedObjects]); - - return ( - { - onConfig({ ...config, dashboardId }); - }} - onCurrentFiltersToggle={() => - onConfig({ - ...config, - useCurrentFilters: !config.useCurrentFilters, - }) - } - onKeepRangeToggle={() => - onConfig({ - ...config, - useCurrentDateRange: !config.useCurrentDateRange, - }) - } - /> - ); -}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts deleted file mode 100644 index e2a530b156da5..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts +++ /dev/null @@ -1,7 +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. - */ - -export const DASHBOARD_TO_DASHBOARD_DRILLDOWN = 'DASHBOARD_TO_DASHBOARD_DRILLDOWN'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx deleted file mode 100644 index 0fb60bb1064a1..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx +++ /dev/null @@ -1,20 +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. - */ - -describe('.isConfigValid()', () => { - test.todo('returns false for incorrect config'); - test.todo('returns true for incorrect config'); -}); - -describe('.execute()', () => { - test.todo('navigates to correct dashboard'); - test.todo( - 'when user chooses to keep current filters, current fileters are set on destination dashboard' - ); - test.todo( - 'when user chooses to keep current time range, current time range is set on destination dashboard' - ); -}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx deleted file mode 100644 index 9d2a378f08acd..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx +++ /dev/null @@ -1,52 +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 from 'react'; -import { CoreStart } from 'src/core/public'; -import { reactToUiComponent } from '../../../../../../../src/plugins/kibana_react/public'; -import { PlaceContext, ActionContext, Config, CollectConfigProps } from './types'; -import { CollectConfigContainer } from './collect_config'; -import { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; -import { DrilldownDefinition as Drilldown } from '../../../../../drilldowns/public'; -import { txtGoToDashboard } from './i18n'; - -export interface Params { - savedObjects: () => Promise; -} - -export class DashboardToDashboardDrilldown - implements Drilldown { - constructor(protected readonly params: Params) {} - - public readonly id = DASHBOARD_TO_DASHBOARD_DRILLDOWN; - - public readonly order = 100; - - public readonly getDisplayName = () => txtGoToDashboard; - - public readonly euiIcon = 'dashboardApp'; - - private readonly ReactCollectConfig: React.FC = props => ( - - ); - - public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); - - public readonly createConfig = () => ({ - dashboardId: '123', - useCurrentFilters: true, - useCurrentDateRange: true, - }); - - public readonly isConfigValid = (config: Config): config is Config => { - if (!config.dashboardId) return false; - return true; - }; - - public readonly execute = () => { - alert('Go to another dashboard!'); - }; -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts deleted file mode 100644 index 98b746bafd24a..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts +++ /dev/null @@ -1,11 +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 { i18n } from '@kbn/i18n'; - -export const txtGoToDashboard = i18n.translate('xpack.dashboard.drilldown.goToDashboard', { - defaultMessage: 'Go to Dashboard', -}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts deleted file mode 100644 index 9daa485bb6e6c..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts +++ /dev/null @@ -1,16 +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. - */ - -export { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; -export { - DashboardToDashboardDrilldown, - Params as DashboardToDashboardDrilldownParams, -} from './drilldown'; -export { - PlaceContext as DashboardToDashboardPlaceContext, - ActionContext as DashboardToDashboardActionContext, - Config as DashboardToDashboardConfig, -} from './types'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts deleted file mode 100644 index 398a259491e3e..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts +++ /dev/null @@ -1,22 +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 { - EmbeddableVisTriggerContext, - EmbeddableContext, -} from '../../../../../../../src/plugins/embeddable/public'; -import { UiActionsCollectConfigProps } from '../../../../../../../src/plugins/ui_actions/public'; - -export type PlaceContext = EmbeddableContext; -export type ActionContext = EmbeddableVisTriggerContext; - -export interface Config { - dashboardId?: string; - useCurrentFilters: boolean; - useCurrentDateRange: boolean; -} - -export type CollectConfigProps = UiActionsCollectConfigProps; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts deleted file mode 100644 index 7be8f1c65da12..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export * from './dashboard_drilldowns_services'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/index.ts deleted file mode 100644 index 8cc3e12906531..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export * from './drilldowns'; diff --git a/x-pack/plugins/dashboard_enhanced/server/config.ts b/x-pack/plugins/dashboard_enhanced/server/config.ts deleted file mode 100644 index b75c95d5f8832..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/server/config.ts +++ /dev/null @@ -1,23 +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 { schema, TypeOf } from '@kbn/config-schema'; -import { PluginConfigDescriptor } from '../../../../src/core/server'; - -export const configSchema = schema.object({ - drilldowns: schema.object({ - enabled: schema.boolean({ defaultValue: false }), - }), -}); - -export type ConfigSchema = TypeOf; - -export const config: PluginConfigDescriptor = { - schema: configSchema, - exposeToBrowser: { - drilldowns: true, - }, -}; diff --git a/x-pack/plugins/dashboard_enhanced/server/index.ts b/x-pack/plugins/dashboard_enhanced/server/index.ts deleted file mode 100644 index e361b9fb075ed..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/server/index.ts +++ /dev/null @@ -1,12 +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. - */ - -export { config } from './config'; - -export const plugin = () => ({ - setup() {}, - start() {}, -}); diff --git a/x-pack/plugins/drilldowns/kibana.json b/x-pack/plugins/drilldowns/kibana.json index 8372d87166364..b951c7dc1fc87 100644 --- a/x-pack/plugins/drilldowns/kibana.json +++ b/x-pack/plugins/drilldowns/kibana.json @@ -3,5 +3,8 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": ["uiActions", "embeddable", "advancedUiActions"] + "requiredPlugins": [ + "uiActions", + "embeddable" + ] } diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx new file mode 100644 index 0000000000000..4834cc8081374 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx @@ -0,0 +1,52 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { CoreStart } from 'src/core/public'; +import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; +import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; +import { FlyoutCreateDrilldown } from '../../components/flyout_create_drilldown'; + +export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; + +export interface FlyoutCreateDrilldownActionContext { + embeddable: IEmbeddable; +} + +export interface OpenFlyoutAddDrilldownParams { + overlays: () => Promise; +} + +export class FlyoutCreateDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; + public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; + public order = 100; + + constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} + + public getDisplayName() { + return i18n.translate('xpack.drilldowns.FlyoutCreateDrilldownAction.displayName', { + defaultMessage: 'Create drilldown', + }); + } + + public getIconType() { + return 'plusInCircle'; + } + + public async isCompatible({ embeddable }: FlyoutCreateDrilldownActionContext) { + return embeddable.getInput().viewMode === 'edit'; + } + + public async execute(context: FlyoutCreateDrilldownActionContext) { + const overlays = await this.params.overlays(); + const handle = overlays.openFlyout( + toMountPoint( handle.close()} />) + ); + } +} diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx new file mode 100644 index 0000000000000..f109da94fcaca --- /dev/null +++ b/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { CoreStart } from 'src/core/public'; +import { EuiNotificationBadge } from '@elastic/eui'; +import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; +import { + toMountPoint, + reactToUiComponent, +} from '../../../../../../src/plugins/kibana_react/public'; +import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; +import { FormCreateDrilldown } from '../../components/form_create_drilldown'; + +export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; + +export interface FlyoutEditDrilldownActionContext { + embeddable: IEmbeddable; +} + +export interface FlyoutEditDrilldownParams { + overlays: () => Promise; +} + +const displayName = i18n.translate('xpack.drilldowns.panel.openFlyoutEditDrilldown.displayName', { + defaultMessage: 'Manage drilldowns', +}); + +// mocked data +const drilldrownCount = 2; + +export class FlyoutEditDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; + public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; + public order = 100; + + constructor(protected readonly params: FlyoutEditDrilldownParams) {} + + public getDisplayName() { + return displayName; + } + + public getIconType() { + return 'list'; + } + + private ReactComp: React.FC<{ context: FlyoutEditDrilldownActionContext }> = () => { + return ( + <> + {displayName}{' '} + + {drilldrownCount} + + + ); + }; + + MenuItem = reactToUiComponent(this.ReactComp); + + public async isCompatible({ embeddable }: FlyoutEditDrilldownActionContext) { + return embeddable.getInput().viewMode === 'edit' && drilldrownCount > 0; + } + + public async execute({ embeddable }: FlyoutEditDrilldownActionContext) { + const overlays = await this.params.overlays(); + overlays.openFlyout(toMountPoint()); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts b/x-pack/plugins/drilldowns/public/actions/index.ts similarity index 100% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts rename to x-pack/plugins/drilldowns/public/actions/index.ts diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx deleted file mode 100644 index 16b4d3a25d9e5..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx +++ /dev/null @@ -1,43 +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 * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; -import { - dashboardFactory, - urlFactory, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; -import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; -import { mockDynamicActionManager } from './test_data'; - -const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ - advancedUiActions: { - getActionFactories() { - return [dashboardFactory, urlFactory]; - }, - } as any, - storage: new Storage(new StubBrowserStorage()), - notifications: { - toasts: { - addError: (...args: any[]) => { - alert(JSON.stringify(args)); - }, - addSuccess: (...args: any[]) => { - alert(JSON.stringify(args)); - }, - } as any, - }, -}); - -storiesOf('components/FlyoutManageDrilldowns', module).add('default', () => ( - {}}> - - -)); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx deleted file mode 100644 index 6749b41e81fc7..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx +++ /dev/null @@ -1,221 +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 from 'react'; -import { cleanup, fireEvent, render, wait } from '@testing-library/react/pure'; -import '@testing-library/jest-dom/extend-expect'; -import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; -import { - dashboardFactory, - urlFactory, -} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; -import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; -import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -import { mockDynamicActionManager } from './test_data'; -import { TEST_SUBJ_DRILLDOWN_ITEM } from '../list_manage_drilldowns'; -import { WELCOME_MESSAGE_TEST_SUBJ } from '../drilldown_hello_bar'; -import { coreMock } from '../../../../../../src/core/public/mocks'; -import { NotificationsStart } from 'kibana/public'; -import { toastDrilldownsCRUDError } from './i18n'; - -const storage = new Storage(new StubBrowserStorage()); -const notifications = coreMock.createStart().notifications; -const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ - advancedUiActions: { - getActionFactories() { - return [dashboardFactory, urlFactory]; - }, - } as any, - storage, - notifications, -}); - -// https://github.com/elastic/kibana/issues/59469 -afterEach(cleanup); - -beforeEach(() => { - storage.clear(); - (notifications.toasts as jest.Mocked).addSuccess.mockClear(); - (notifications.toasts as jest.Mocked).addError.mockClear(); -}); - -test('Allows to manage drilldowns', async () => { - const screen = render( - - ); - - // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); - - // no drilldowns in the list - expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0); - - fireEvent.click(screen.getByText(/Create new/i)); - - let [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); - expect(createHeading).toBeVisible(); - expect(screen.getByLabelText(/Back/i)).toBeVisible(); - - expect(createButton).toBeDisabled(); - - // input drilldown name - const name = 'Test name'; - fireEvent.change(screen.getByLabelText(/name/i), { - target: { value: name }, - }); - - // select URL one - fireEvent.click(screen.getByText(/Go to URL/i)); - - // Input url - const URL = 'https://elastic.co'; - fireEvent.change(screen.getByLabelText(/url/i), { - target: { value: URL }, - }); - - [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); - - expect(createButton).toBeEnabled(); - fireEvent.click(createButton); - - expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); - - await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(1)); - expect(screen.getByText(name)).toBeVisible(); - const editButton = screen.getByText(/edit/i); - fireEvent.click(editButton); - - expect(screen.getByText(/Edit Drilldown/i)).toBeVisible(); - // check that wizard is prefilled with current drilldown values - expect(screen.getByLabelText(/name/i)).toHaveValue(name); - expect(screen.getByLabelText(/url/i)).toHaveValue(URL); - - // input new drilldown name - const newName = 'New drilldown name'; - fireEvent.change(screen.getByLabelText(/name/i), { - target: { value: newName }, - }); - fireEvent.click(screen.getByText(/save/i)); - - expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); - await wait(() => screen.getByText(newName)); - - // delete drilldown from edit view - fireEvent.click(screen.getByText(/edit/i)); - fireEvent.click(screen.getByText(/delete/i)); - - expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); - await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); -}); - -test('Can delete multiple drilldowns', async () => { - const screen = render( - - ); - // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); - - const createDrilldown = async () => { - const oldCount = screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM).length; - fireEvent.click(screen.getByText(/Create new/i)); - fireEvent.change(screen.getByLabelText(/name/i), { - target: { value: 'test' }, - }); - fireEvent.click(screen.getByText(/Go to URL/i)); - fireEvent.change(screen.getByLabelText(/url/i), { - target: { value: 'https://elastic.co' }, - }); - fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); - await wait(() => - expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(oldCount + 1) - ); - }; - - await createDrilldown(); - await createDrilldown(); - await createDrilldown(); - - const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); - expect(checkboxes).toHaveLength(3); - checkboxes.forEach(checkbox => fireEvent.click(checkbox)); - expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); - fireEvent.click(screen.getByText(/Delete \(3\)/i)); - - await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); -}); - -test('Create only mode', async () => { - const onClose = jest.fn(); - const screen = render( - - ); - // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); - fireEvent.change(screen.getByLabelText(/name/i), { - target: { value: 'test' }, - }); - fireEvent.click(screen.getByText(/Go to URL/i)); - fireEvent.change(screen.getByLabelText(/url/i), { - target: { value: 'https://elastic.co' }, - }); - fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); - - await wait(() => expect(notifications.toasts.addSuccess).toBeCalled()); - expect(onClose).toBeCalled(); - expect(await mockDynamicActionManager.state.get().events.length).toBe(1); -}); - -test.todo("Error when can't fetch drilldown list"); - -test("Error when can't save drilldown changes", async () => { - const error = new Error('Oops'); - jest.spyOn(mockDynamicActionManager, 'createEvent').mockImplementationOnce(async () => { - throw error; - }); - const screen = render( - - ); - // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); - fireEvent.click(screen.getByText(/Create new/i)); - fireEvent.change(screen.getByLabelText(/name/i), { - target: { value: 'test' }, - }); - fireEvent.click(screen.getByText(/Go to URL/i)); - fireEvent.change(screen.getByLabelText(/url/i), { - target: { value: 'https://elastic.co' }, - }); - fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); - await wait(() => - expect(notifications.toasts.addError).toBeCalledWith(error, { title: toastDrilldownsCRUDError }) - ); -}); - -test('Should show drilldown welcome message. Should be able to dismiss it', async () => { - let screen = render( - - ); - - // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); - - expect(screen.getByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeVisible(); - fireEvent.click(screen.getByText(/hide/i)); - expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); - cleanup(); - - screen = render( - - ); - // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); - expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); -}); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx deleted file mode 100644 index f22ccc2f26f02..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx +++ /dev/null @@ -1,332 +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, { useEffect, useState } from 'react'; -import useMountedState from 'react-use/lib/useMountedState'; -import { - AdvancedUiActionsActionFactory as ActionFactory, - AdvancedUiActionsStart, -} from '../../../../advanced_ui_actions/public'; -import { NotificationsStart } from '../../../../../../src/core/public'; -import { DrilldownWizardConfig, FlyoutDrilldownWizard } from '../flyout_drilldown_wizard'; -import { FlyoutListManageDrilldowns } from '../flyout_list_manage_drilldowns'; -import { IStorageWrapper } from '../../../../../../src/plugins/kibana_utils/public'; -import { - DynamicActionManager, - UiActionsSerializedEvent, - UiActionsSerializedAction, - VALUE_CLICK_TRIGGER, - SELECT_RANGE_TRIGGER, - TriggerContextMapping, -} from '../../../../../../src/plugins/ui_actions/public'; -import { useContainerState } from '../../../../../../src/plugins/kibana_utils/common'; -import { DrilldownListItem } from '../list_manage_drilldowns'; -import { - toastDrilldownCreated, - toastDrilldownDeleted, - toastDrilldownEdited, - toastDrilldownsCRUDError, - toastDrilldownsDeleted, -} from './i18n'; -import { DrilldownFactoryContext } from '../../types'; - -interface ConnectedFlyoutManageDrilldownsProps { - placeContext: Context; - dynamicActionManager: DynamicActionManager; - viewMode?: 'create' | 'manage'; - onClose?: () => void; -} - -/** - * Represent current state (route) of FlyoutManageDrilldowns - */ -enum Routes { - Manage = 'manage', - Create = 'create', - Edit = 'edit', -} - -export function createFlyoutManageDrilldowns({ - advancedUiActions, - storage, - notifications, -}: { - advancedUiActions: AdvancedUiActionsStart; - storage: IStorageWrapper; - notifications: NotificationsStart; -}) { - // fine to assume this is static, - // because all action factories should be registered in setup phase - const allActionFactories = advancedUiActions.getActionFactories(); - const allActionFactoriesById = allActionFactories.reduce((acc, next) => { - acc[next.id] = next; - return acc; - }, {} as Record); - - return (props: ConnectedFlyoutManageDrilldownsProps) => { - const isCreateOnly = props.viewMode === 'create'; - - const selectedTriggers: Array = React.useMemo( - () => [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER], - [] - ); - - const factoryContext: DrilldownFactoryContext = React.useMemo( - () => ({ - placeContext: props.placeContext, - triggers: selectedTriggers, - }), - [props.placeContext, selectedTriggers] - ); - - const actionFactories = useCompatibleActionFactoriesForCurrentContext( - allActionFactories, - factoryContext - ); - - const [route, setRoute] = useState( - () => (isCreateOnly ? Routes.Create : Routes.Manage) // initial state is different depending on `viewMode` - ); - const [currentEditId, setCurrentEditId] = useState(null); - - const [shouldShowWelcomeMessage, onHideWelcomeMessage] = useWelcomeMessage(storage); - - const { - drilldowns, - createDrilldown, - editDrilldown, - deleteDrilldown, - } = useDrilldownsStateManager(props.dynamicActionManager, notifications); - - /** - * isCompatible promise is not yet resolved. - * Skip rendering until it is resolved - */ - if (!actionFactories) return null; - /** - * Drilldowns are not fetched yet or error happened during fetching - * In case of error user is notified with toast - */ - if (!drilldowns) return null; - - /** - * Needed for edit mode to prefill wizard fields with data from current edited drilldown - */ - function resolveInitialDrilldownWizardConfig(): DrilldownWizardConfig | undefined { - if (route !== Routes.Edit) return undefined; - if (!currentEditId) return undefined; - const drilldownToEdit = drilldowns?.find(d => d.eventId === currentEditId); - if (!drilldownToEdit) return undefined; - - return { - actionFactory: allActionFactoriesById[drilldownToEdit.action.factoryId], - actionConfig: drilldownToEdit.action.config as object, // TODO: config is unknown, but we know it always extends object - name: drilldownToEdit.action.name, - }; - } - - /** - * Maps drilldown to list item view model - */ - function mapToDrilldownToDrilldownListItem( - drilldown: UiActionsSerializedEvent - ): DrilldownListItem { - const actionFactory = allActionFactoriesById[drilldown.action.factoryId]; - return { - id: drilldown.eventId, - drilldownName: drilldown.action.name, - actionName: actionFactory?.getDisplayName(factoryContext) ?? drilldown.action.factoryId, - icon: actionFactory?.getIconType(factoryContext), - }; - } - - switch (route) { - case Routes.Create: - case Routes.Edit: - return ( - setRoute(Routes.Manage)} - onSubmit={({ actionConfig, actionFactory, name }) => { - if (route === Routes.Create) { - createDrilldown( - { - name, - config: actionConfig, - factoryId: actionFactory.id, - }, - selectedTriggers - ); - } else { - editDrilldown( - currentEditId!, - { - name, - config: actionConfig, - factoryId: actionFactory.id, - }, - selectedTriggers - ); - } - - if (isCreateOnly) { - if (props.onClose) { - props.onClose(); - } - } else { - setRoute(Routes.Manage); - } - - setCurrentEditId(null); - }} - onDelete={() => { - deleteDrilldown(currentEditId!); - setRoute(Routes.Manage); - setCurrentEditId(null); - }} - actionFactoryContext={factoryContext} - initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()} - /> - ); - - case Routes.Manage: - default: - return ( - { - setCurrentEditId(null); - deleteDrilldown(ids); - }} - onEdit={id => { - setCurrentEditId(id); - setRoute(Routes.Edit); - }} - onCreate={() => { - setCurrentEditId(null); - setRoute(Routes.Create); - }} - onClose={props.onClose} - /> - ); - } - }; -} - -function useCompatibleActionFactoriesForCurrentContext( - actionFactories: Array>, - context: Context -) { - const [compatibleActionFactories, setCompatibleActionFactories] = useState< - Array> - >(); - useEffect(() => { - let canceled = false; - async function updateCompatibleFactoriesForContext() { - const compatibility = await Promise.all( - actionFactories.map(factory => factory.isCompatible(context)) - ); - if (canceled) return; - setCompatibleActionFactories(actionFactories.filter((_, i) => compatibility[i])); - } - updateCompatibleFactoriesForContext(); - return () => { - canceled = true; - }; - }, [context, actionFactories]); - - return compatibleActionFactories; -} - -function useWelcomeMessage(storage: IStorageWrapper): [boolean, () => void] { - const key = `drilldowns:hidWelcomeMessage`; - const [hidWelcomeMessage, setHidWelcomeMessage] = useState(storage.get(key) ?? false); - - return [ - !hidWelcomeMessage, - () => { - if (hidWelcomeMessage) return; - setHidWelcomeMessage(true); - storage.set(key, true); - }, - ]; -} - -function useDrilldownsStateManager( - actionManager: DynamicActionManager, - notifications: NotificationsStart -) { - const { events: drilldowns } = useContainerState(actionManager.state); - const [isLoading, setIsLoading] = useState(false); - const isMounted = useMountedState(); - - async function run(op: () => Promise) { - setIsLoading(true); - try { - await op(); - } catch (e) { - notifications.toasts.addError(e, { - title: toastDrilldownsCRUDError, - }); - if (!isMounted) return; - setIsLoading(false); - return; - } - } - - async function createDrilldown( - action: UiActionsSerializedAction, - selectedTriggers: Array - ) { - await run(async () => { - await actionManager.createEvent(action, selectedTriggers); - notifications.toasts.addSuccess({ - title: toastDrilldownCreated.title, - text: toastDrilldownCreated.text(action.name), - }); - }); - } - - async function editDrilldown( - drilldownId: string, - action: UiActionsSerializedAction, - selectedTriggers: Array - ) { - await run(async () => { - await actionManager.updateEvent(drilldownId, action, selectedTriggers); - notifications.toasts.addSuccess({ - title: toastDrilldownEdited.title, - text: toastDrilldownEdited.text(action.name), - }); - }); - } - - async function deleteDrilldown(drilldownIds: string | string[]) { - await run(async () => { - drilldownIds = Array.isArray(drilldownIds) ? drilldownIds : [drilldownIds]; - await actionManager.deleteEvents(drilldownIds); - notifications.toasts.addSuccess( - drilldownIds.length === 1 - ? { - title: toastDrilldownDeleted.title, - text: toastDrilldownDeleted.text, - } - : { - title: toastDrilldownsDeleted.title, - text: toastDrilldownsDeleted.text(drilldownIds.length), - } - ); - }); - } - - return { drilldowns, isLoading, createDrilldown, editDrilldown, deleteDrilldown }; -} diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts deleted file mode 100644 index 70f4d735e2a74..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts +++ /dev/null @@ -1,88 +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 { i18n } from '@kbn/i18n'; - -export const toastDrilldownCreated = { - title: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', - { - defaultMessage: 'Drilldown created', - } - ), - text: (drilldownName: string) => - i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', { - defaultMessage: 'You created "{drilldownName}"', - values: { - drilldownName, - }, - }), -}; - -export const toastDrilldownEdited = { - title: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', - { - defaultMessage: 'Drilldown edited', - } - ), - text: (drilldownName: string) => - i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', { - defaultMessage: 'You edited "{drilldownName}"', - values: { - drilldownName, - }, - }), -}; - -export const toastDrilldownDeleted = { - title: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', - { - defaultMessage: 'Drilldown deleted', - } - ), - text: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', - { - defaultMessage: 'You deleted a drilldown', - } - ), -}; - -export const toastDrilldownsDeleted = { - title: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', - { - defaultMessage: 'Drilldowns deleted', - } - ), - text: (n: number) => - i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', - { - defaultMessage: 'You deleted {n} drilldowns', - values: { - n, - }, - } - ), -}; - -export const toastDrilldownsCRUDError = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', - { - defaultMessage: 'Error saving drilldown', - description: 'Title for generic error toast when persisting drilldown updates failed', - } -); - -export const toastDrilldownsFetchError = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsFetchErrorTitle', - { - defaultMessage: 'Error fetching drilldowns', - } -); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts deleted file mode 100644 index f084a3e563c23..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export * from './connected_flyout_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts deleted file mode 100644 index b8deaa8b842bc..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts +++ /dev/null @@ -1,89 +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 uuid from 'uuid'; -import { - DynamicActionManager, - DynamicActionManagerState, - UiActionsSerializedAction, - TriggerContextMapping, -} from '../../../../../../src/plugins/ui_actions/public'; -import { createStateContainer } from '../../../../../../src/plugins/kibana_utils/common'; - -class MockDynamicActionManager implements PublicMethodsOf { - public readonly state = createStateContainer({ - isFetchingEvents: false, - fetchCount: 0, - events: [], - }); - - async count() { - return this.state.get().events.length; - } - - async list() { - return this.state.get().events; - } - - async createEvent( - action: UiActionsSerializedAction, - triggers: Array - ) { - const event = { - action, - triggers, - eventId: uuid(), - }; - const state = this.state.get(); - this.state.set({ - ...state, - events: [...state.events, event], - }); - } - - async deleteEvents(eventIds: string[]) { - const state = this.state.get(); - let events = state.events; - - eventIds.forEach(id => { - events = events.filter(e => e.eventId !== id); - }); - - this.state.set({ - ...state, - events, - }); - } - - async updateEvent( - eventId: string, - action: UiActionsSerializedAction, - triggers: Array - ) { - const state = this.state.get(); - const events = state.events; - const idx = events.findIndex(e => e.eventId === eventId); - const event = { - eventId, - action, - triggers, - }; - - this.state.set({ - ...state, - events: [...events.slice(0, idx), event, ...events.slice(idx + 1)], - }); - } - - async deleteEvent() { - throw new Error('not implemented'); - } - - async start() {} - async stop() {} -} - -export const mockDynamicActionManager = (new MockDynamicActionManager() as unknown) as DynamicActionManager; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx index c4a4630397f1c..7a9e19342f27c 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx @@ -8,16 +8,6 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { DrilldownHelloBar } from '.'; -const Demo = () => { - const [show, setShow] = React.useState(true); - return show ? ( - { - setShow(false); - }} - /> - ) : null; -}; - -storiesOf('components/DrilldownHelloBar', module).add('default', () => ); +storiesOf('components/DrilldownHelloBar', module).add('default', () => { + return ; +}); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx index 8c6739a8ad6c8..1ef714f7b86e2 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx @@ -5,58 +5,22 @@ */ import React from 'react'; -import { - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiTextColor, - EuiText, - EuiLink, - EuiSpacer, - EuiButtonEmpty, - EuiIcon, -} from '@elastic/eui'; -import { txtHideHelpButtonLabel, txtHelpText, txtViewDocsLinkLabel } from './i18n'; export interface DrilldownHelloBarProps { docsLink?: string; - onHideClick?: () => void; } -export const WELCOME_MESSAGE_TEST_SUBJ = 'drilldowns-welcome-message-test-subj'; - -export const DrilldownHelloBar: React.FC = ({ - docsLink, - onHideClick = () => {}, -}) => { +/** + * @todo https://github.com/elastic/kibana/issues/55311 + */ +export const DrilldownHelloBar: React.FC = ({ docsLink }) => { return ( - - -
- -
-
- - - {txtHelpText} - - {docsLink && ( - <> - - {txtViewDocsLinkLabel} - - )} - - - - {txtHideHelpButtonLabel} - - - - } - /> +
); }; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts deleted file mode 100644 index 63dc95dabc0fb..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts +++ /dev/null @@ -1,29 +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 { i18n } from '@kbn/i18n'; - -export const txtHelpText = i18n.translate( - 'xpack.drilldowns.components.DrilldownHelloBar.helpText', - { - defaultMessage: - 'Drilldowns provide the ability to define a new behavior when interacting with a panel. You can add multiple options or simply override the default filtering behavior.', - } -); - -export const txtViewDocsLinkLabel = i18n.translate( - 'xpack.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel', - { - defaultMessage: 'View docs', - } -); - -export const txtHideHelpButtonLabel = i18n.translate( - 'xpack.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel', - { - defaultMessage: 'Hide', - } -); diff --git a/x-pack/plugins/dashboard_enhanced/scripts/storybook.js b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx similarity index 53% rename from x-pack/plugins/dashboard_enhanced/scripts/storybook.js rename to x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx index f2cbe4135f4cb..5627a5d6f4522 100644 --- a/x-pack/plugins/dashboard_enhanced/scripts/storybook.js +++ b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { join } from 'path'; +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { DrilldownPicker } from '.'; -// eslint-disable-next-line -require('@kbn/storybook').runStorybookCli({ - name: 'dashboard_enhanced', - storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], +storiesOf('components/DrilldownPicker', module).add('default', () => { + return ; }); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx new file mode 100644 index 0000000000000..3748fc666c81c --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx @@ -0,0 +1,21 @@ +/* + * 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'; + +// eslint-disable-next-line +export interface DrilldownPickerProps {} + +export const DrilldownPicker: React.FC = () => { + return ( + + ); +}; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/index.ts b/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx similarity index 87% rename from x-pack/plugins/advanced_ui_actions/public/components/index.ts rename to x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx index 236b1a6ec4611..3be289fe6d46e 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/index.ts +++ b/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './action_wizard'; +export * from './drilldown_picker'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx new file mode 100644 index 0000000000000..4f024b7d9cd6a --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FlyoutCreateDrilldown } from '.'; + +storiesOf('components/FlyoutCreateDrilldown', module) + .add('default', () => { + return ; + }) + .add('open in flyout', () => { + return ( + + + + ); + }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx new file mode 100644 index 0000000000000..b45ac9197c7e0 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -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 React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { FormCreateDrilldown } from '../form_create_drilldown'; +import { FlyoutFrame } from '../flyout_frame'; +import { txtCreateDrilldown } from './i18n'; +import { FlyoutCreateDrilldownActionContext } from '../../actions'; + +export interface FlyoutCreateDrilldownProps { + context: FlyoutCreateDrilldownActionContext; + onClose?: () => void; +} + +export const FlyoutCreateDrilldown: React.FC = ({ + context, + onClose, +}) => { + const footer = ( + {}} fill> + {txtCreateDrilldown} + + ); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts similarity index 64% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts rename to x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts index 4e2e5eb7092e4..ceabc6d3a9aa5 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts @@ -6,9 +6,9 @@ import { i18n } from '@kbn/i18n'; -export const txtDisplayName = i18n.translate( - 'xpack.dashboard.panel.openFlyoutEditDrilldown.displayName', +export const txtCreateDrilldown = i18n.translate( + 'xpack.drilldowns.components.FlyoutCreateDrilldown.CreateDrilldown', { - defaultMessage: 'Manage drilldowns', + defaultMessage: 'Create drilldown', } ); diff --git a/x-pack/plugins/advanced_ui_actions/public/services/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts similarity index 84% rename from x-pack/plugins/advanced_ui_actions/public/services/index.ts rename to x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts index 0f8b4c8d8f409..ce235043b4ef6 100644 --- a/x-pack/plugins/advanced_ui_actions/public/services/index.ts +++ b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './action_factory_service'; +export * from './flyout_create_drilldown'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx deleted file mode 100644 index 152cd393b9d3e..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx +++ /dev/null @@ -1,70 +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. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FlyoutDrilldownWizard } from '.'; -import { - dashboardFactory, - urlFactory, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; - -storiesOf('components/FlyoutDrilldownWizard', module) - .add('default', () => { - return ; - }) - .add('open in flyout - create', () => { - return ( - {}}> - {}} - drilldownActionFactories={[urlFactory, dashboardFactory]} - /> - - ); - }) - .add('open in flyout - edit', () => { - return ( - {}}> - {}} - drilldownActionFactories={[urlFactory, dashboardFactory]} - initialDrilldownWizardConfig={{ - name: 'My fancy drilldown', - actionFactory: urlFactory as any, - actionConfig: { - url: 'https://elastic.co', - openInNewTab: true, - }, - }} - mode={'edit'} - /> - - ); - }) - .add('open in flyout - edit, just 1 action type', () => { - return ( - {}}> - {}} - drilldownActionFactories={[dashboardFactory]} - initialDrilldownWizardConfig={{ - name: 'My fancy drilldown', - actionFactory: urlFactory as any, - actionConfig: { - url: 'https://elastic.co', - openInNewTab: true, - }, - }} - mode={'edit'} - /> - - ); - }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx deleted file mode 100644 index faa965a98a4bb..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx +++ /dev/null @@ -1,139 +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, { useState } from 'react'; -import { EuiButton, EuiSpacer } from '@elastic/eui'; -import { FormDrilldownWizard } from '../form_drilldown_wizard'; -import { FlyoutFrame } from '../flyout_frame'; -import { - txtCreateDrilldownButtonLabel, - txtCreateDrilldownTitle, - txtDeleteDrilldownButtonLabel, - txtEditDrilldownButtonLabel, - txtEditDrilldownTitle, -} from './i18n'; -import { DrilldownHelloBar } from '../drilldown_hello_bar'; -import { AdvancedUiActionsActionFactory as ActionFactory } from '../../../../advanced_ui_actions/public'; - -export interface DrilldownWizardConfig { - name: string; - actionFactory?: ActionFactory; - actionConfig?: ActionConfig; -} - -export interface FlyoutDrilldownWizardProps { - drilldownActionFactories: Array>; - - onSubmit?: (drilldownWizardConfig: Required) => void; - onDelete?: () => void; - onClose?: () => void; - onBack?: () => void; - - mode?: 'create' | 'edit'; - initialDrilldownWizardConfig?: DrilldownWizardConfig; - - showWelcomeMessage?: boolean; - onWelcomeHideClick?: () => void; - - actionFactoryContext?: object; -} - -export function FlyoutDrilldownWizard({ - onClose, - onBack, - onSubmit = () => {}, - initialDrilldownWizardConfig, - mode = 'create', - onDelete = () => {}, - showWelcomeMessage = true, - onWelcomeHideClick, - drilldownActionFactories, - actionFactoryContext, -}: FlyoutDrilldownWizardProps) { - const [wizardConfig, setWizardConfig] = useState( - () => - initialDrilldownWizardConfig ?? { - name: '', - } - ); - - const isActionValid = ( - config: DrilldownWizardConfig - ): config is Required => { - if (!wizardConfig.name) return false; - if (!wizardConfig.actionFactory) return false; - if (!wizardConfig.actionConfig) return false; - - return wizardConfig.actionFactory.isConfigValid(wizardConfig.actionConfig); - }; - - const footer = ( - { - if (isActionValid(wizardConfig)) { - onSubmit(wizardConfig); - } - }} - fill - isDisabled={!isActionValid(wizardConfig)} - > - {mode === 'edit' ? txtEditDrilldownButtonLabel : txtCreateDrilldownButtonLabel} - - ); - - return ( - } - > - { - setWizardConfig({ - ...wizardConfig, - name: newName, - }); - }} - actionConfig={wizardConfig.actionConfig} - onActionConfigChange={newActionConfig => { - setWizardConfig({ - ...wizardConfig, - actionConfig: newActionConfig, - }); - }} - currentActionFactory={wizardConfig.actionFactory} - onActionFactoryChange={actionFactory => { - if (!actionFactory) { - setWizardConfig({ - ...wizardConfig, - actionFactory: undefined, - actionConfig: undefined, - }); - } else { - setWizardConfig({ - ...wizardConfig, - actionFactory, - actionConfig: actionFactory.createConfig(), - }); - } - }} - actionFactories={drilldownActionFactories} - actionFactoryContext={actionFactoryContext!} - /> - {mode === 'edit' && ( - <> - - - {txtDeleteDrilldownButtonLabel} - - - )} - - ); -} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts deleted file mode 100644 index a4a2754a444ab..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts +++ /dev/null @@ -1,42 +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 { i18n } from '@kbn/i18n'; - -export const txtCreateDrilldownTitle = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle', - { - defaultMessage: 'Create Drilldown', - } -); - -export const txtEditDrilldownTitle = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle', - { - defaultMessage: 'Edit Drilldown', - } -); - -export const txtCreateDrilldownButtonLabel = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel', - { - defaultMessage: 'Create drilldown', - } -); - -export const txtEditDrilldownButtonLabel = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel', - { - defaultMessage: 'Save', - } -); - -export const txtDeleteDrilldownButtonLabel = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel', - { - defaultMessage: 'Delete drilldown', - } -); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts deleted file mode 100644 index 96ed23bf112c9..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export * from './flyout_drilldown_wizard'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx index cb223db556f56..2715637f6392f 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx @@ -21,13 +21,6 @@ storiesOf('components/FlyoutFrame', module) .add('with onClose', () => { return console.log('onClose')}>test; }) - .add('with onBack', () => { - return ( - console.log('onClose')} title={'Title'}> - test - - ); - }) .add('custom footer', () => { return click me!}>test; }) diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx index 0a3989487745f..b5fb52fcf5c18 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx @@ -6,11 +6,9 @@ import React from 'react'; import { render } from 'react-dom'; -import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; +import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; import { FlyoutFrame } from '.'; -afterEach(cleanup); - describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx index b55cbd88d0dc0..2945cfd739482 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx @@ -13,16 +13,13 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, - EuiButtonIcon, } from '@elastic/eui'; -import { txtClose, txtBack } from './i18n'; +import { txtClose } from './i18n'; export interface FlyoutFrameProps { title?: React.ReactNode; footer?: React.ReactNode; - banner?: React.ReactNode; onClose?: () => void; - onBack?: () => void; } /** @@ -33,31 +30,11 @@ export const FlyoutFrame: React.FC = ({ footer, onClose, children, - onBack, - banner, }) => { - const headerFragment = (title || onBack) && ( + const headerFragment = title && ( - - {onBack && ( - -
- -
-
- )} - {title && ( - -

{title}

-
- )} -
+

{title}

); @@ -87,7 +64,7 @@ export const FlyoutFrame: React.FC = ({ return ( <> {headerFragment} - {children} + {children} {footerFragment} ); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts index 23af89ebf9bc7..257d7d36dbee1 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts @@ -6,10 +6,6 @@ import { i18n } from '@kbn/i18n'; -export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.CloseButtonLabel', { +export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.Close', { defaultMessage: 'Close', }); - -export const txtBack = i18n.translate('xpack.drilldowns.components.FlyoutFrame.BackButtonLabel', { - defaultMessage: 'Back', -}); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx deleted file mode 100644 index 0529f0451b16a..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx +++ /dev/null @@ -1,22 +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 * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FlyoutListManageDrilldowns } from './flyout_list_manage_drilldowns'; - -storiesOf('components/FlyoutListManageDrilldowns', module).add('default', () => ( - {}}> - - -)); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx deleted file mode 100644 index a44a7ccccb4dc..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx +++ /dev/null @@ -1,46 +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 from 'react'; -import { FlyoutFrame } from '../flyout_frame'; -import { DrilldownListItem, ListManageDrilldowns } from '../list_manage_drilldowns'; -import { txtManageDrilldowns } from './i18n'; -import { DrilldownHelloBar } from '../drilldown_hello_bar'; - -export interface FlyoutListManageDrilldownsProps { - drilldowns: DrilldownListItem[]; - onClose?: () => void; - onCreate?: () => void; - onEdit?: (drilldownId: string) => void; - onDelete?: (drilldownIds: string[]) => void; - showWelcomeMessage?: boolean; - onWelcomeHideClick?: () => void; -} - -export function FlyoutListManageDrilldowns({ - drilldowns, - onClose = () => {}, - onCreate, - onDelete, - onEdit, - showWelcomeMessage = true, - onWelcomeHideClick, -}: FlyoutListManageDrilldownsProps) { - return ( - } - > - - - ); -} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts deleted file mode 100644 index 0dd4e37d4dddd..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts +++ /dev/null @@ -1,14 +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 { i18n } from '@kbn/i18n'; - -export const txtManageDrilldowns = i18n.translate( - 'xpack.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle', - { - defaultMessage: 'Manage Drilldowns', - } -); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts deleted file mode 100644 index f8c9d224fb292..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export * from './flyout_list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx new file mode 100644 index 0000000000000..e7e1d67473e8c --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx @@ -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. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FormCreateDrilldown } from '.'; + +const DemoEditName: React.FC = () => { + const [name, setName] = React.useState(''); + + return ; +}; + +storiesOf('components/FormCreateDrilldown', module) + .add('default', () => { + return ; + }) + .add('[name=foobar]', () => { + return ; + }) + .add('can edit name', () => ) + .add('open in flyout', () => { + return ( + + + + ); + }); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx similarity index 70% rename from x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx rename to x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx index 4560773cc8a6d..6691966e47e64 100644 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx @@ -6,23 +6,21 @@ import React from 'react'; import { render } from 'react-dom'; -import { FormDrilldownWizard } from './form_drilldown_wizard'; -import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; +import { FormCreateDrilldown } from '.'; +import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; import { txtNameOfDrilldown } from './i18n'; -afterEach(cleanup); - -describe('', () => { +describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); - render( {}} actionFactoryContext={{}} />, div); + render( {}} />, div); }); describe('[name=]', () => { test('if name not provided, uses to empty string', () => { const div = document.createElement('div'); - render(, div); + render(, div); const input = div.querySelector( '[data-test-subj="dynamicActionNameInput"]' @@ -31,10 +29,10 @@ describe('', () => { expect(input?.value).toBe(''); }); - test('can set initial name input field value', () => { + test('can set name input field value', () => { const div = document.createElement('div'); - render(, div); + render(, div); const input = div.querySelector( '[data-test-subj="dynamicActionNameInput"]' @@ -42,7 +40,7 @@ describe('', () => { expect(input?.value).toBe('foo'); - render(, div); + render(, div); expect(input?.value).toBe('bar'); }); @@ -50,7 +48,7 @@ describe('', () => { test('fires onNameChange callback on name change', () => { const onNameChange = jest.fn(); const utils = renderTestingLibrary( - + ); const input = utils.getByLabelText(txtNameOfDrilldown); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx new file mode 100644 index 0000000000000..4422de604092b --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx @@ -0,0 +1,52 @@ +/* + * 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 { EuiForm, EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { DrilldownHelloBar } from '../drilldown_hello_bar'; +import { txtNameOfDrilldown, txtUntitledDrilldown, txtDrilldownAction } from './i18n'; +import { DrilldownPicker } from '../drilldown_picker'; + +const noop = () => {}; + +export interface FormCreateDrilldownProps { + name?: string; + onNameChange?: (name: string) => void; +} + +export const FormCreateDrilldown: React.FC = ({ + name = '', + onNameChange = noop, +}) => { + const nameFragment = ( + + onNameChange(event.target.value)} + data-test-subj="dynamicActionNameInput" + /> + + ); + + const triggerPicker =
Trigger Picker will be here
; + const actionPicker = ( + + + + ); + + return ( + <> + + {nameFragment} + {triggerPicker} + {actionPicker} + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts similarity index 89% rename from x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts rename to x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts index e9b19ab0afa97..4c0e287935edd 100644 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const txtNameOfDrilldown = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown', { - defaultMessage: 'Name', + defaultMessage: 'Name of drilldown', } ); @@ -23,6 +23,6 @@ export const txtUntitledDrilldown = i18n.translate( export const txtDrilldownAction = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.drilldownAction', { - defaultMessage: 'Action', + defaultMessage: 'Drilldown action', } ); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx similarity index 85% rename from x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx rename to x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx index 4aea824de00d7..c2c5a7e435b39 100644 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './form_drilldown_wizard'; +export * from './form_create_drilldown'; diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx deleted file mode 100644 index 2fc35eb6b5298..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx +++ /dev/null @@ -1,29 +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 * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { FormDrilldownWizard } from '.'; - -const DemoEditName: React.FC = () => { - const [name, setName] = React.useState(''); - - return ( - <> - {' '} -
name: {name}
- - ); -}; - -storiesOf('components/FormDrilldownWizard', module) - .add('default', () => { - return ; - }) - .add('[name=foobar]', () => { - return ; - }) - .add('can edit name', () => ); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx deleted file mode 100644 index bdafaaf07873c..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx +++ /dev/null @@ -1,79 +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 from 'react'; -import { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer } from '@elastic/eui'; -import { txtDrilldownAction, txtNameOfDrilldown, txtUntitledDrilldown } from './i18n'; -import { - AdvancedUiActionsActionFactory as ActionFactory, - ActionWizard, -} from '../../../../advanced_ui_actions/public'; - -const noopFn = () => {}; - -export interface FormDrilldownWizardProps { - name?: string; - onNameChange?: (name: string) => void; - - currentActionFactory?: ActionFactory; - onActionFactoryChange?: (actionFactory: ActionFactory | null) => void; - actionFactoryContext: object; - - actionConfig?: object; - onActionConfigChange?: (config: object) => void; - - actionFactories?: ActionFactory[]; -} - -export const FormDrilldownWizard: React.FC = ({ - name = '', - actionConfig, - currentActionFactory, - onNameChange = noopFn, - onActionConfigChange = noopFn, - onActionFactoryChange = noopFn, - actionFactories = [], - actionFactoryContext, -}) => { - const nameFragment = ( - - onNameChange(event.target.value)} - data-test-subj="dynamicActionNameInput" - /> - - ); - - const actionWizard = ( - 1 ? txtDrilldownAction : undefined} - fullWidth={true} - > - onActionFactoryChange(actionFactory)} - onConfigChange={config => onActionConfigChange(config)} - context={actionFactoryContext} - /> - - ); - - return ( - <> - - {nameFragment} - - {actionWizard} - - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts deleted file mode 100644 index fbc7c9dcfb4a1..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts +++ /dev/null @@ -1,36 +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 { i18n } from '@kbn/i18n'; - -export const txtCreateDrilldown = i18n.translate( - 'xpack.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel', - { - defaultMessage: 'Create new', - } -); - -export const txtEditDrilldown = i18n.translate( - 'xpack.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel', - { - defaultMessage: 'Edit', - } -); - -export const txtDeleteDrilldowns = (count: number) => - i18n.translate('xpack.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel', { - defaultMessage: 'Delete ({count})', - values: { - count, - }, - }); - -export const txtSelectDrilldown = i18n.translate( - 'xpack.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel', - { - defaultMessage: 'Select this drilldown', - } -); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx deleted file mode 100644 index 82b6ce27af6d4..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx +++ /dev/null @@ -1,7 +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. - */ - -export * from './list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx deleted file mode 100644 index eafe50bab2016..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx +++ /dev/null @@ -1,19 +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 * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { ListManageDrilldowns } from './list_manage_drilldowns'; - -storiesOf('components/ListManageDrilldowns', module).add('default', () => ( - -)); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx deleted file mode 100644 index 4a4d67b08b1d3..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx +++ /dev/null @@ -1,70 +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 from 'react'; -import { cleanup, fireEvent, render } from '@testing-library/react/pure'; -import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global -import { - DrilldownListItem, - ListManageDrilldowns, - TEST_SUBJ_DRILLDOWN_ITEM, -} from './list_manage_drilldowns'; - -// TODO: for some reason global cleanup from RTL doesn't work -// afterEach is not available for it globally during setup -afterEach(cleanup); - -const drilldowns: DrilldownListItem[] = [ - { id: '1', actionName: 'Dashboard', drilldownName: 'Drilldown 1' }, - { id: '2', actionName: 'Dashboard', drilldownName: 'Drilldown 2' }, - { id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3' }, -]; - -test('Render list of drilldowns', () => { - const screen = render(); - expect(screen.getAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(drilldowns.length); -}); - -test('Emit onEdit() when clicking on edit drilldown', () => { - const fn = jest.fn(); - const screen = render(); - - const editButtons = screen.getAllByText('Edit'); - expect(editButtons).toHaveLength(drilldowns.length); - fireEvent.click(editButtons[1]); - expect(fn).toBeCalledWith(drilldowns[1].id); -}); - -test('Emit onCreate() when clicking on create drilldown', () => { - const fn = jest.fn(); - const screen = render(); - fireEvent.click(screen.getByText('Create new')); - expect(fn).toBeCalled(); -}); - -test('Delete button is not visible when non is selected', () => { - const fn = jest.fn(); - const screen = render(); - expect(screen.queryByText(/Delete/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Create/i)).toBeInTheDocument(); -}); - -test('Can delete drilldowns', () => { - const fn = jest.fn(); - const screen = render(); - - const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); - expect(checkboxes).toHaveLength(3); - - fireEvent.click(checkboxes[1]); - fireEvent.click(checkboxes[2]); - - expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); - - fireEvent.click(screen.getByText(/Delete \(2\)/i)); - - expect(fn).toBeCalledWith([drilldowns[1].id, drilldowns[2].id]); -}); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx deleted file mode 100644 index 5a15781a1faf2..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx +++ /dev/null @@ -1,116 +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 { - EuiBasicTable, - EuiBasicTableColumn, - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiSpacer, - EuiTextColor, -} from '@elastic/eui'; -import React, { useState } from 'react'; -import { - txtCreateDrilldown, - txtDeleteDrilldowns, - txtEditDrilldown, - txtSelectDrilldown, -} from './i18n'; - -export interface DrilldownListItem { - id: string; - actionName: string; - drilldownName: string; - icon?: string; -} - -export interface ListManageDrilldownsProps { - drilldowns: DrilldownListItem[]; - - onEdit?: (id: string) => void; - onCreate?: () => void; - onDelete?: (ids: string[]) => void; -} - -const noop = () => {}; - -export const TEST_SUBJ_DRILLDOWN_ITEM = 'list-manage-drilldowns-item'; - -export function ListManageDrilldowns({ - drilldowns, - onEdit = noop, - onCreate = noop, - onDelete = noop, -}: ListManageDrilldownsProps) { - const [selectedDrilldowns, setSelectedDrilldowns] = useState([]); - - const columns: Array> = [ - { - field: 'drilldownName', - name: 'Name', - truncateText: true, - width: '50%', - }, - { - name: 'Action', - render: (drilldown: DrilldownListItem) => ( - - {drilldown.icon && ( - - - - )} - - {drilldown.actionName} - - - ), - }, - { - align: 'right', - render: (drilldown: DrilldownListItem) => ( - onEdit(drilldown.id)}> - {txtEditDrilldown} - - ), - }, - ]; - - return ( - <> - { - setSelectedDrilldowns(selection.map(drilldown => drilldown.id)); - }, - selectableMessage: () => txtSelectDrilldown, - }} - rowProps={{ - 'data-test-subj': TEST_SUBJ_DRILLDOWN_ITEM, - }} - hasActions={true} - /> - - {selectedDrilldowns.length === 0 ? ( - onCreate()}> - {txtCreateDrilldown} - - ) : ( - onDelete(selectedDrilldowns)}> - {txtDeleteDrilldowns(selectedDrilldowns.length)} - - )} - - ); -} diff --git a/x-pack/plugins/drilldowns/public/index.ts b/x-pack/plugins/drilldowns/public/index.ts index 044e29c671de4..63e7a12235462 100644 --- a/x-pack/plugins/drilldowns/public/index.ts +++ b/x-pack/plugins/drilldowns/public/index.ts @@ -7,14 +7,12 @@ import { DrilldownsPlugin } from './plugin'; export { - SetupContract as DrilldownsSetup, - SetupDependencies as DrilldownsSetupDependencies, - StartContract as DrilldownsStart, - StartDependencies as DrilldownsStartDependencies, + DrilldownsSetupContract, + DrilldownsSetupDependencies, + DrilldownsStartContract, + DrilldownsStartDependencies, } from './plugin'; export function plugin() { return new DrilldownsPlugin(); } - -export { DrilldownDefinition } from './types'; diff --git a/x-pack/plugins/drilldowns/public/mocks.ts b/x-pack/plugins/drilldowns/public/mocks.ts index 18816243a3572..bfade1674072a 100644 --- a/x-pack/plugins/drilldowns/public/mocks.ts +++ b/x-pack/plugins/drilldowns/public/mocks.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DrilldownsSetup, DrilldownsStart } from '.'; +import { DrilldownsSetupContract, DrilldownsStartContract } from '.'; -export type Setup = jest.Mocked; -export type Start = jest.Mocked; +export type Setup = jest.Mocked; +export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { @@ -17,14 +17,12 @@ const createSetupContract = (): Setup => { }; const createStartContract = (): Start => { - const startContract: Start = { - FlyoutManageDrilldowns: jest.fn(), - }; + const startContract: Start = {}; return startContract; }; -export const drilldownsPluginMock = { +export const bfetchPluginMock = { createSetupContract, createStartContract, }; diff --git a/x-pack/plugins/drilldowns/public/plugin.ts b/x-pack/plugins/drilldowns/public/plugin.ts index bbc06847d5842..b89172541b91e 100644 --- a/x-pack/plugins/drilldowns/public/plugin.ts +++ b/x-pack/plugins/drilldowns/public/plugin.ts @@ -6,46 +6,52 @@ import { CoreStart, CoreSetup, Plugin } from 'src/core/public'; import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; -import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '../../advanced_ui_actions/public'; -import { DrilldownService, DrilldownServiceSetupContract } from './services'; -import { createFlyoutManageDrilldowns } from './components/connected_flyout_manage_drilldowns'; -import { Storage } from '../../../../src/plugins/kibana_utils/public'; - -export interface SetupDependencies { +import { DrilldownService } from './service'; +import { + FlyoutCreateDrilldownActionContext, + FlyoutEditDrilldownActionContext, + OPEN_FLYOUT_ADD_DRILLDOWN, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './actions'; + +export interface DrilldownsSetupDependencies { uiActions: UiActionsSetup; - advancedUiActions: AdvancedUiActionsSetup; } -export interface StartDependencies { +export interface DrilldownsStartDependencies { uiActions: UiActionsStart; - advancedUiActions: AdvancedUiActionsStart; } -export type SetupContract = DrilldownServiceSetupContract; +export type DrilldownsSetupContract = Pick; // eslint-disable-next-line -export interface StartContract { - FlyoutManageDrilldowns: ReturnType; +export interface DrilldownsStartContract {} + +declare module '../../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [OPEN_FLYOUT_ADD_DRILLDOWN]: FlyoutCreateDrilldownActionContext; + [OPEN_FLYOUT_EDIT_DRILLDOWN]: FlyoutEditDrilldownActionContext; + } } export class DrilldownsPlugin - implements Plugin { + implements + Plugin< + DrilldownsSetupContract, + DrilldownsStartContract, + DrilldownsSetupDependencies, + DrilldownsStartDependencies + > { private readonly service = new DrilldownService(); - public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { - const setup = this.service.setup(core, plugins); + public setup(core: CoreSetup, plugins: DrilldownsSetupDependencies): DrilldownsSetupContract { + this.service.bootstrap(core, plugins); - return setup; + return this.service; } - public start(core: CoreStart, plugins: StartDependencies): StartContract { - return { - FlyoutManageDrilldowns: createFlyoutManageDrilldowns({ - advancedUiActions: plugins.advancedUiActions, - storage: new Storage(localStorage), - notifications: core.notifications, - }), - }; + public start(core: CoreStart, plugins: DrilldownsStartDependencies): DrilldownsStartContract { + return {}; } public stop() {} diff --git a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts b/x-pack/plugins/drilldowns/public/service/drilldown_service.ts new file mode 100644 index 0000000000000..7745c30b4e335 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/service/drilldown_service.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +// import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; +import { FlyoutCreateDrilldownAction, FlyoutEditDrilldownAction } from '../actions'; +import { DrilldownsSetupDependencies } from '../plugin'; + +export class DrilldownService { + bootstrap(core: CoreSetup, { uiActions }: DrilldownsSetupDependencies) { + const overlays = async () => (await core.getStartServices())[0].overlays; + + const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays }); + uiActions.registerAction(actionFlyoutCreateDrilldown); + // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); + + const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays }); + uiActions.registerAction(actionFlyoutEditDrilldown); + // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); + } + + /** + * Convenience method to register a drilldown. (It should set-up all the + * necessary triggers and actions.) + */ + registerDrilldown = (): void => { + throw new Error('not implemented'); + }; +} diff --git a/x-pack/plugins/drilldowns/public/services/index.ts b/x-pack/plugins/drilldowns/public/service/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/services/index.ts rename to x-pack/plugins/drilldowns/public/service/index.ts diff --git a/x-pack/plugins/drilldowns/public/services/drilldown_service.ts b/x-pack/plugins/drilldowns/public/services/drilldown_service.ts deleted file mode 100644 index bfbe514d46095..0000000000000 --- a/x-pack/plugins/drilldowns/public/services/drilldown_service.ts +++ /dev/null @@ -1,79 +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 { CoreSetup } from 'src/core/public'; -import { AdvancedUiActionsSetup } from '../../../advanced_ui_actions/public'; -import { DrilldownDefinition, DrilldownFactoryContext } from '../types'; -import { UiActionsActionFactoryDefinition as ActionFactoryDefinition } from '../../../../../src/plugins/ui_actions/public'; - -export interface DrilldownServiceSetupDeps { - advancedUiActions: AdvancedUiActionsSetup; -} - -export interface DrilldownServiceSetupContract { - /** - * Convenience method to register a drilldown. - */ - registerDrilldown: < - Config extends object = object, - CreationContext extends object = object, - ExecutionContext extends object = object - >( - drilldown: DrilldownDefinition - ) => void; -} - -export class DrilldownService { - setup( - core: CoreSetup, - { advancedUiActions }: DrilldownServiceSetupDeps - ): DrilldownServiceSetupContract { - const registerDrilldown = < - Config extends object = object, - CreationContext extends object = object, - ExecutionContext extends object = object - >({ - id: factoryId, - CollectConfig, - createConfig, - isConfigValid, - getDisplayName, - euiIcon, - execute, - }: DrilldownDefinition) => { - const actionFactory: ActionFactoryDefinition< - Config, - DrilldownFactoryContext, - ExecutionContext - > = { - id: factoryId, - CollectConfig, - createConfig, - isConfigValid, - getDisplayName, - getIconType: () => euiIcon, - isCompatible: async () => true, - create: serializedAction => ({ - id: '', - type: factoryId, - getIconType: () => euiIcon, - getDisplayName: () => serializedAction.name, - execute: async context => await execute(serializedAction.config, context), - }), - } as ActionFactoryDefinition< - Config, - DrilldownFactoryContext, - ExecutionContext - >; - - advancedUiActions.registerActionFactory(actionFactory); - }; - - return { - registerDrilldown, - }; - } -} diff --git a/x-pack/plugins/drilldowns/public/types.ts b/x-pack/plugins/drilldowns/public/types.ts deleted file mode 100644 index a8232887f9ca6..0000000000000 --- a/x-pack/plugins/drilldowns/public/types.ts +++ /dev/null @@ -1,120 +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 { AdvancedUiActionsActionFactoryDefinition as ActionFactoryDefinition } from '../../advanced_ui_actions/public'; - -/** - * This is a convenience interface to register a drilldown. Drilldown has - * ability to collect configuration from user. Once drilldown is executed it - * receives the collected information together with the context of the - * user's interaction. - * - * `Config` is a serializable object containing the configuration that the - * drilldown is able to collect using UI. - * - * `PlaceContext` is an object that the app that opens drilldown management - * flyout provides to the React component, specifying the contextual information - * about that app. For example, on Dashboard app this context contains - * information about the current embeddable and dashboard. - * - * `ExecutionContext` is an object created in response to user's interaction - * and provided to the `execute` function of the drilldown. This object contains - * information about the action user performed. - */ -export interface DrilldownDefinition< - Config extends object = object, - PlaceContext extends object = object, - ExecutionContext extends object = object -> { - /** - * Globally unique identifier for this drilldown. - */ - id: string; - - /** - * Function that returns default config for this drilldown. - */ - createConfig: ActionFactoryDefinition< - Config, - DrilldownFactoryContext, - ExecutionContext - >['createConfig']; - - /** - * `UiComponent` that collections config for this drilldown. You can create - * a React component and transform it `UiComponent` using `uiToReactComponent` - * helper from `kibana_utils` plugin. - * - * ```tsx - * import React from 'react'; - * import { uiToReactComponent } from 'src/plugins/kibana_utils'; - * import { UiActionsCollectConfigProps as CollectConfigProps } from 'src/plugins/ui_actions/public'; - * - * type Props = CollectConfigProps; - * - * const ReactCollectConfig: React.FC = () => { - * return
Collecting config...'
; - * }; - * - * export const CollectConfig = uiToReactComponent(ReactCollectConfig); - * ``` - */ - CollectConfig: ActionFactoryDefinition< - Config, - DrilldownFactoryContext, - ExecutionContext - >['CollectConfig']; - - /** - * A validator function for the config object. Should always return a boolean - * given any input. - */ - isConfigValid: ActionFactoryDefinition< - Config, - DrilldownFactoryContext, - ExecutionContext - >['isConfigValid']; - - /** - * Name of EUI icon to display when showing this drilldown to user. - */ - euiIcon?: string; - - /** - * Should return an internationalized name of the drilldown, which will be - * displayed to the user. - */ - getDisplayName: () => string; - - /** - * Implements the "navigation" action of the drilldown. This happens when - * user clicks something in the UI that executes a trigger to which this - * drilldown was attached. - * - * @param config Config object that user configured this drilldown with. - * @param context Object that represents context in which the underlying - * `UIAction` of this drilldown is being executed in. - */ - execute(config: Config, context: ExecutionContext): void; -} - -/** - * Context object used when creating a drilldown. - */ -export interface DrilldownFactoryContext { - /** - * Context provided to the drilldown factory by the place where the UI is - * rendered. For example, for the "dashboard" place, this context contains - * the ID of the current dashboard, which could be used for filtering it out - * of the list. - */ - placeContext: T; - - /** - * List of triggers that user selected in the UI. - */ - triggers: string[]; -} diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx index ac46d84469513..08ba10ff69207 100644 --- a/x-pack/plugins/reporting/public/plugin.tsx +++ b/x-pack/plugins/reporting/public/plugin.tsx @@ -143,7 +143,8 @@ export class ReportingPublicPlugin implements Plugin { }, }); - uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); + uiActions.registerAction(action); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); share.register(csvReportingProvider({ apiClient, toasts, license$ })); share.register( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 09392093b8f62..129aa25c27e84 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -636,6 +636,7 @@ "embeddableApi.addPanel.noMatchingObjectsMessage": "一致するオブジェクトが見つかりませんでした。", "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} が追加されました", "embeddableApi.addPanel.Title": "パネルの追加", + "embeddableApi.customizePanel.action.displayName": "パネルをカスタマイズ", "embeddableApi.customizePanel.modal.cancel": "キャンセル", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "パネルタイトル", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "パネルのカスタムタイトルを入力してください", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a3382a19f76b7..d0a354a2108d4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -636,6 +636,7 @@ "embeddableApi.addPanel.noMatchingObjectsMessage": "未找到任何匹配对象。", "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} 已添加", "embeddableApi.addPanel.Title": "添加面板", + "embeddableApi.customizePanel.action.displayName": "定制面板", "embeddableApi.customizePanel.modal.cancel": "取消", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "面板标题", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "为面板输入定制标题", From 72afbbbd72a027c24fbc0a6e0fe56c5c9970ff9f Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Tue, 24 Mar 2020 14:30:44 -0700 Subject: [PATCH 36/56] [Maps] Fix cross origin error for icon spritesheets when Kibana secured via OAuth proxy (#53896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Set crossOrigin to anonymous only on requests from external hosts * Update x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js Co-Authored-By: Joe Portner <5295965+jportner@users.noreply.github.com> * 🙇‍♂️ Lint Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> Co-authored-by: Elastic Machine --- .../public/connected_components/map/mb/utils.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js index 413d66fce7f70..a2850d2bb6c23 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js @@ -110,7 +110,9 @@ function getImageData(img) { export async function loadSpriteSheetImageData(imgUrl) { return new Promise((resolve, reject) => { const image = new Image(); - image.crossOrigin = 'Anonymous'; + if (isCrossOriginUrl(imgUrl)) { + image.crossOrigin = 'Anonymous'; + } image.onload = el => { const imgData = getImageData(el.currentTarget); resolve(imgData); @@ -142,3 +144,13 @@ export async function addSpritesheetToMap(json, imgUrl, mbMap) { const imgData = await loadSpriteSheetImageData(imgUrl); addSpriteSheetToMapFromImageData(json, imgData, mbMap); } + +function isCrossOriginUrl(url) { + const a = window.document.createElement('a'); + a.href = url; + return ( + a.protocol !== window.document.location.protocol || + a.host !== window.document.location.host || + a.port !== window.document.location.port + ); +} From 506e5d6fb07d26bcfcc03c0e22b99bbb7d2130b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Tue, 24 Mar 2020 22:38:38 +0100 Subject: [PATCH 37/56] [APM] Add capture_body option to dotnet (#61099) --- .../setting_definitions/general_settings.ts | 72 +++++++++---------- .../setting_definitions/index.test.ts | 2 + 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index b6eb40305dae7..b83c03c543295 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -64,24 +64,6 @@ export const generalSettings: RawSettingDefinition[] = [ excludeAgents: ['js-base', 'rum-js', 'dotnet'] }, - // Capture headers - { - key: 'capture_headers', - type: 'boolean', - defaultValue: 'true', - label: i18n.translate('xpack.apm.agentConfig.captureHeaders.label', { - defaultMessage: 'Capture Headers' - }), - description: i18n.translate( - 'xpack.apm.agentConfig.captureHeaders.description', - { - defaultMessage: - 'If set to `true`, the agent will capture request and response headers, including cookies.\n\nNOTE: Setting this to `false` reduces network bandwidth, disk space and object allocations.' - } - ), - excludeAgents: ['js-base', 'rum-js'] - }, - // Capture body { key: 'capture_body', @@ -104,7 +86,25 @@ export const generalSettings: RawSettingDefinition[] = [ { text: 'transactions' }, { text: 'all' } ], - excludeAgents: ['js-base', 'rum-js', 'dotnet'] + excludeAgents: ['js-base', 'rum-js'] + }, + + // Capture headers + { + key: 'capture_headers', + type: 'boolean', + defaultValue: 'true', + label: i18n.translate('xpack.apm.agentConfig.captureHeaders.label', { + defaultMessage: 'Capture Headers' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.captureHeaders.description', + { + defaultMessage: + 'If set to `true`, the agent will capture request and response headers, including cookies.\n\nNOTE: Setting this to `false` reduces network bandwidth, disk space and object allocations.' + } + ), + excludeAgents: ['js-base', 'rum-js'] }, // LOG_LEVEL @@ -175,23 +175,6 @@ export const generalSettings: RawSettingDefinition[] = [ includeAgents: ['nodejs', 'java', 'dotnet', 'go'] }, - // Transaction sample rate - { - key: 'transaction_sample_rate', - type: 'float', - defaultValue: '1.0', - label: i18n.translate('xpack.apm.agentConfig.transactionSampleRate.label', { - defaultMessage: 'Transaction sample rate' - }), - description: i18n.translate( - 'xpack.apm.agentConfig.transactionSampleRate.description', - { - defaultMessage: - 'By default, the agent will sample every transaction (e.g. request to your service). To reduce overhead and storage requirements, you can set the sample rate to a value between 0.0 and 1.0. We still record overall time and the result for unsampled transactions, but no context information, labels, or spans.' - } - ) - }, - // Transaction max spans { key: 'transaction_max_spans', @@ -215,5 +198,22 @@ export const generalSettings: RawSettingDefinition[] = [ min: 0, max: 32000, excludeAgents: ['js-base', 'rum-js'] + }, + + // Transaction sample rate + { + key: 'transaction_sample_rate', + type: 'float', + defaultValue: '1.0', + label: i18n.translate('xpack.apm.agentConfig.transactionSampleRate.label', { + defaultMessage: 'Transaction sample rate' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.transactionSampleRate.description', + { + defaultMessage: + 'By default, the agent will sample every transaction (e.g. request to your service). To reduce overhead and storage requirements, you can set the sample rate to a value between 0.0 and 1.0. We still record overall time and the result for unsampled transactions, but no context information, labels, or spans.' + } + ) } ]; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts index 0d1113d74c98b..fe55442324c92 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -127,6 +127,7 @@ describe('filterByAgent', () => { it('dotnet', () => { expect(getSettingKeysForAgent('dotnet')).toEqual([ + 'capture_body', 'capture_headers', 'log_level', 'span_frames_min_duration', @@ -152,6 +153,7 @@ describe('filterByAgent', () => { it('"All" services (no agent name)', () => { expect(getSettingKeysForAgent(undefined)).toEqual([ + 'capture_body', 'capture_headers', 'transaction_max_spans', 'transaction_sample_rate' From 64a5734439755399116450fbc1eb79d3227b35c3 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Tue, 24 Mar 2020 16:49:35 -0500 Subject: [PATCH 38/56] [SIEM] Add license checks for ML Rules on the backend (#61023) * WIP: Check license on simple rule creation We'll add this to the rest of the routes momentarily. * Add license checks around all rule-modifying endpoints This ensures that you cannot create nor update an ML Rule if your license is not Platinum (or Trial). Co-authored-by: Elastic Machine --- .../legacy/plugins/siem/common/constants.ts | 5 +++ .../routes/__mocks__/request_context.ts | 3 ++ .../routes/__mocks__/request_responses.ts | 26 ++++++++++---- .../routes/__mocks__/utils.ts | 15 ++++++++ .../rules/create_rules_bulk_route.test.ts | 17 ++++++++++ .../routes/rules/create_rules_bulk_route.ts | 3 ++ .../routes/rules/create_rules_route.test.ts | 18 ++++++++++ .../routes/rules/create_rules_route.ts | 8 ++++- .../routes/rules/import_rules_route.test.ts | 26 ++++++++++++++ .../routes/rules/import_rules_route.ts | 6 ++++ .../rules/patch_rules_bulk_route.test.ts | 22 ++++++++++++ .../routes/rules/patch_rules_bulk_route.ts | 11 +++++- .../routes/rules/patch_rules_route.test.ts | 17 ++++++++++ .../routes/rules/patch_rules_route.ts | 11 +++++- .../rules/update_rules_bulk_route.test.ts | 22 ++++++++++++ .../routes/rules/update_rules_bulk_route.ts | 9 ++++- .../routes/rules/update_rules_route.test.ts | 17 ++++++++++ .../routes/rules/update_rules_route.ts | 9 ++++- .../detection_engine/routes/rules/utils.ts | 9 ++++- .../lib/detection_engine/routes/utils.test.ts | 34 +++++++++++++++++++ .../lib/detection_engine/routes/utils.ts | 30 ++++++++++++++++ x-pack/legacy/plugins/siem/server/plugin.ts | 4 ++- 22 files changed, 308 insertions(+), 14 deletions(-) diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index ec720164e9bd7..eb22c0bdf93c3 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -94,4 +94,9 @@ export const DETECTION_ENGINE_QUERY_SIGNALS_URL = `${DETECTION_ENGINE_SIGNALS_UR */ export const UNAUTHENTICATED_USER = 'Unauthenticated'; +/* + Licensing requirements + */ +export const MINIMUM_ML_LICENSE = 'platinum'; + export const NOTIFICATION_THROTTLE_RULE = 'rule'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_context.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_context.ts index ebf6b3ae79ea8..2e5c29bc0221a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_context.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_context.ts @@ -12,11 +12,13 @@ import { } from '../../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../../plugins/actions/server/mocks'; +import { licensingMock } from '../../../../../../../../plugins/licensing/server/mocks'; const createMockClients = () => ({ actionsClient: actionsClientMock.create(), alertsClient: alertsClientMock.create(), clusterClient: elasticsearchServiceMock.createScopedClusterClient(), + licensing: { license: licensingMock.createLicenseMock() }, savedObjectsClient: savedObjectsClientMock.create(), siemClient: { signalsIndex: 'mockSignalsIndex' }, }); @@ -33,6 +35,7 @@ const createRequestContextMock = ( elasticsearch: { ...coreContext.elasticsearch, dataClient: clients.clusterClient }, savedObjects: { client: clients.savedObjectsClient }, }, + licensing: clients.licensing, siem: { getSiemClient: jest.fn(() => clients.siemClient) }, } as unknown) as RequestHandlerContext; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 6435410f31797..10e53d94de90a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -295,18 +295,30 @@ export const getCreateRequest = () => body: typicalPayload(), }); -export const createMlRuleRequest = () => { +export const typicalMlRulePayload = () => { const { query, language, index, ...mlParams } = typicalPayload(); + return { + ...mlParams, + type: 'machine_learning', + anomaly_threshold: 58, + machine_learning_job_id: 'typical-ml-job-id', + }; +}; + +export const createMlRuleRequest = () => { return requestMock.create({ method: 'post', path: DETECTION_ENGINE_RULES_URL, - body: { - ...mlParams, - type: 'machine_learning', - anomaly_threshold: 50, - machine_learning_job_id: 'some-uuid', - }, + body: typicalMlRulePayload(), + }); +}; + +export const createBulkMlRuleRequest = () => { + return requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: [typicalMlRulePayload()], }); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts index 13d75cc44992c..a2485ec477453 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -23,6 +23,21 @@ export const getSimpleRule = (ruleId = 'rule-1'): Partial = query: 'user.name: root or user.name: admin', }); +/** + * This is a typical ML rule for testing + * @param ruleId + */ +export const getSimpleMlRule = (ruleId = 'rule-1'): Partial => ({ + name: 'Simple Rule Query', + description: 'Simple Rule Query', + risk_score: 1, + rule_id: ruleId, + severity: 'high', + type: 'machine_learning', + anomaly_threshold: 44, + machine_learning_job_id: 'some_job_id', +}); + /** * This is a typical simple rule for testing that is easy for most basic testing * @param ruleId diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index e2af678c828e6..32b8eca298229 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -13,6 +13,7 @@ import { getFindResultWithSingleHit, getEmptyFindResult, getResult, + createBulkMlRuleRequest, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesBulkRoute } from './create_rules_bulk_route'; @@ -56,6 +57,22 @@ describe('create_rules_bulk', () => { }); describe('unhappy paths', () => { + it('returns an error object if creating an ML rule with an insufficient license', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + + const response = await server.inject(createBulkMlRuleRequest(), context); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + error: { + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); + it('returns an error object if the index does not exist', async () => { clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); const response = await server.inject(getReadBulkRequest(), context); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index 4ffa29c385f28..1ca9f7ef9075e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -19,6 +19,7 @@ import { createBulkErrorObject, buildRouteValidation, buildSiemResponse, + validateLicenseForRuleType, } from '../utils'; import { createRulesBulkSchema } from '../schemas/create_rules_bulk_schema'; import { rulesBulkSchema } from '../schemas/response/rules_bulk_schema'; @@ -90,6 +91,8 @@ export const createRulesBulkRoute = (router: IRouter) => { } = payloadRule; const ruleIdOrUuid = ruleId ?? uuid.v4(); try { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + const finalIndex = outputIndex ?? siemClient.signalsIndex; const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, finalIndex); if (!indexExists) { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 14592dd499d43..4da879d12f809 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -59,6 +59,13 @@ describe('create_rules', () => { expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); + + it('returns 200 if license is not platinum', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + + const response = await server.inject(getCreateRequest(), context); + expect(response.status).toEqual(200); + }); }); describe('creating an ML Rule', () => { @@ -66,6 +73,17 @@ describe('create_rules', () => { const response = await server.inject(createMlRuleRequest(), context); expect(response.status).toEqual(200); }); + + it('rejects the request if licensing is not platinum', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + + const response = await server.inject(createMlRuleRequest(), context); + expect(response.status).toEqual(400); + expect(response.body).toEqual({ + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }); + }); }); describe('creating a Notification if throttle and actions were provided ', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index 1fbbb5274d738..edf37bcb8dbe7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -16,7 +16,12 @@ import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { transformValidate } from './validate'; import { getIndexExists } from '../../index/get_index_exists'; import { createRulesSchema } from '../schemas/create_rules_schema'; -import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; +import { + buildRouteValidation, + transformError, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { createNotifications } from '../../notifications/create_notifications'; export const createRulesRoute = (router: IRouter): void => { @@ -66,6 +71,7 @@ export const createRulesRoute = (router: IRouter): void => { const siemResponse = buildSiemResponse(response); try { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); if (!context.alerting || !context.actions) { return siemResponse.error({ statusCode: 404 }); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index f6e1cf6e2420c..aacf83b9ec58a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -9,6 +9,8 @@ import { ruleIdsToNdJsonString, rulesToNdJsonString, getSimpleRuleWithId, + getSimpleRule, + getSimpleMlRule, } from '../__mocks__/utils'; import { getImportRulesRequest, @@ -102,6 +104,30 @@ describe('import_rules_route', () => { }); describe('unhappy paths', () => { + it('returns an error object if creating an ML rule with an insufficient license', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const rules = [getSimpleRule(), getSimpleMlRule('rule-2')]; + const hapiStreamWithMlRule = buildHapiStream(rulesToNdJsonString(rules)); + request = getImportRulesRequest(hapiStreamWithMlRule); + + const response = await server.inject(request, context); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + errors: [ + { + error: { + message: + 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }, + rule_id: 'rule-2', + }, + ], + success: false, + success_count: 1, + }); + }); + test('returns error if createPromiseFromStreams throws error', async () => { jest .spyOn(createRulesStreamFromNdJson, 'createRulesStreamFromNdJson') diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 4a5ea33025d49..2e6c72a87ec7f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -24,6 +24,7 @@ import { isImportRegular, transformError, buildSiemResponse, + validateLicenseForRuleType, } from '../utils'; import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; import { ImportRuleAlertRest } from '../../types'; @@ -146,6 +147,11 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config } = parsedRule; try { + validateLicenseForRuleType({ + license: context.licensing.license, + ruleType: type, + }); + const signalsIndex = siemClient.signalsIndex; const indexExists = await getIndexExists( clusterClient.callAsCurrentUser, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index 4c00cfa51c8ee..a1f39936dd674 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -11,6 +11,7 @@ import { getFindResultWithSingleHit, getPatchBulkRequest, getResult, + typicalMlRulePayload, } from '../__mocks__/request_responses'; import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { patchRulesBulkRoute } from './patch_rules_bulk_route'; @@ -88,6 +89,27 @@ describe('patch_rules_bulk', () => { expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); + + it('rejects patching of an ML rule with an insufficient license', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const request = requestMock.create({ + method: 'patch', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [typicalMlRulePayload()], + }); + + const response = await server.inject(request, context); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + error: { + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); }); describe('request validation', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index a80f3fee6b433..645dbdadf8cab 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -10,7 +10,12 @@ import { IRuleSavedAttributesSavedObjectAttributes, PatchRuleAlertParamsRest, } from '../../rules/types'; -import { transformBulkError, buildRouteValidation, buildSiemResponse } from '../utils'; +import { + transformBulkError, + buildRouteValidation, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { getIdBulkError } from './utils'; import { transformValidateBulkError, validate } from './validate'; import { patchRulesBulkSchema } from '../schemas/patch_rules_bulk_schema'; @@ -80,6 +85,10 @@ export const patchRulesBulkRoute = (router: IRouter) => { } = payloadRule; const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; try { + if (type) { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + } + const rule = await patchRules({ alertsClient, actionsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index 07519733db291..1e344d8ea7e31 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -13,6 +13,7 @@ import { typicalPayload, getFindResultWithSingleHit, nonRuleFindResult, + typicalMlRulePayload, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { patchRulesRoute } from './patch_rules_route'; @@ -109,6 +110,22 @@ describe('patch_rules', () => { }) ); }); + + it('rejects patching a rule to ML if licensing is not platinum', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_URL, + body: typicalMlRulePayload(), + }); + const response = await server.inject(request, context); + + expect(response.status).toEqual(400); + expect(response.body).toEqual({ + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }); + }); }); describe('request validation', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts index c5ecb109f4595..620bcd8fc17b0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -12,7 +12,12 @@ import { IRuleSavedAttributesSavedObjectAttributes, } from '../../rules/types'; import { patchRulesSchema } from '../schemas/patch_rules_schema'; -import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; +import { + buildRouteValidation, + transformError, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { getIdError } from './utils'; import { transformValidate } from './validate'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; @@ -65,6 +70,10 @@ export const patchRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { + if (type) { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + } + if (!context.alerting || !context.actions) { return siemResponse.error({ statusCode: 404 }); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index d530866edaf0d..611b38ccbae8b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -11,6 +11,7 @@ import { getFindResultWithSingleHit, getUpdateBulkRequest, getFindResultStatus, + typicalMlRulePayload, } from '../__mocks__/request_responses'; import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { updateRulesBulkRoute } from './update_rules_bulk_route'; @@ -83,6 +84,27 @@ describe('update_rules_bulk', () => { expect(response.status).toEqual(200); expect(response.body).toEqual(expected); }); + + it('returns an error object if creating an ML rule with an insufficient license', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const request = requestMock.create({ + method: 'put', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [typicalMlRulePayload()], + }); + + const response = await server.inject(request, context); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + error: { + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); }); describe('request validation', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index 6c3c8dffa3dfa..4abeb840c8c0a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -12,7 +12,12 @@ import { } from '../../rules/types'; import { getIdBulkError } from './utils'; import { transformValidateBulkError, validate } from './validate'; -import { buildRouteValidation, transformBulkError, buildSiemResponse } from '../utils'; +import { + buildRouteValidation, + transformBulkError, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { updateRulesBulkSchema } from '../schemas/update_rules_bulk_schema'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { updateRules } from '../../rules/update_rules'; @@ -83,6 +88,8 @@ export const updateRulesBulkRoute = (router: IRouter) => { const finalIndex = outputIndex ?? siemClient.signalsIndex; const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; try { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + const rule = await updateRules({ alertsClient, actionsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index a15f1ca9b044e..717f2cc4a52fe 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -13,6 +13,7 @@ import { getFindResultWithSingleHit, getFindResultStatusEmpty, nonRuleFindResult, + typicalMlRulePayload, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; @@ -88,6 +89,22 @@ describe('update_rules', () => { status_code: 500, }); }); + + it('rejects the request if licensing is not adequate', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const request = requestMock.create({ + method: 'put', + path: DETECTION_ENGINE_RULES_URL, + body: typicalMlRulePayload(), + }); + + const response = await server.inject(request, context); + expect(response.status).toEqual(400); + expect(response.body).toEqual({ + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }); + }); }); describe('request validation', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index f8cca6494e000..f0d5f08c5f636 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -11,7 +11,12 @@ import { IRuleSavedAttributesSavedObjectAttributes, } from '../../rules/types'; import { updateRulesSchema } from '../schemas/update_rules_schema'; -import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; +import { + buildRouteValidation, + transformError, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { getIdError } from './utils'; import { transformValidate } from './validate'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; @@ -67,6 +72,8 @@ export const updateRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + if (!context.alerting || !context.actions) { return siemResponse.error({ statusCode: 404 }); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index a0458dc3a133d..ca0d133627210 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -19,7 +19,12 @@ import { isRuleStatusFindTypes, isRuleStatusSavedObjectType, } from '../../rules/types'; -import { OutputRuleAlertRest, ImportRuleAlertRest, RuleAlertParamsRest } from '../../types'; +import { + OutputRuleAlertRest, + ImportRuleAlertRest, + RuleAlertParamsRest, + RuleType, +} from '../../types'; import { createBulkErrorObject, BulkError, @@ -295,3 +300,5 @@ export const getTupleDuplicateErrorsAndUniqueRules = ( return [Array.from(errors.values()), Array.from(rulesAcc.values())]; }; + +export const isMlRule = (ruleType: RuleType) => ruleType === 'machine_learning'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts index fdb1cd148c7fa..9efe4e491968b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts @@ -19,9 +19,11 @@ import { transformImportError, convertToSnakeCase, SiemResponseFactory, + validateLicenseForRuleType, } from './utils'; import { responseMock } from './__mocks__'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../feature_flags'; +import { licensingMock } from '../../../../../../../plugins/licensing/server/mocks'; describe('utils', () => { beforeAll(() => { @@ -359,4 +361,36 @@ describe('utils', () => { ); }); }); + + describe('validateLicenseForRuleType', () => { + let licenseMock: ReturnType; + + beforeEach(() => { + licenseMock = licensingMock.createLicenseMock(); + }); + + it('throws a BadRequestError if operating on an ML Rule with an insufficient license', () => { + licenseMock.hasAtLeast.mockReturnValue(false); + + expect(() => + validateLicenseForRuleType({ license: licenseMock, ruleType: 'machine_learning' }) + ).toThrowError(BadRequestError); + }); + + it('does not throw if operating on an ML Rule with a sufficient license', () => { + licenseMock.hasAtLeast.mockReturnValue(true); + + expect(() => + validateLicenseForRuleType({ license: licenseMock, ruleType: 'machine_learning' }) + ).not.toThrowError(BadRequestError); + }); + + it('does not throw if operating on a query rule', () => { + licenseMock.hasAtLeast.mockReturnValue(false); + + expect(() => + validateLicenseForRuleType({ license: licenseMock, ruleType: 'query' }) + ).not.toThrowError(BadRequestError); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts index 79c2f47658f7e..90c7d4a07ddf8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts @@ -7,13 +7,18 @@ import Boom from 'boom'; import Joi from 'joi'; import { has, snakeCase } from 'lodash/fp'; +import { i18n } from '@kbn/i18n'; import { RouteValidationFunction, KibanaResponseFactory, CustomHttpResponseOptions, } from '../../../../../../../../src/core/server'; +import { ILicense } from '../../../../../../../plugins/licensing/server'; +import { MINIMUM_ML_LICENSE } from '../../../../common/constants'; import { BadRequestError } from '../errors/bad_request_error'; +import { RuleType } from '../types'; +import { isMlRule } from './rules/utils'; export interface OutputError { message: string; @@ -289,3 +294,28 @@ export const convertToSnakeCase = >( return { ...acc, [newKey]: obj[item] }; }, {}); }; + +/** + * Checks the current Kibana License against the rule under operation. + * + * @param license ILicense representing the user license + * @param ruleType the type of the current rule + * + * @throws BadRequestError if rule and license are incompatible + */ +export const validateLicenseForRuleType = ({ + license, + ruleType, +}: { + license: ILicense; + ruleType: RuleType; +}) => { + if (isMlRule(ruleType) && !license.hasAtLeast(MINIMUM_ML_LICENSE)) { + const message = i18n.translate('xpack.siem.licensing.unsupportedMachineLearningMessage', { + defaultMessage: + 'Your license does not support machine learning. Please upgrade your license.', + }); + + throw new BadRequestError(message); + } +}; diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index d785de32eab7e..44e6b7a32a842 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -21,6 +21,7 @@ import { PluginSetupContract as FeaturesSetup } from '../../../../plugins/featur import { EncryptedSavedObjectsPluginSetup as EncryptedSavedObjectsSetup } from '../../../../plugins/encrypted_saved_objects/server'; import { SpacesPluginSetup as SpacesSetup } from '../../../../plugins/spaces/server'; import { PluginStartContract as ActionsStart } from '../../../../plugins/actions/server'; +import { LicensingPluginSetup } from '../../../../plugins/licensing/server'; import { LegacyServices } from './types'; import { initServer } from './init_server'; import { compose } from './lib/compose/kibana'; @@ -41,11 +42,12 @@ import { hasListsFeature, listsEnvFeatureFlagName } from './lib/detection_engine export { CoreSetup, CoreStart }; export interface SetupPlugins { + alerting: AlertingSetup; encryptedSavedObjects: EncryptedSavedObjectsSetup; features: FeaturesSetup; + licensing: LicensingPluginSetup; security: SecuritySetup; spaces?: SpacesSetup; - alerting: AlertingSetup; } export interface StartPlugins { From 24438275794d95fb29b271197a2518c1f33b4690 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 25 Mar 2020 00:05:57 +0100 Subject: [PATCH 39/56] [Uptime] Feature/enhance telemetry Phase 1 (#61062) * add telemetry * update telemetry * update telemetry * update types * fix issue when no data * use dynamic settings in telemtry * fix type Co-authored-by: Elastic Machine --- .../uptime/common/constants/rest_api.ts | 1 + .../uptime/public/hooks/use_telemetry.ts | 42 ++-- .../plugins/uptime/public/pages/settings.tsx | 23 +- x-pack/plugins/uptime/server/kibana.index.ts | 4 +- .../lib/adapters/framework/adapter_types.ts | 11 +- .../kibana_telemetry_adapter.test.ts.snap | 54 ++++- .../kibana_telemetry_adapter.test.ts | 69 ++++-- .../telemetry/kibana_telemetry_adapter.ts | 203 ++++++++++++++++-- .../server/lib/adapters/telemetry/types.ts | 35 +++ .../server/lib/requests/get_filter_bar.ts | 2 +- x-pack/plugins/uptime/server/plugin.ts | 21 +- .../plugins/uptime/server/rest_api/index.ts | 5 +- .../uptime/server/rest_api/telemetry/index.ts | 3 +- .../rest_api/telemetry/log_monitor_page.ts | 21 -- .../rest_api/telemetry/log_overview_page.ts | 21 -- .../rest_api/telemetry/log_page_view.ts | 33 +++ 16 files changed, 427 insertions(+), 121 deletions(-) create mode 100644 x-pack/plugins/uptime/server/lib/adapters/telemetry/types.ts delete mode 100644 x-pack/plugins/uptime/server/rest_api/telemetry/log_monitor_page.ts delete mode 100644 x-pack/plugins/uptime/server/rest_api/telemetry/log_overview_page.ts create mode 100644 x-pack/plugins/uptime/server/rest_api/telemetry/log_page_view.ts diff --git a/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts b/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts index a1a3e86e6a97e..7fafe6584d831 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts @@ -16,6 +16,7 @@ export enum API_URLS { PING_HISTOGRAM = `/api/uptime/ping/histogram`, SNAPSHOT_COUNT = `/api/uptime/snapshot/count`, FILTERS = `/api/uptime/filters`, + logPageView = `/api/uptime/logPageView`, ML_MODULE_JOBS = `/api/ml/modules/jobs_exist/`, ML_SETUP_MODULE = '/api/ml/modules/setup/', diff --git a/x-pack/legacy/plugins/uptime/public/hooks/use_telemetry.ts b/x-pack/legacy/plugins/uptime/public/hooks/use_telemetry.ts index 7eb18404decfd..fc0e0ce1c3e88 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/use_telemetry.ts +++ b/x-pack/legacy/plugins/uptime/public/hooks/use_telemetry.ts @@ -5,33 +5,31 @@ */ import { useEffect } from 'react'; -import { HttpHandler } from 'kibana/public'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { useUrlParams } from './use_url_params'; +import { apiService } from '../state/api/utils'; +import { API_URLS } from '../../common/constants'; export enum UptimePage { - Overview = '/api/uptime/logOverview', - Monitor = '/api/uptime/logMonitor', + Overview = 'Overview', + Monitor = 'Monitor', + Settings = 'Settings', NotFound = '__not-found__', } -const getApiPath = (page?: UptimePage) => { - if (!page) throw new Error('Telemetry logging for this page not yet implemented'); - if (page === '__not-found__') - throw new Error('Telemetry logging for 404 page not yet implemented'); - return page.valueOf(); -}; - -const logPageLoad = async (fetch: HttpHandler, page?: UptimePage) => { - await fetch(getApiPath(page), { - method: 'POST', - }); -}; - export const useUptimeTelemetry = (page?: UptimePage) => { - const kibana = useKibana(); - const fetch = kibana.services.http?.fetch; + const [getUrlParams] = useUrlParams(); + const { dateRangeStart, dateRangeEnd, autorefreshInterval, autorefreshIsPaused } = getUrlParams(); + useEffect(() => { - if (!fetch) throw new Error('Core http services are not defined'); - logPageLoad(fetch, page); - }, [fetch, page]); + if (!apiService.http) throw new Error('Core http services are not defined'); + + const params = { + page, + autorefreshInterval: autorefreshInterval / 1000, // divide by 1000 to keep it in secs + dateStart: dateRangeStart, + dateEnd: dateRangeEnd, + autoRefreshEnabled: !autorefreshIsPaused, + }; + apiService.post(API_URLS.logPageView, params); + }, [autorefreshInterval, autorefreshIsPaused, dateRangeEnd, dateRangeStart, page]); }; diff --git a/x-pack/legacy/plugins/uptime/public/pages/settings.tsx b/x-pack/legacy/plugins/uptime/public/pages/settings.tsx index 679a61686e435..e78c3e0f7de09 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/settings.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/settings.tsx @@ -6,19 +6,19 @@ import React, { useEffect, useState } from 'react'; import { - EuiForm, - EuiTitle, - EuiSpacer, + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiCode, EuiDescribedFormGroup, EuiFieldText, - EuiFormRow, - EuiCode, - EuiPanel, EuiFlexGroup, EuiFlexItem, - EuiButton, - EuiButtonEmpty, - EuiCallOut, + EuiForm, + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { connect } from 'react-redux'; @@ -29,10 +29,11 @@ import { AppState } from '../state'; import { selectDynamicSettings } from '../state/selectors'; import { DynamicSettingsState } from '../state/reducers/dynamic_settings'; import { getDynamicSettings, setDynamicSettings } from '../state/actions/dynamic_settings'; -import { DynamicSettings, defaultDynamicSettings } from '../../common/runtime_types'; +import { defaultDynamicSettings, DynamicSettings } from '../../common/runtime_types'; import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { OVERVIEW_ROUTE } from '../../common/constants'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { UptimePage, useUptimeTelemetry } from '../hooks'; interface Props { dynamicSettingsState: DynamicSettingsState; @@ -53,6 +54,8 @@ export const SettingsPageComponent = ({ }); useBreadcrumbs([{ text: settingsBreadcrumbText }]); + useUptimeTelemetry(UptimePage.Settings); + useEffect(() => { dispatchGetDynamicSettings({}); }, [dispatchGetDynamicSettings]); diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index da208e13acdad..c206cfa06e272 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -6,7 +6,6 @@ import { Request, Server } from 'hapi'; import { PLUGIN } from '../../../legacy/plugins/uptime/common/constants'; -import { KibanaTelemetryAdapter } from './lib/adapters/telemetry'; import { compose } from './lib/compose/kibana'; import { initUptimeServer } from './uptime_server'; import { UptimeCorePlugins, UptimeCoreSetup } from './lib/adapters/framework'; @@ -25,9 +24,8 @@ export interface KibanaServer extends Server { } export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCorePlugins) => { - const { features, usageCollection } = plugins; + const { features } = plugins; const libs = compose(server); - KibanaTelemetryAdapter.registerUsageCollector(usageCollection); features.registerFeature({ id: PLUGIN.ID, diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index a6dd8efd57c14..47fe5f2af4263 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -6,12 +6,17 @@ import { GraphQLSchema } from 'graphql'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { IRouter, CallAPIOptions, SavedObjectsClientContract } from 'src/core/server'; +import { + IRouter, + CallAPIOptions, + SavedObjectsClientContract, + ISavedObjectsRepository, +} from 'src/core/server'; import { UMKibanaRoute } from '../../../rest_api'; import { PluginSetupContract } from '../../../../../features/server'; import { DynamicSettings } from '../../../../../../legacy/plugins/uptime/common/runtime_types'; -type APICaller = ( +export type APICaller = ( endpoint: string, clientParams: Record, options?: CallAPIOptions @@ -22,7 +27,7 @@ export type UMElasticsearchQueryFn = ( ) => Promise | R; export type UMSavedObjectsQueryFn = ( - client: SavedObjectsClientContract, + client: SavedObjectsClientContract | ISavedObjectsRepository, params?: P ) => Promise | T; diff --git a/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/__snapshots__/kibana_telemetry_adapter.test.ts.snap b/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/__snapshots__/kibana_telemetry_adapter.test.ts.snap index e88a2cdc50cd9..8c55d5da54ac7 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/__snapshots__/kibana_telemetry_adapter.test.ts.snap +++ b/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/__snapshots__/kibana_telemetry_adapter.test.ts.snap @@ -4,8 +4,32 @@ exports[`KibanaTelemetryAdapter collects monitor and overview data 1`] = ` Object { "last_24_hours": Object { "hits": Object { + "autoRefreshEnabled": true, + "autorefreshInterval": Array [ + 30, + ], + "dateRangeEnd": Array [ + "now", + ], + "dateRangeStart": Array [ + "now-15", + ], + "monitor_frequency": Array [], + "monitor_name_stats": Object { + "avg_length": 0, + "max_length": 0, + "min_length": 0, + }, "monitor_page": 1, - "overview_page": 2, + "no_of_unique_monitors": 0, + "no_of_unique_observer_locations": 0, + "observer_location_name_stats": Object { + "avg_length": 0, + "max_length": 0, + "min_length": 0, + }, + "overview_page": 1, + "settings_page": 1, }, }, } @@ -15,8 +39,32 @@ exports[`KibanaTelemetryAdapter drops old buckets and reduces current window 1`] Object { "last_24_hours": Object { "hits": Object { - "monitor_page": 3, - "overview_page": 4, + "autoRefreshEnabled": true, + "autorefreshInterval": Array [ + 30, + ], + "dateRangeEnd": Array [ + "now", + ], + "dateRangeStart": Array [ + "now-15", + ], + "monitor_frequency": Array [], + "monitor_name_stats": Object { + "avg_length": 0, + "max_length": 0, + "min_length": 0, + }, + "monitor_page": 2, + "no_of_unique_monitors": 0, + "no_of_unique_observer_locations": 0, + "observer_location_name_stats": Object { + "avg_length": 0, + "max_length": 0, + "min_length": 0, + }, + "overview_page": 1, + "settings_page": 2, }, }, } diff --git a/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/kibana_telemetry_adapter.test.ts b/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/kibana_telemetry_adapter.test.ts index 8e4011b4cf0eb..c2437dbf35307 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/kibana_telemetry_adapter.test.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/kibana_telemetry_adapter.test.ts @@ -8,6 +8,7 @@ import { KibanaTelemetryAdapter } from '../kibana_telemetry_adapter'; describe('KibanaTelemetryAdapter', () => { let usageCollection: any; + let getSavedObjectsClient: any; let collector: { type: string; fetch: () => Promise; isReady: () => boolean }; beforeEach(() => { usageCollection = { @@ -15,14 +16,35 @@ describe('KibanaTelemetryAdapter', () => { collector = val; }, }; + getSavedObjectsClient = () => { + return {}; + }; }); it('collects monitor and overview data', async () => { expect.assertions(1); - KibanaTelemetryAdapter.initUsageCollector(usageCollection); - KibanaTelemetryAdapter.countMonitor(); - KibanaTelemetryAdapter.countOverview(); - KibanaTelemetryAdapter.countOverview(); + KibanaTelemetryAdapter.initUsageCollector(usageCollection, getSavedObjectsClient); + KibanaTelemetryAdapter.countPageView({ + page: 'Overview', + dateStart: 'now-15', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); + KibanaTelemetryAdapter.countPageView({ + page: 'Monitor', + dateStart: 'now-15', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); + KibanaTelemetryAdapter.countPageView({ + page: 'Settings', + dateStart: 'now-15', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); const result = await collector.fetch(); expect(result).toMatchSnapshot(); }); @@ -31,21 +53,42 @@ describe('KibanaTelemetryAdapter', () => { expect.assertions(1); // give a time of > 24 hours ago Date.now = jest.fn(() => 1559053560000); - KibanaTelemetryAdapter.initUsageCollector(usageCollection); - KibanaTelemetryAdapter.countMonitor(); - KibanaTelemetryAdapter.countOverview(); - // give a time of now + KibanaTelemetryAdapter.initUsageCollector(usageCollection, getSavedObjectsClient); + KibanaTelemetryAdapter.countPageView({ + page: 'Overview', + dateStart: 'now-20', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); + KibanaTelemetryAdapter.countPageView({ + page: 'Monitor', + dateStart: 'now-15', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); // give a time of now Date.now = jest.fn(() => new Date().valueOf()); - KibanaTelemetryAdapter.countMonitor(); - KibanaTelemetryAdapter.countMonitor(); - KibanaTelemetryAdapter.countOverview(); - KibanaTelemetryAdapter.countOverview(); + KibanaTelemetryAdapter.countPageView({ + page: 'Monitor', + dateStart: 'now-15', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); + KibanaTelemetryAdapter.countPageView({ + page: 'Settings', + dateStart: 'now-15', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); const result = await collector.fetch(); expect(result).toMatchSnapshot(); }); it('defaults ready to `true`', async () => { - KibanaTelemetryAdapter.initUsageCollector(usageCollection); + KibanaTelemetryAdapter.initUsageCollector(usageCollection, getSavedObjectsClient); expect(collector.isReady()).toBe(true); }); }); diff --git a/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts b/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts index 8dec0c1d2d485..e10a476bcc668 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts @@ -3,12 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -interface UptimeTelemetry { - overview_page: number; - monitor_page: number; -} +import moment from 'moment'; +import { ISavedObjectsRepository } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { PageViewParams, UptimeTelemetry } from './types'; +import { APICaller } from '../framework'; +import { savedObjectsAdapter } from '../../saved_objects'; interface UptimeTelemetryCollector { [key: number]: UptimeTelemetry; @@ -20,30 +21,180 @@ const BUCKET_SIZE = 3600; const BUCKET_NUMBER = 24; export class KibanaTelemetryAdapter { - public static registerUsageCollector = (usageCollector: UsageCollectionSetup) => { - const collector = KibanaTelemetryAdapter.initUsageCollector(usageCollector); + public static registerUsageCollector = ( + usageCollector: UsageCollectionSetup, + getSavedObjectsClient: () => ISavedObjectsRepository | undefined + ) => { + if (!usageCollector) { + return; + } + const collector = KibanaTelemetryAdapter.initUsageCollector( + usageCollector, + getSavedObjectsClient + ); usageCollector.registerCollector(collector); }; - public static initUsageCollector(usageCollector: UsageCollectionSetup) { + public static initUsageCollector( + usageCollector: UsageCollectionSetup, + getSavedObjectsClient: () => ISavedObjectsRepository | undefined + ) { return usageCollector.makeUsageCollector({ type: 'uptime', - fetch: async () => { + fetch: async (callCluster: APICaller) => { + const savedObjectsClient = getSavedObjectsClient()!; + if (savedObjectsClient) { + this.countNoOfUniqueMonitorAndLocations(callCluster, savedObjectsClient); + } const report = this.getReport(); return { last_24_hours: { hits: { ...report } } }; }, - isReady: () => true, + isReady: () => typeof getSavedObjectsClient() !== 'undefined', }); } - public static countOverview() { - const bucket = this.getBucketToIncrement(); - this.collector[bucket].overview_page += 1; + public static countPageView(pageView: PageViewParams) { + const bucketId = this.getBucketToIncrement(); + const bucket = this.collector[bucketId]; + if (pageView.page === 'Overview') { + bucket.overview_page += 1; + } + if (pageView.page === 'Monitor') { + bucket.monitor_page += 1; + } + if (pageView.page === 'Settings') { + bucket.settings_page += 1; + } + this.updateDateData(pageView, bucket); + return bucket; + } + + public static updateDateData( + { dateStart, dateEnd, autoRefreshEnabled, autorefreshInterval }: PageViewParams, + bucket: UptimeTelemetry + ) { + const prevDateStart = [...bucket.dateRangeStart].pop(); + if (!prevDateStart || prevDateStart !== dateStart) { + bucket.dateRangeStart.push(dateStart); + bucket.dateRangeEnd.push(dateEnd); + } else { + const prevDateEnd = [...bucket.dateRangeEnd].pop(); + if (!prevDateEnd || prevDateEnd !== dateEnd) { + bucket.dateRangeStart.push(dateStart); + bucket.dateRangeEnd.push(dateEnd); + } + } + + const prevAutorefreshInterval = [...bucket.autorefreshInterval].pop(); + if (!prevAutorefreshInterval || prevAutorefreshInterval !== autorefreshInterval) { + bucket.autorefreshInterval.push(autorefreshInterval); + } + bucket.autoRefreshEnabled = autoRefreshEnabled; } - public static countMonitor() { + public static async countNoOfUniqueMonitorAndLocations( + callCluster: APICaller, + savedObjectsClient: ISavedObjectsRepository + ) { + const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings(savedObjectsClient); + const params = { + index: dynamicSettings.heartbeatIndices, + body: { + query: { + bool: { + must: [ + { + range: { + '@timestamp': { + gte: 'now-1d/d', + lt: 'now', + }, + }, + }, + ], + }, + }, + size: 0, + aggs: { + unique_monitors: { + cardinality: { + field: 'monitor.id', + }, + }, + unique_locations: { + cardinality: { + field: 'observer.geo.name', + missing: 'N/A', + }, + }, + monitor_name: { + string_stats: { + field: 'monitor.name', + }, + }, + observer_loc_name: { + string_stats: { + field: 'observer.geo.name', + }, + }, + monitors: { + terms: { + field: 'monitor.id', + size: 1000, + }, + aggs: { + docs: { + top_hits: { + size: 1, + _source: ['monitor.timespan'], + }, + }, + }, + }, + }, + }, + }; + + const result = await callCluster('search', params); + const numberOfUniqueMonitors: number = result?.aggregations?.unique_monitors?.value ?? 0; + const numberOfUniqueLocations: number = result?.aggregations?.unique_locations?.value ?? 0; + const monitorNameStats: any = result?.aggregations?.monitor_name; + const locationNameStats: any = result?.aggregations?.observer_loc_name; + const uniqueMonitors: any = result?.aggregations?.monitors.buckets; const bucket = this.getBucketToIncrement(); - this.collector[bucket].monitor_page += 1; + + this.collector[bucket].no_of_unique_monitors = numberOfUniqueMonitors; + this.collector[bucket].no_of_unique_observer_locations = numberOfUniqueLocations; + this.collector[bucket].no_of_unique_observer_locations = numberOfUniqueLocations; + this.collector[bucket].monitor_name_stats = { + min_length: monitorNameStats?.min_length ?? 0, + max_length: monitorNameStats?.max_length ?? 0, + avg_length: +monitorNameStats?.avg_length.toFixed(2), + }; + + this.collector[bucket].observer_location_name_stats = { + min_length: locationNameStats?.min_length ?? 0, + max_length: locationNameStats?.max_length ?? 0, + avg_length: +locationNameStats?.avg_length.toFixed(2), + }; + + this.collector[bucket].monitor_frequency = this.getMonitorsFrequency(uniqueMonitors); + } + + private static getMonitorsFrequency(uniqueMonitors = []) { + const frequencies: number[] = []; + uniqueMonitors + .map((item: any) => item!.docs.hits?.hits?.[0] ?? {}) + .forEach(monitor => { + const timespan = monitor?._source?.monitor?.timespan; + if (timespan) { + const timeDiffSec = moment + .duration(moment(timespan.lt).diff(moment(timespan.gte))) + .asSeconds(); + frequencies.push(timeDiffSec); + } + }); + return frequencies; } private static collector: UptimeTelemetryCollector = {}; @@ -59,10 +210,12 @@ export class KibanaTelemetryAdapter { return Object.values(this.collector).reduce( (acc, cum) => ({ + ...cum, overview_page: acc.overview_page + cum.overview_page, monitor_page: acc.monitor_page + cum.monitor_page, + settings_page: acc.settings_page + cum.settings_page, }), - { overview_page: 0, monitor_page: 0 } + { overview_page: 0, monitor_page: 0, settings_page: 0 } ); } @@ -77,6 +230,24 @@ export class KibanaTelemetryAdapter { this.collector[bucketId] = { overview_page: 0, monitor_page: 0, + no_of_unique_monitors: 0, + settings_page: 0, + monitor_frequency: [], + monitor_name_stats: { + min_length: 0, + max_length: 0, + avg_length: 0, + }, + no_of_unique_observer_locations: 0, + observer_location_name_stats: { + min_length: 0, + max_length: 0, + avg_length: 0, + }, + dateRangeStart: [], + dateRangeEnd: [], + autoRefreshEnabled: false, + autorefreshInterval: [], }; } return bucketId; diff --git a/x-pack/plugins/uptime/server/lib/adapters/telemetry/types.ts b/x-pack/plugins/uptime/server/lib/adapters/telemetry/types.ts new file mode 100644 index 0000000000000..059bea6cc3215 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/adapters/telemetry/types.ts @@ -0,0 +1,35 @@ +/* + * 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 interface PageViewParams { + page: string; + dateStart: string; + dateEnd: string; + autoRefreshEnabled: boolean; + autorefreshInterval: number; +} + +export interface Stats { + min_length: number; + max_length: number; + avg_length: number; +} + +export interface UptimeTelemetry { + overview_page: number; + monitor_page: number; + settings_page: number; + no_of_unique_monitors: number; + monitor_frequency: number[]; + no_of_unique_observer_locations: number; + monitor_name_stats: Stats; + observer_location_name_stats: Stats; + + dateRangeStart: string[]; + dateRangeEnd: string[]; + autorefreshInterval: number[]; + autoRefreshEnabled: boolean; +} diff --git a/x-pack/plugins/uptime/server/lib/requests/get_filter_bar.ts b/x-pack/plugins/uptime/server/lib/requests/get_filter_bar.ts index b533c990083ab..95d23ddcbf466 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_filter_bar.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_filter_bar.ts @@ -58,7 +58,7 @@ export const extractFilterAggsResults = ( tags: [], }; keys.forEach(key => { - const buckets = responseAggregations[key]?.term?.buckets ?? []; + const buckets = responseAggregations?.[key]?.term?.buckets ?? []; values[key] = buckets.map((item: { key: string | number }) => item.key); }); return values; diff --git a/x-pack/plugins/uptime/server/plugin.ts b/x-pack/plugins/uptime/server/plugin.ts index 00e36be50d24e..7cc591a6b2db1 100644 --- a/x-pack/plugins/uptime/server/plugin.ts +++ b/x-pack/plugins/uptime/server/plugin.ts @@ -4,16 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext, CoreStart, CoreSetup } from '../../../../src/core/server'; +import { + PluginInitializerContext, + CoreStart, + CoreSetup, + ISavedObjectsRepository, +} from '../../../../src/core/server'; import { initServerWithKibana } from './kibana.index'; -import { UptimeCorePlugins } from './lib/adapters'; +import { KibanaTelemetryAdapter, UptimeCorePlugins } from './lib/adapters'; import { umDynamicSettings } from './lib/saved_objects'; export class Plugin { + private savedObjectsClient?: ISavedObjectsRepository; + constructor(_initializerContext: PluginInitializerContext) {} + public setup(core: CoreSetup, plugins: UptimeCorePlugins) { initServerWithKibana({ route: core.http.createRouter() }, plugins); core.savedObjects.registerType(umDynamicSettings); + KibanaTelemetryAdapter.registerUsageCollector( + plugins.usageCollection, + () => this.savedObjectsClient + ); + } + + public start(_core: CoreStart, _plugins: any) { + this.savedObjectsClient = _core.savedObjects.createInternalRepository(); } - public start(_core: CoreStart, _plugins: any) {} } diff --git a/x-pack/plugins/uptime/server/rest_api/index.ts b/x-pack/plugins/uptime/server/rest_api/index.ts index 000fba69fab00..561997c3567d0 100644 --- a/x-pack/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/index.ts @@ -7,7 +7,7 @@ import { createGetOverviewFilters } from './overview_filters'; import { createGetPingsRoute } from './pings'; import { createGetDynamicSettingsRoute, createPostDynamicSettingsRoute } from './dynamic_settings'; -import { createLogMonitorPageRoute, createLogOverviewPageRoute } from './telemetry'; +import { createLogPageViewRoute } from './telemetry'; import { createGetSnapshotCount } from './snapshot'; import { UMRestApiRouteFactory } from './types'; import { @@ -36,8 +36,7 @@ export const restApiRoutes: UMRestApiRouteFactory[] = [ createGetMonitorLocationsRoute, createGetStatusBarRoute, createGetSnapshotCount, - createLogMonitorPageRoute, - createLogOverviewPageRoute, + createLogPageViewRoute, createGetPingHistogramRoute, createGetMonitorDurationRoute, ]; diff --git a/x-pack/plugins/uptime/server/rest_api/telemetry/index.ts b/x-pack/plugins/uptime/server/rest_api/telemetry/index.ts index 29640d97213a6..f16080296dc67 100644 --- a/x-pack/plugins/uptime/server/rest_api/telemetry/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/telemetry/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { createLogMonitorPageRoute } from './log_monitor_page'; -export { createLogOverviewPageRoute } from './log_overview_page'; +export { createLogPageViewRoute } from './log_page_view'; diff --git a/x-pack/plugins/uptime/server/rest_api/telemetry/log_monitor_page.ts b/x-pack/plugins/uptime/server/rest_api/telemetry/log_monitor_page.ts deleted file mode 100644 index 71d6b8025dff2..0000000000000 --- a/x-pack/plugins/uptime/server/rest_api/telemetry/log_monitor_page.ts +++ /dev/null @@ -1,21 +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 { KibanaTelemetryAdapter } from '../../lib/adapters/telemetry'; -import { UMRestApiRouteFactory } from '../types'; - -export const createLogMonitorPageRoute: UMRestApiRouteFactory = () => ({ - method: 'POST', - path: '/api/uptime/logMonitor', - validate: false, - handler: async (_customParams, _context, _request, response): Promise => { - await KibanaTelemetryAdapter.countMonitor(); - return response.ok(); - }, - options: { - tags: ['access:uptime-read'], - }, -}); diff --git a/x-pack/plugins/uptime/server/rest_api/telemetry/log_overview_page.ts b/x-pack/plugins/uptime/server/rest_api/telemetry/log_overview_page.ts deleted file mode 100644 index de1ac5f4ed735..0000000000000 --- a/x-pack/plugins/uptime/server/rest_api/telemetry/log_overview_page.ts +++ /dev/null @@ -1,21 +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 { KibanaTelemetryAdapter } from '../../lib/adapters/telemetry'; -import { UMRestApiRouteFactory } from '../types'; - -export const createLogOverviewPageRoute: UMRestApiRouteFactory = () => ({ - method: 'POST', - path: '/api/uptime/logOverview', - validate: false, - handler: async (_customParams, _context, _request, response): Promise => { - await KibanaTelemetryAdapter.countOverview(); - return response.ok(); - }, - options: { - tags: ['access:uptime-read'], - }, -}); diff --git a/x-pack/plugins/uptime/server/rest_api/telemetry/log_page_view.ts b/x-pack/plugins/uptime/server/rest_api/telemetry/log_page_view.ts new file mode 100644 index 0000000000000..1f6f052019870 --- /dev/null +++ b/x-pack/plugins/uptime/server/rest_api/telemetry/log_page_view.ts @@ -0,0 +1,33 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { KibanaTelemetryAdapter } from '../../lib/adapters/telemetry'; +import { UMRestApiRouteFactory } from '../types'; +import { PageViewParams } from '../../lib/adapters/telemetry/types'; + +export const createLogPageViewRoute: UMRestApiRouteFactory = () => ({ + method: 'POST', + path: '/api/uptime/logPageView', + validate: { + body: schema.object({ + page: schema.string(), + dateStart: schema.string(), + dateEnd: schema.string(), + autoRefreshEnabled: schema.boolean(), + autorefreshInterval: schema.number(), + }), + }, + handler: async ({ callES, dynamicSettings }, _context, _request, response): Promise => { + const result = KibanaTelemetryAdapter.countPageView(_request.body as PageViewParams); + return response.ok({ + body: result, + }); + }, + options: { + tags: ['access:uptime-read'], + }, +}); From 846e84b6b71f544fefd2284e4097912084433e17 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Wed, 25 Mar 2020 00:14:06 +0100 Subject: [PATCH 40/56] [Console] Update spec definitions for 7.7.0 (#61080) * Update and cleanup existing OSS spec * Including some maintenance on existing overrides * Update x-pack spec definitions and overrides --- packages/kbn-spec-to-console/README.md | 4 +- .../bin/spec_to_console.js | 27 ++++++- packages/kbn-spec-to-console/lib/convert.js | 5 ++ .../kbn-spec-to-console/lib/convert/params.js | 1 + .../json/generated/cat.aliases.json | 9 ++- .../json/generated/cat.indices.json | 9 ++- .../cluster.delete_component_template.json | 15 ++++ .../json/generated/cluster.health.json | 1 + .../json/generated/cluster.state.json | 1 + .../json/generated/count.json | 1 + .../json/generated/delete_by_query.json | 2 + .../json/generated/field_caps.json | 1 + .../json/generated/indices.clear_cache.json | 1 + .../json/generated/indices.close.json | 1 + .../json/generated/indices.create.json | 1 + .../json/generated/indices.delete.json | 1 + .../json/generated/indices.exists.json | 1 + .../json/generated/indices.exists_alias.json | 1 + .../json/generated/indices.exists_type.json | 1 + .../json/generated/indices.flush.json | 1 + .../json/generated/indices.forcemerge.json | 1 + .../json/generated/indices.get.json | 2 + .../json/generated/indices.get_alias.json | 1 + .../generated/indices.get_field_mapping.json | 2 + .../json/generated/indices.get_mapping.json | 2 + .../json/generated/indices.get_settings.json | 1 + .../json/generated/indices.get_template.json | 1 + .../json/generated/indices.get_upgrade.json | 1 + .../json/generated/indices.open.json | 1 + .../json/generated/indices.put_mapping.json | 2 + .../json/generated/indices.put_settings.json | 1 + .../json/generated/indices.put_template.json | 5 +- .../json/generated/indices.refresh.json | 1 + .../json/generated/indices.rollover.json | 1 + .../json/generated/indices.segments.json | 1 + .../json/generated/indices.shard_stores.json | 1 + .../json/generated/indices.stats.json | 1 + .../json/generated/indices.upgrade.json | 1 + .../generated/indices.validate_query.json | 1 + .../json/generated/msearch_template.json | 3 +- .../json/generated/rank_eval.json | 1 + .../json/generated/search.json | 1 + .../json/generated/search_shards.json | 1 + .../json/generated/search_template.json | 4 +- .../json/generated/update_by_query.json | 1 + .../json/overrides/cluster.health.json | 37 ++++++++-- .../json/overrides/indices.get_template.json | 8 --- .../spec/generated/async_search.delete.json | 11 +++ .../spec/generated/async_search.get.json | 16 +++++ .../spec/generated/async_search.submit.json | 70 +++++++++++++++++++ .../autoscaling.get_autoscaling_decision.json | 11 +++ .../cat.ml_data_frame_analytics.json | 42 +++++++++++ .../spec/generated/cat.ml_datafeeds.json | 29 ++++++++ .../server/spec/generated/cat.ml_jobs.json | 42 +++++++++++ .../spec/generated/cat.ml_trained_models.json | 44 ++++++++++++ .../spec/generated/ccr.forget_follower.json | 2 +- .../server/spec/generated/ccr.unfollow.json | 2 +- .../spec/generated/enrich.delete_policy.json | 3 +- .../spec/generated/enrich.execute_policy.json | 3 +- .../spec/generated/enrich.get_policy.json | 3 +- .../spec/generated/enrich.put_policy.json | 3 +- .../server/spec/generated/enrich.stats.json | 3 +- .../generated/migration.deprecations.json | 2 +- .../server/spec/generated/ml.close_job.json | 2 +- .../ml.delete_data_frame_analytics.json | 2 +- .../spec/generated/ml.delete_datafeed.json | 2 +- .../spec/generated/ml.delete_forecast.json | 2 +- .../server/spec/generated/ml.delete_job.json | 2 +- .../generated/ml.delete_model_snapshot.json | 2 +- .../generated/ml.estimate_memory_usage.json | 11 --- .../generated/ml.evaluate_data_frame.json | 2 +- .../generated/ml.find_file_structure.json | 2 +- .../server/spec/generated/ml.flush_job.json | 2 +- .../server/spec/generated/ml.get_buckets.json | 2 +- .../spec/generated/ml.get_categories.json | 2 +- .../ml.get_data_frame_analytics.json | 2 +- .../ml.get_data_frame_analytics_stats.json | 2 +- .../spec/generated/ml.get_datafeed_stats.json | 2 +- .../spec/generated/ml.get_datafeeds.json | 2 +- .../spec/generated/ml.get_influencers.json | 2 +- .../spec/generated/ml.get_job_stats.json | 2 +- .../server/spec/generated/ml.get_jobs.json | 2 +- .../generated/ml.get_model_snapshots.json | 2 +- .../generated/ml.get_overall_buckets.json | 2 +- .../server/spec/generated/ml.get_records.json | 2 +- .../spec/generated/ml.get_trained_models.json | 3 +- .../server/spec/generated/ml.open_job.json | 2 +- .../server/spec/generated/ml.post_data.json | 2 +- .../spec/generated/ml.preview_datafeed.json | 2 +- .../ml.put_data_frame_analytics.json | 2 +- .../spec/generated/ml.put_datafeed.json | 14 +++- .../server/spec/generated/ml.put_job.json | 2 +- .../generated/ml.revert_model_snapshot.json | 2 +- .../spec/generated/ml.set_upgrade_mode.json | 2 +- .../ml.start_data_frame_analytics.json | 2 +- .../spec/generated/ml.start_datafeed.json | 2 +- .../ml.stop_data_frame_analytics.json | 2 +- .../spec/generated/ml.stop_datafeed.json | 2 +- .../spec/generated/ml.update_datafeed.json | 14 +++- .../server/spec/generated/ml.update_job.json | 2 +- .../generated/ml.update_model_snapshot.json | 2 +- .../generated/security.delete_privileges.json | 2 +- .../generated/security.put_privileges.json | 4 +- .../spec/generated/slm.delete_lifecycle.json | 2 +- .../spec/generated/slm.execute_lifecycle.json | 2 +- .../spec/generated/slm.get_lifecycle.json | 2 +- .../server/spec/generated/slm.get_stats.json | 2 +- .../server/spec/generated/slm.get_status.json | 2 +- .../spec/generated/slm.put_lifecycle.json | 2 +- .../server/spec/generated/slm.start.json | 2 +- .../server/spec/generated/slm.stop.json | 2 +- .../spec/generated/sql.clear_cursor.json | 2 +- .../server/spec/generated/sql.query.json | 2 +- .../server/spec/generated/sql.translate.json | 2 +- .../generated/transform.cat_transform.json | 31 ++++++++ .../spec/generated/watcher.ack_watch.json | 2 +- .../spec/generated/watcher.delete_watch.json | 2 +- .../spec/generated/watcher.execute_watch.json | 2 +- .../spec/generated/watcher.get_watch.json | 2 +- .../spec/generated/watcher.put_watch.json | 2 +- .../server/spec/generated/watcher.start.json | 2 +- .../server/spec/generated/watcher.stats.json | 2 +- .../server/spec/generated/watcher.stop.json | 2 +- .../server/spec/generated/xpack.usage.json | 2 +- .../spec/overrides/async_search.submit.json | 7 ++ .../overrides/ml.estimate_memory_usage.json | 38 ---------- .../overrides/security.delete_privileges.json | 5 -- .../overrides/security.put_privileges.json | 5 -- 128 files changed, 544 insertions(+), 151 deletions(-) create mode 100644 src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json delete mode 100644 src/plugins/console/server/lib/spec_definitions/json/overrides/indices.get_template.json create mode 100644 x-pack/plugins/console_extensions/server/spec/generated/async_search.delete.json create mode 100644 x-pack/plugins/console_extensions/server/spec/generated/async_search.get.json create mode 100644 x-pack/plugins/console_extensions/server/spec/generated/async_search.submit.json create mode 100644 x-pack/plugins/console_extensions/server/spec/generated/autoscaling.get_autoscaling_decision.json create mode 100644 x-pack/plugins/console_extensions/server/spec/generated/cat.ml_data_frame_analytics.json create mode 100644 x-pack/plugins/console_extensions/server/spec/generated/cat.ml_datafeeds.json create mode 100644 x-pack/plugins/console_extensions/server/spec/generated/cat.ml_jobs.json create mode 100644 x-pack/plugins/console_extensions/server/spec/generated/cat.ml_trained_models.json delete mode 100644 x-pack/plugins/console_extensions/server/spec/generated/ml.estimate_memory_usage.json create mode 100644 x-pack/plugins/console_extensions/server/spec/generated/transform.cat_transform.json create mode 100644 x-pack/plugins/console_extensions/server/spec/overrides/async_search.submit.json delete mode 100644 x-pack/plugins/console_extensions/server/spec/overrides/ml.estimate_memory_usage.json delete mode 100644 x-pack/plugins/console_extensions/server/spec/overrides/security.delete_privileges.json delete mode 100644 x-pack/plugins/console_extensions/server/spec/overrides/security.put_privileges.json diff --git a/packages/kbn-spec-to-console/README.md b/packages/kbn-spec-to-console/README.md index 6729f03b3d4db..bf60afd88f494 100644 --- a/packages/kbn-spec-to-console/README.md +++ b/packages/kbn-spec-to-console/README.md @@ -23,10 +23,10 @@ At the root of the Kibana repository, run the following commands: ```sh # OSS -yarn spec_to_console -g "/rest-api-spec/src/main/resources/rest-api-spec/api/*" -d "src/legacy/core_plugins/console/server/api_server/spec/generated" +yarn spec_to_console -g "/rest-api-spec/src/main/resources/rest-api-spec/api/*" -d "src/plugins/console/server/lib/spec_definitions/json" # X-pack -yarn spec_to_console -g "/x-pack/plugin/src/test/resources/rest-api-spec/api/*" -d "x-pack/legacy/plugins/console_extensions/spec/generated" +yarn spec_to_console -g "/x-pack/plugin/src/test/resources/rest-api-spec/api/*" -d "x-pack/plugins/console_extensions/server/spec/generated" ``` ### Information used in Console that is not available in the REST spec diff --git a/packages/kbn-spec-to-console/bin/spec_to_console.js b/packages/kbn-spec-to-console/bin/spec_to_console.js index 20e870963e4b4..20b42c67f3b89 100644 --- a/packages/kbn-spec-to-console/bin/spec_to_console.js +++ b/packages/kbn-spec-to-console/bin/spec_to_console.js @@ -21,6 +21,7 @@ const fs = require('fs'); const path = require('path'); const program = require('commander'); const glob = require('glob'); +const chalk = require('chalk'); const packageJSON = require('../package.json'); const convert = require('../lib/convert'); @@ -37,10 +38,26 @@ if (!program.glob) { } const files = glob.sync(program.glob); -console.log(files.length, files); +const totalFilesCount = files.length; +let convertedFilesCount = 0; + +console.log(chalk.bold(`Detected files (count: ${totalFilesCount}):`)); +console.log(); +console.log(files); +console.log(); + files.forEach(file => { const spec = JSON.parse(fs.readFileSync(file)); - const output = JSON.stringify(convert(spec), null, 2); + const convertedSpec = convert(spec); + if (!Object.keys(convertedSpec).length) { + console.log( + // prettier-ignore + `${chalk.yellow('Detected')} ${chalk.grey(file)} but no endpoints were converted; ${chalk.yellow('skipping')}...` + ); + return; + } + const output = JSON.stringify(convertedSpec, null, 2); + ++convertedFilesCount; if (program.directory) { const outputName = path.basename(file); const outputPath = path.resolve(program.directory, outputName); @@ -54,3 +71,9 @@ files.forEach(file => { console.log(output); } }); + +console.log(); +// prettier-ignore +console.log(`${chalk.grey('Converted')} ${chalk.bold(`${convertedFilesCount}/${totalFilesCount}`)} ${chalk.grey('files')}`); +console.log(`Check your ${chalk.bold('git status')}.`); +console.log(); diff --git a/packages/kbn-spec-to-console/lib/convert.js b/packages/kbn-spec-to-console/lib/convert.js index 5dbdd6e1c94e4..88e3693d702e5 100644 --- a/packages/kbn-spec-to-console/lib/convert.js +++ b/packages/kbn-spec-to-console/lib/convert.js @@ -36,6 +36,11 @@ module.exports = spec => { */ Object.keys(spec).forEach(api => { const source = spec[api]; + + if (source.url.paths.every(path => Boolean(path.deprecated))) { + return; + } + if (!source.url) { return result; } diff --git a/packages/kbn-spec-to-console/lib/convert/params.js b/packages/kbn-spec-to-console/lib/convert/params.js index 86ac1667282f0..0d1747ae4f685 100644 --- a/packages/kbn-spec-to-console/lib/convert/params.js +++ b/packages/kbn-spec-to-console/lib/convert/params.js @@ -47,6 +47,7 @@ module.exports = params => { case 'date': case 'string': case 'number': + case 'number|string': result[param] = defaultValue || ''; break; case 'list': diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cat.aliases.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cat.aliases.json index 2135bd67e57d8..40b0e56782641 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cat.aliases.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cat.aliases.json @@ -6,7 +6,14 @@ "h": [], "help": "__flag__", "s": [], - "v": "__flag__" + "v": "__flag__", + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ] }, "methods": [ "GET" diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cat.indices.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cat.indices.json index e6ca1fb575396..410350df13721 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cat.indices.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cat.indices.json @@ -36,7 +36,14 @@ "nanos" ], "v": "__flag__", - "include_unloaded_segments": "__flag__" + "include_unloaded_segments": "__flag__", + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ] }, "methods": [ "GET" diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json new file mode 100644 index 0000000000000..e935b8999e6d3 --- /dev/null +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json @@ -0,0 +1,15 @@ +{ + "cluster.delete_component_template": { + "url_params": { + "timeout": "", + "master_timeout": "" + }, + "methods": [ + "DELETE" + ], + "patterns": [ + "_component_template/{name}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-component-templates.html" + } +} diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.health.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.health.json index 64ede603c0e0d..1758ea44d92c0 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.health.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.health.json @@ -4,6 +4,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.state.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.state.json index ba9c8d427e7bd..fb4a02c603174 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.state.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.state.json @@ -11,6 +11,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/count.json b/src/plugins/console/server/lib/spec_definitions/json/generated/count.json index bd69fd0c77ec8..67386eb7c6f1b 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/count.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/count.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/delete_by_query.json b/src/plugins/console/server/lib/spec_definitions/json/generated/delete_by_query.json index 2d1636d5f2c02..e01ea8b2dec6d 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/delete_by_query.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/delete_by_query.json @@ -1,6 +1,7 @@ { "delete_by_query": { "url_params": { + "analyzer": "", "analyze_wildcard": "__flag__", "default_operator": [ "AND", @@ -17,6 +18,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/field_caps.json b/src/plugins/console/server/lib/spec_definitions/json/generated/field_caps.json index 5e632018bef25..4bf63d7566788 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/field_caps.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/field_caps.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.clear_cache.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.clear_cache.json index f5cf05c9a3f7f..fc84d07df88a4 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.clear_cache.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.clear_cache.json @@ -9,6 +9,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.close.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.close.json index 676f20632e63b..1b58a27829bc7 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.close.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.close.json @@ -8,6 +8,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.create.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.create.json index 8227e38d3c6d9..1970f88b30958 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.create.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.create.json @@ -1,6 +1,7 @@ { "indices.create": { "url_params": { + "include_type_name": "__flag__", "wait_for_active_shards": "", "timeout": "", "master_timeout": "" diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete.json index b006d5ea7a3cb..084828108123b 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete.json @@ -8,6 +8,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists.json index 33c845210ea87..09f6c7fd780f8 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_alias.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_alias.json index d302bbe6b93de..4b93184ed52f1 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_alias.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_alias.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_type.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_type.json index 70d35e6c453c9..0b11356155b50 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_type.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_type.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.flush.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.flush.json index 0ad1a250229b2..63c86d10a9864 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.flush.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.flush.json @@ -8,6 +8,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.forcemerge.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.forcemerge.json index 0e705e2e721ee..b642d5f04a044 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.forcemerge.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.forcemerge.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get.json index 7ca9e88274aa5..6df796ed6c4cf 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get.json @@ -1,12 +1,14 @@ { "indices.get": { "url_params": { + "include_type_name": "__flag__", "local": "__flag__", "ignore_unavailable": "__flag__", "allow_no_indices": "__flag__", "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_alias.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_alias.json index d687cab56630f..95bc74edc5865 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_alias.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_alias.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_field_mapping.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_field_mapping.json index ea952435566ed..c95e2efc73fab 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_field_mapping.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_field_mapping.json @@ -1,12 +1,14 @@ { "indices.get_field_mapping": { "url_params": { + "include_type_name": "__flag__", "include_defaults": "__flag__", "ignore_unavailable": "__flag__", "allow_no_indices": "__flag__", "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_mapping.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_mapping.json index 73f4e42262bf2..555137d0e2ee0 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_mapping.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_mapping.json @@ -1,11 +1,13 @@ { "indices.get_mapping": { "url_params": { + "include_type_name": "__flag__", "ignore_unavailable": "__flag__", "allow_no_indices": "__flag__", "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_settings.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_settings.json index 1c84258d0fce9..a6777f7a820aa 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_settings.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_settings.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json index f5902929c25cc..d5f52ec76b374 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json @@ -1,6 +1,7 @@ { "indices.get_template": { "url_params": { + "include_type_name": "__flag__", "flat_settings": "__flag__", "master_timeout": "", "local": "__flag__" diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_upgrade.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_upgrade.json index d781172c54d63..99ac958523084 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_upgrade.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_upgrade.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.open.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.open.json index b5c4c5501d05d..6369238739203 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.open.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.open.json @@ -8,6 +8,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_mapping.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_mapping.json index 07a62a64b64e1..e36783c815e3f 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_mapping.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_mapping.json @@ -1,6 +1,7 @@ { "indices.put_mapping": { "url_params": { + "include_type_name": "__flag__", "timeout": "", "master_timeout": "", "ignore_unavailable": "__flag__", @@ -8,6 +9,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_settings.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_settings.json index fe7b938d2f3fc..a2508cd0fc817 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_settings.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_settings.json @@ -9,6 +9,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json index 54a7625a2713c..e6317bd6eb537 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json @@ -1,11 +1,10 @@ { "indices.put_template": { "url_params": { + "include_type_name": "__flag__", "order": "", "create": "__flag__", - "timeout": "", - "master_timeout": "", - "flat_settings": "__flag__" + "master_timeout": "" }, "methods": [ "PUT", diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.refresh.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.refresh.json index 54cd2a869902a..2906349d3fdae 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.refresh.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.refresh.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.rollover.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.rollover.json index 19e0f1f909ab8..7fa76a687eb77 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.rollover.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.rollover.json @@ -1,6 +1,7 @@ { "indices.rollover": { "url_params": { + "include_type_name": "__flag__", "timeout": "", "dry_run": "__flag__", "master_timeout": "", diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.segments.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.segments.json index 9e2eb6efce27e..b3c07150699af 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.segments.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.segments.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.shard_stores.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.shard_stores.json index f8e026eb89984..c50f4cf501698 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.shard_stores.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.shard_stores.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.stats.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.stats.json index c3fc0f8f7055f..1fa32265c91ee 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.stats.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.stats.json @@ -16,6 +16,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.upgrade.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.upgrade.json index 68ee06dd1b0bd..484115bb9b260 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.upgrade.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.upgrade.json @@ -5,6 +5,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.validate_query.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.validate_query.json index 33720576ef8a3..315aa13d4b4e8 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.validate_query.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.validate_query.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/msearch_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/msearch_template.json index c2f741066bbdb..0b0ca087b1819 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/msearch_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/msearch_template.json @@ -9,7 +9,8 @@ ], "typed_keys": "__flag__", "max_concurrent_searches": "", - "rest_total_hits_as_int": "__flag__" + "rest_total_hits_as_int": "__flag__", + "ccs_minimize_roundtrips": "__flag__" }, "methods": [ "GET", diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/rank_eval.json b/src/plugins/console/server/lib/spec_definitions/json/generated/rank_eval.json index c2bed081124a8..4d73e58bd4c06 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/rank_eval.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/rank_eval.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/search.json b/src/plugins/console/server/lib/spec_definitions/json/generated/search.json index eb21b43644d77..78b969d3ed8f2 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/search.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/search.json @@ -19,6 +19,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/search_shards.json b/src/plugins/console/server/lib/spec_definitions/json/generated/search_shards.json index cbeb0a429352d..b0819f8e066c8 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/search_shards.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/search_shards.json @@ -9,6 +9,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/search_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/search_template.json index cf5a5c5f32db3..748326522e5c2 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/search_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/search_template.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], @@ -22,7 +23,8 @@ "explain": "__flag__", "profile": "__flag__", "typed_keys": "__flag__", - "rest_total_hits_as_int": "__flag__" + "rest_total_hits_as_int": "__flag__", + "ccs_minimize_roundtrips": "__flag__" }, "methods": [ "GET", diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/update_by_query.json b/src/plugins/console/server/lib/spec_definitions/json/generated/update_by_query.json index 393197949e86c..596f8f8b83963 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/update_by_query.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/update_by_query.json @@ -18,6 +18,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.health.json b/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.health.json index 7e1655e680b8f..949b897b29ff4 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.health.json +++ b/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.health.json @@ -1,11 +1,38 @@ { "cluster.health": { "url_params": { - "master_timeout": "30s", - "timeout": "30s", - "wait_for_relocating_shards": 0, - "wait_for_active_shards": 0, - "wait_for_nodes": 0 + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ], + "level": [ + "cluster", + "indices", + "shards" + ], + "local": "__flag__", + "master_timeout": "", + "timeout": "", + "wait_for_active_shards": "", + "wait_for_nodes": "", + "wait_for_events": [ + "immediate", + "urgent", + "high", + "normal", + "low", + "languid" + ], + "wait_for_no_relocating_shards": "__flag__", + "wait_for_no_initializing_shards": "__flag__", + "wait_for_status": [ + "green", + "yellow", + "red" + ] } } } diff --git a/src/plugins/console/server/lib/spec_definitions/json/overrides/indices.get_template.json b/src/plugins/console/server/lib/spec_definitions/json/overrides/indices.get_template.json deleted file mode 100644 index e0cbcc9cee2ec..0000000000000 --- a/src/plugins/console/server/lib/spec_definitions/json/overrides/indices.get_template.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "indices.get_template": { - "patterns": [ - "_template", - "_template/{template}" - ] - } -} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/async_search.delete.json b/x-pack/plugins/console_extensions/server/spec/generated/async_search.delete.json new file mode 100644 index 0000000000000..a0be8f05e7722 --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/async_search.delete.json @@ -0,0 +1,11 @@ +{ + "async_search.delete": { + "methods": [ + "DELETE" + ], + "patterns": [ + "_async_search/{id}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/async-search.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/async_search.get.json b/x-pack/plugins/console_extensions/server/spec/generated/async_search.get.json new file mode 100644 index 0000000000000..09f4520d580e3 --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/async_search.get.json @@ -0,0 +1,16 @@ +{ + "async_search.get": { + "url_params": { + "wait_for_completion": "", + "keep_alive": "", + "typed_keys": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_async_search/{id}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/async-search.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/async_search.submit.json b/x-pack/plugins/console_extensions/server/spec/generated/async_search.submit.json new file mode 100644 index 0000000000000..83fb7c0fe75ad --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/async_search.submit.json @@ -0,0 +1,70 @@ +{ + "async_search.submit": { + "url_params": { + "wait_for_completion": "", + "clean_on_completion": "__flag__", + "keep_alive": "", + "batched_reduce_size": "", + "request_cache": "__flag__", + "analyzer": "", + "analyze_wildcard": "__flag__", + "default_operator": [ + "AND", + "OR" + ], + "df": "", + "explain": "__flag__", + "stored_fields": [], + "docvalue_fields": [], + "from": "0", + "ignore_unavailable": "__flag__", + "ignore_throttled": "__flag__", + "allow_no_indices": "__flag__", + "expand_wildcards": [ + "open", + "closed", + "none", + "all" + ], + "lenient": "__flag__", + "preference": "random", + "q": "", + "routing": [], + "search_type": [ + "query_then_fetch", + "dfs_query_then_fetch" + ], + "size": "10", + "sort": [], + "_source": [], + "_source_excludes": [], + "_source_includes": [], + "terminate_after": "", + "stats": [], + "suggest_field": "", + "suggest_mode": [ + "missing", + "popular", + "always" + ], + "suggest_size": "", + "suggest_text": "", + "timeout": "", + "track_scores": "__flag__", + "track_total_hits": "__flag__", + "allow_partial_search_results": "__flag__", + "typed_keys": "__flag__", + "version": "__flag__", + "seq_no_primary_term": "__flag__", + "max_concurrent_shard_requests": "" + }, + "methods": [ + "POST" + ], + "patterns": [ + "_async_search", + "{indices}/_async_search" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/async-search.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/autoscaling.get_autoscaling_decision.json b/x-pack/plugins/console_extensions/server/spec/generated/autoscaling.get_autoscaling_decision.json new file mode 100644 index 0000000000000..241075f4ca538 --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/autoscaling.get_autoscaling_decision.json @@ -0,0 +1,11 @@ +{ + "autoscaling.get_autoscaling_decision": { + "methods": [ + "GET" + ], + "patterns": [ + "_autoscaling/decision" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/autoscaling-get-autoscaling-decision.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_data_frame_analytics.json b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_data_frame_analytics.json new file mode 100644 index 0000000000000..e2ddaefd87dea --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_data_frame_analytics.json @@ -0,0 +1,42 @@ +{ + "cat.ml_data_frame_analytics": { + "url_params": { + "allow_no_match": "__flag__", + "bytes": [ + "b", + "k", + "kb", + "m", + "mb", + "g", + "gb", + "t", + "tb", + "p", + "pb" + ], + "format": "", + "h": [], + "help": "__flag__", + "s": [], + "time": [ + "d", + "h", + "m", + "s", + "ms", + "micros", + "nanos" + ], + "v": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_cat/ml/data_frame/analytics", + "_cat/ml/data_frame/analytics/{id}" + ], + "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/cat-dfanalytics.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_datafeeds.json b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_datafeeds.json new file mode 100644 index 0000000000000..04f4e45782e1f --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_datafeeds.json @@ -0,0 +1,29 @@ +{ + "cat.ml_datafeeds": { + "url_params": { + "allow_no_datafeeds": "__flag__", + "format": "", + "h": [], + "help": "__flag__", + "s": [], + "time": [ + "d", + "h", + "m", + "s", + "ms", + "micros", + "nanos" + ], + "v": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_cat/ml/datafeeds", + "_cat/ml/datafeeds/{datafeed_id}" + ], + "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/cat-datafeeds.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_jobs.json b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_jobs.json new file mode 100644 index 0000000000000..2f7e03e564b5d --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_jobs.json @@ -0,0 +1,42 @@ +{ + "cat.ml_jobs": { + "url_params": { + "allow_no_jobs": "__flag__", + "bytes": [ + "b", + "k", + "kb", + "m", + "mb", + "g", + "gb", + "t", + "tb", + "p", + "pb" + ], + "format": "", + "h": [], + "help": "__flag__", + "s": [], + "time": [ + "d", + "h", + "m", + "s", + "ms", + "micros", + "nanos" + ], + "v": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_cat/ml/anomaly_detectors", + "_cat/ml/anomaly_detectors/{job_id}" + ], + "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/cat-anomaly-detectors.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_trained_models.json b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_trained_models.json new file mode 100644 index 0000000000000..9ff12e8bf6c57 --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_trained_models.json @@ -0,0 +1,44 @@ +{ + "cat.ml_trained_models": { + "url_params": { + "allow_no_match": "__flag__", + "from": 0, + "size": 0, + "bytes": [ + "b", + "k", + "kb", + "m", + "mb", + "g", + "gb", + "t", + "tb", + "p", + "pb" + ], + "format": "", + "h": [], + "help": "__flag__", + "s": [], + "time": [ + "d", + "h", + "m", + "s", + "ms", + "micros", + "nanos" + ], + "v": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_cat/ml/trained_models", + "_cat/ml/trained_models/{model_id}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/cat-trained-model.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ccr.forget_follower.json b/x-pack/plugins/console_extensions/server/spec/generated/ccr.forget_follower.json index aa9a42c54dff4..f2aabe9ef4257 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ccr.forget_follower.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ccr.forget_follower.json @@ -6,6 +6,6 @@ "patterns": [ "{indices}/_ccr/forget_follower" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ccr-post-forget-follower.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ccr.unfollow.json b/x-pack/plugins/console_extensions/server/spec/generated/ccr.unfollow.json index 92759d8222c63..37530bf373c42 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ccr.unfollow.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ccr.unfollow.json @@ -6,6 +6,6 @@ "patterns": [ "{indices}/_ccr/unfollow" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ccr-post-unfollow.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/enrich.delete_policy.json b/x-pack/plugins/console_extensions/server/spec/generated/enrich.delete_policy.json index 3d3c40fa093a4..d7615779bc566 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/enrich.delete_policy.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/enrich.delete_policy.json @@ -5,6 +5,7 @@ ], "patterns": [ "_enrich/policy/{name}" - ] + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/delete-enrich-policy-api.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/enrich.execute_policy.json b/x-pack/plugins/console_extensions/server/spec/generated/enrich.execute_policy.json index 542b709c08ec6..a7d6d99753c2e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/enrich.execute_policy.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/enrich.execute_policy.json @@ -8,6 +8,7 @@ ], "patterns": [ "_enrich/policy/{name}/_execute" - ] + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/execute-enrich-policy-api.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/enrich.get_policy.json b/x-pack/plugins/console_extensions/server/spec/generated/enrich.get_policy.json index b59bf72670b6d..9b91d899d099f 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/enrich.get_policy.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/enrich.get_policy.json @@ -6,6 +6,7 @@ "patterns": [ "_enrich/policy/{name}", "_enrich/policy" - ] + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/get-enrich-policy-api.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/enrich.put_policy.json b/x-pack/plugins/console_extensions/server/spec/generated/enrich.put_policy.json index 96d854f04dcfc..5ff0ab55aef80 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/enrich.put_policy.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/enrich.put_policy.json @@ -5,6 +5,7 @@ ], "patterns": [ "_enrich/policy/{name}" - ] + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/put-enrich-policy-api.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/enrich.stats.json b/x-pack/plugins/console_extensions/server/spec/generated/enrich.stats.json index e6d1b04d63e45..6cdd037a21216 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/enrich.stats.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/enrich.stats.json @@ -5,6 +5,7 @@ ], "patterns": [ "_enrich/_stats" - ] + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/enrich-stats-api.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/migration.deprecations.json b/x-pack/plugins/console_extensions/server/spec/generated/migration.deprecations.json index dce5244ea40ac..597791a2439c2 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/migration.deprecations.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/migration.deprecations.json @@ -7,6 +7,6 @@ "_migration/deprecations", "{indices}/_migration/deprecations" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/migration-api-deprecation.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/migration-api-deprecation.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.close_job.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.close_job.json index 310b0d125b1f9..b0f2c6489b30e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.close_job.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.close_job.json @@ -11,6 +11,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/_close" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-close-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-close-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_data_frame_analytics.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_data_frame_analytics.json index c3d7048406ef6..2e4593f339212 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_data_frame_analytics.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_data_frame_analytics.json @@ -9,6 +9,6 @@ "patterns": [ "_ml/data_frame/analytics/{id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/delete-dfanalytics.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/delete-dfanalytics.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_datafeed.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_datafeed.json index 7c7f3c40f23bb..0836a844eb0f5 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_datafeed.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_datafeed.json @@ -9,6 +9,6 @@ "patterns": [ "_ml/datafeeds/{datafeed_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_forecast.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_forecast.json index 971a761cc77e9..acaddfba74338 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_forecast.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_forecast.json @@ -11,6 +11,6 @@ "_ml/anomaly_detectors/{job_id}/_forecast", "_ml/anomaly_detectors/{job_id}/_forecast/{forecast_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-forecast.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-forecast.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_job.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_job.json index ab518071bf765..aa79a4c195ebe 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_job.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_job.json @@ -10,6 +10,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_model_snapshot.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_model_snapshot.json index 53d45bf0498ab..af4a7a6d68498 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_model_snapshot.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_model_snapshot.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/model_snapshots/{snapshot_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-snapshot.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-snapshot.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.estimate_memory_usage.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.estimate_memory_usage.json deleted file mode 100644 index 2195b74640c79..0000000000000 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.estimate_memory_usage.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "ml.estimate_memory_usage": { - "methods": [ - "PUT" - ], - "patterns": [ - "_ml/data_frame/analytics/_estimate_memory_usage" - ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/estimate-memory-usage-dfanalytics.html" - } -} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.evaluate_data_frame.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.evaluate_data_frame.json index c4523a8b41604..40f913383424d 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.evaluate_data_frame.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.evaluate_data_frame.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/data_frame/_evaluate" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/evaluate-dfanalytics.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/evaluate-dfanalytics.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.find_file_structure.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.find_file_structure.json index ec51a62c4f901..6e7163ae2b740 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.find_file_structure.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.find_file_structure.json @@ -27,6 +27,6 @@ "patterns": [ "_ml/find_file_structure" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-find-file-structure.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-find-file-structure.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.flush_job.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.flush_job.json index 2f496003a2834..38f8cd7e9b90b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.flush_job.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.flush_job.json @@ -13,6 +13,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/_flush" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-flush-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-flush-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_buckets.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_buckets.json index 2cbcb9d6155ec..b7c864064496e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_buckets.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_buckets.json @@ -19,6 +19,6 @@ "_ml/anomaly_detectors/{job_id}/results/buckets/{timestamp}", "_ml/anomaly_detectors/{job_id}/results/buckets" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-bucket.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-bucket.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_categories.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_categories.json index 357a7b7fb0ccc..64edb196bb366 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_categories.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_categories.json @@ -12,6 +12,6 @@ "_ml/anomaly_detectors/{job_id}/results/categories/{category_id}", "_ml/anomaly_detectors/{job_id}/results/categories/" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-category.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-category.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics.json index b3a0c9cf3ef71..ecccec9c7e059 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics.json @@ -12,6 +12,6 @@ "_ml/data_frame/analytics/{id}", "_ml/data_frame/analytics" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/get-dfanalytics.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/get-dfanalytics.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics_stats.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics_stats.json index e4b4ee7b1f64e..3ae103f79f798 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics_stats.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics_stats.json @@ -12,6 +12,6 @@ "_ml/data_frame/analytics/_stats", "_ml/data_frame/analytics/{id}/_stats" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/get-dfanalytics-stats.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/get-dfanalytics-stats.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeed_stats.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeed_stats.json index 5c300e444c794..2971b8a7f6c63 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeed_stats.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeed_stats.json @@ -10,6 +10,6 @@ "_ml/datafeeds/{datafeed_id}/_stats", "_ml/datafeeds/_stats" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-datafeed-stats.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-datafeed-stats.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeeds.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeeds.json index 9979a685426be..deeb81d692739 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeeds.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeeds.json @@ -10,6 +10,6 @@ "_ml/datafeeds/{datafeed_id}", "_ml/datafeeds" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_influencers.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_influencers.json index 9471fac64d489..6f6745d3a5472 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_influencers.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_influencers.json @@ -17,6 +17,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/results/influencers" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-influencer.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-influencer.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_job_stats.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_job_stats.json index b28a2655cbefe..6173b3ebdc6d0 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_job_stats.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_job_stats.json @@ -10,6 +10,6 @@ "_ml/anomaly_detectors/_stats", "_ml/anomaly_detectors/{job_id}/_stats" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-job-stats.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-job-stats.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_jobs.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_jobs.json index 8f7de906578d7..2486684424670 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_jobs.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_jobs.json @@ -10,6 +10,6 @@ "_ml/anomaly_detectors/{job_id}", "_ml/anomaly_detectors" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_model_snapshots.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_model_snapshots.json index a3b9702f4e4f0..19a61afc9e0e3 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_model_snapshots.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_model_snapshots.json @@ -16,6 +16,6 @@ "_ml/anomaly_detectors/{job_id}/model_snapshots/{snapshot_id}", "_ml/anomaly_detectors/{job_id}/model_snapshots" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-snapshot.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-snapshot.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_overall_buckets.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_overall_buckets.json index e89d63ae7f49f..3a88c9d8ab9c9 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_overall_buckets.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_overall_buckets.json @@ -16,6 +16,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/results/overall_buckets" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-overall-buckets.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-overall-buckets.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_records.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_records.json index fd03c8d34214c..6ad8ecb6f7d6b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_records.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_records.json @@ -17,6 +17,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/results/records" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-record.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-record.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_trained_models.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_trained_models.json index cdeaca9654b77..76598ee015c6d 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_trained_models.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_trained_models.json @@ -5,7 +5,8 @@ "include_model_definition": "__flag__", "decompress_definition": "__flag__", "from": 0, - "size": 0 + "size": 0, + "tags": [] }, "methods": [ "GET" diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.open_job.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.open_job.json index cd330ec4822c0..969da2253cc89 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.open_job.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.open_job.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/_open" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-open-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-open-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.post_data.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.post_data.json index cc6f0b658e111..512d258f52780 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.post_data.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.post_data.json @@ -10,6 +10,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/_data" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-post-data.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-post-data.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.preview_datafeed.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.preview_datafeed.json index be3c3d466f37d..6eb537804134b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.preview_datafeed.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.preview_datafeed.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/datafeeds/{datafeed_id}/_preview" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-preview-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-preview-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.put_data_frame_analytics.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.put_data_frame_analytics.json index 83ffd0da3dda5..fd00ff3a94ebd 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.put_data_frame_analytics.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.put_data_frame_analytics.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/data_frame/analytics/{id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/put-dfanalytics.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/put-dfanalytics.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.put_datafeed.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.put_datafeed.json index a61f9ab465724..302599b1633f4 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.put_datafeed.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.put_datafeed.json @@ -1,11 +1,23 @@ { "ml.put_datafeed": { + "url_params": { + "ignore_unavailable": "__flag__", + "allow_no_indices": "__flag__", + "ignore_throttled": "__flag__", + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ] + }, "methods": [ "PUT" ], "patterns": [ "_ml/datafeeds/{datafeed_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-put-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-put-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.put_job.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.put_job.json index d8e38a0bd4b9d..7a48994bd1a6c 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.put_job.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.put_job.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-put-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-put-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.revert_model_snapshot.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.revert_model_snapshot.json index 7b6d74d47a711..b0763d8a9b329 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.revert_model_snapshot.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.revert_model_snapshot.json @@ -9,6 +9,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/model_snapshots/{snapshot_id}/_revert" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-revert-snapshot.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-revert-snapshot.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.set_upgrade_mode.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.set_upgrade_mode.json index d46e93c6eee46..71a0f0c042813 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.set_upgrade_mode.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.set_upgrade_mode.json @@ -10,6 +10,6 @@ "patterns": [ "_ml/set_upgrade_mode" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-set-upgrade-mode.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-set-upgrade-mode.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.start_data_frame_analytics.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.start_data_frame_analytics.json index 1b5d7c122fc53..0b420733cd9de 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.start_data_frame_analytics.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.start_data_frame_analytics.json @@ -9,6 +9,6 @@ "patterns": [ "_ml/data_frame/analytics/{id}/_start" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/start-dfanalytics.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/start-dfanalytics.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.start_datafeed.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.start_datafeed.json index 8171a792d7e33..36f9e5fa93257 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.start_datafeed.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.start_datafeed.json @@ -11,6 +11,6 @@ "patterns": [ "_ml/datafeeds/{datafeed_id}/_start" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-start-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-start-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_data_frame_analytics.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_data_frame_analytics.json index 05edf9bbef3a2..bda7a7c0d414b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_data_frame_analytics.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_data_frame_analytics.json @@ -11,6 +11,6 @@ "patterns": [ "_ml/data_frame/analytics/{id}/_stop" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/stop-dfanalytics.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/stop-dfanalytics.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_datafeed.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_datafeed.json index b10fed7010a7f..d6769ed58148f 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_datafeed.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_datafeed.json @@ -11,6 +11,6 @@ "patterns": [ "_ml/datafeeds/{datafeed_id}/_stop" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-stop-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-stop-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.update_datafeed.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.update_datafeed.json index 9c0d7502d2fbe..4b31a9595659d 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.update_datafeed.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.update_datafeed.json @@ -1,11 +1,23 @@ { "ml.update_datafeed": { + "url_params": { + "ignore_unavailable": "__flag__", + "allow_no_indices": "__flag__", + "ignore_throttled": "__flag__", + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ] + }, "methods": [ "POST" ], "patterns": [ "_ml/datafeeds/{datafeed_id}/_update" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-update-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-update-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.update_job.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.update_job.json index 7276183b2e0c9..47ba249374e51 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.update_job.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.update_job.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/_update" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-update-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-update-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.update_model_snapshot.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.update_model_snapshot.json index 80e533eb55826..037982e7ebb2e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.update_model_snapshot.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.update_model_snapshot.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/model_snapshots/{snapshot_id}/_update" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-update-snapshot.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-update-snapshot.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/security.delete_privileges.json b/x-pack/plugins/console_extensions/server/spec/generated/security.delete_privileges.json index b44798013fe59..a7b56aa904bb2 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/security.delete_privileges.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/security.delete_privileges.json @@ -13,6 +13,6 @@ "patterns": [ "_security/privilege/{application}/{name}" ], - "documentation": "TODO" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-delete-privilege.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/security.put_privileges.json b/x-pack/plugins/console_extensions/server/spec/generated/security.put_privileges.json index a42d5eb6c953e..4dbe88c526f0e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/security.put_privileges.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/security.put_privileges.json @@ -12,8 +12,8 @@ "POST" ], "patterns": [ - "_security/privilege" + "_security/privilege/" ], - "documentation": "TODO" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-privileges.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.delete_lifecycle.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.delete_lifecycle.json index 621aa9327e798..ee63fd52eeb5b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.delete_lifecycle.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.delete_lifecycle.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/policy/{policy_id}" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-delete.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-delete-policy.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.execute_lifecycle.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.execute_lifecycle.json index 6d0b5fe02a9ee..9e50e2fc1009b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.execute_lifecycle.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.execute_lifecycle.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/policy/{policy_id}/_execute" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-execute.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-execute-lifecycle.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.get_lifecycle.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.get_lifecycle.json index 869438deb9219..93c32091be8e3 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.get_lifecycle.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.get_lifecycle.json @@ -7,6 +7,6 @@ "_slm/policy/{policy_id}", "_slm/policy" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-get.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-get-policy.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.get_stats.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.get_stats.json index e980534105b3c..b5af57beb2f79 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.get_stats.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.get_stats.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/stats" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/slm-get-stats.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/slm-api-get-stats.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.get_status.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.get_status.json index a7ffde10b316d..3a01a414b5afd 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.get_status.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.get_status.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/status" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-get-status.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-get-status.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.put_lifecycle.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.put_lifecycle.json index 1391669ed293b..09bc2b7bf836b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.put_lifecycle.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.put_lifecycle.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/policy/{policy_id}" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-put.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-put-policy.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.start.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.start.json index a5b94d98f08fb..1dff975cb2625 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.start.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.start.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/start" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-start.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-start.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.stop.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.stop.json index 0b76fe68d2b5e..2970c9a355005 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.stop.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.stop.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/stop" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-stop.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-stop.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/sql.clear_cursor.json b/x-pack/plugins/console_extensions/server/spec/generated/sql.clear_cursor.json index 6f15e1b979c51..3c98c9d295710 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/sql.clear_cursor.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/sql.clear_cursor.json @@ -6,6 +6,6 @@ "patterns": [ "_sql/close" ], - "documentation": "Clear SQL cursor" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-pagination.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/sql.query.json b/x-pack/plugins/console_extensions/server/spec/generated/sql.query.json index 0e4274e772f30..75d0989fb779e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/sql.query.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/sql.query.json @@ -10,6 +10,6 @@ "patterns": [ "_sql" ], - "documentation": "Execute SQL" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-rest-overview.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/sql.translate.json b/x-pack/plugins/console_extensions/server/spec/generated/sql.translate.json index e80ae7f8e3c5f..f93669ad58dc8 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/sql.translate.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/sql.translate.json @@ -7,6 +7,6 @@ "patterns": [ "_sql/translate" ], - "documentation": "Translate SQL into Elasticsearch queries" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-translate.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/transform.cat_transform.json b/x-pack/plugins/console_extensions/server/spec/generated/transform.cat_transform.json new file mode 100644 index 0000000000000..6fe19a6e53d28 --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/transform.cat_transform.json @@ -0,0 +1,31 @@ +{ + "transform.cat_transform": { + "url_params": { + "from": 0, + "size": 0, + "allow_no_match": "__flag__", + "format": "", + "h": [], + "help": "__flag__", + "s": [], + "time": [ + "d", + "h", + "m", + "s", + "ms", + "micros", + "nanos" + ], + "v": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_cat/transforms", + "_cat/transforms/{transform_id}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/cat-transforms.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.ack_watch.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.ack_watch.json index bedaa40c10548..0eacab92ba98d 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.ack_watch.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.ack_watch.json @@ -8,6 +8,6 @@ "_watcher/watch/{watch_id}/_ack", "_watcher/watch/{watch_id}/_ack/{action_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-ack-watch.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-ack-watch.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.delete_watch.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.delete_watch.json index 63e76c78c0d4a..4e0153423f540 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.delete_watch.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.delete_watch.json @@ -6,6 +6,6 @@ "patterns": [ "_watcher/watch/{id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-delete-watch.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-delete-watch.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.execute_watch.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.execute_watch.json index 7319d68d249ff..249c912637d5e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.execute_watch.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.execute_watch.json @@ -11,6 +11,6 @@ "_watcher/watch/{id}/_execute", "_watcher/watch/_execute" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-execute-watch.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-execute-watch.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.get_watch.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.get_watch.json index d9e646712edd6..bc244ed9415d2 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.get_watch.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.get_watch.json @@ -6,6 +6,6 @@ "patterns": [ "_watcher/watch/{id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-get-watch.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-get-watch.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.put_watch.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.put_watch.json index 98250da734222..59eba35f7fcbd 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.put_watch.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.put_watch.json @@ -13,6 +13,6 @@ "patterns": [ "_watcher/watch/{id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-put-watch.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-put-watch.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.start.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.start.json index 28bc2db990ebd..e1d9e4c820ad7 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.start.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.start.json @@ -6,6 +6,6 @@ "patterns": [ "_watcher/_start" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-start.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-start.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.stats.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.stats.json index 62c6c5fea123e..d19446e0f5bb2 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.stats.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.stats.json @@ -19,6 +19,6 @@ "queued_watches" ] }, - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-stats.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-stats.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.stop.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.stop.json index c2f370981d8e6..ac8fdaf365346 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.stop.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.stop.json @@ -6,6 +6,6 @@ "patterns": [ "_watcher/_stop" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-stop.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-stop.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/xpack.usage.json b/x-pack/plugins/console_extensions/server/spec/generated/xpack.usage.json index cd43f16ec45f8..90d50ce8aa533 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/xpack.usage.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/xpack.usage.json @@ -9,6 +9,6 @@ "patterns": [ "_xpack/usage" ], - "documentation": "Retrieve information about xpack features usage" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/usage-api.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/overrides/async_search.submit.json b/x-pack/plugins/console_extensions/server/spec/overrides/async_search.submit.json new file mode 100644 index 0000000000000..f176bf64fadd3 --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/overrides/async_search.submit.json @@ -0,0 +1,7 @@ +{ + "async_search.submit": { + "data_autocomplete_rules": { + "__scope_link": "search" + } + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/overrides/ml.estimate_memory_usage.json b/x-pack/plugins/console_extensions/server/spec/overrides/ml.estimate_memory_usage.json deleted file mode 100644 index 4954fd81a55d1..0000000000000 --- a/x-pack/plugins/console_extensions/server/spec/overrides/ml.estimate_memory_usage.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "ml.estimate_memory_usage": { - "data_autocomplete_rules": { - "data_frame_analytics_config": { - "source": { - "index": { "__one_of": ["SOURCE_INDEX_NAME", []] }, - "query": {} - }, - "dest": { - "index": "", - "results_field": "" - }, - "analysis": { - "outlier_detection": { - "n_neighbors": 1, - "method": {"__one_of": ["lof", "ldof", "distance_knn_nn", "distance_knn"]}, - "feature_influence_threshold": 1.0 - } - }, - "analyzed_fields": { - "__one_of": [ - "FIELD_NAME", - [], - { - "includes": { - "__one_of": ["FIELD_NAME", []] - }, - "excludes": { - "__one_of": ["FIELD_NAME", []] - } - } - ] - }, - "model_memory_limit": "" - } - } - } -} diff --git a/x-pack/plugins/console_extensions/server/spec/overrides/security.delete_privileges.json b/x-pack/plugins/console_extensions/server/spec/overrides/security.delete_privileges.json deleted file mode 100644 index 5486098ff7bd8..0000000000000 --- a/x-pack/plugins/console_extensions/server/spec/overrides/security.delete_privileges.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "security.delete_privileges": { - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-delete-privilege.html" - } -} diff --git a/x-pack/plugins/console_extensions/server/spec/overrides/security.put_privileges.json b/x-pack/plugins/console_extensions/server/spec/overrides/security.put_privileges.json deleted file mode 100644 index 9ebb1046047a7..0000000000000 --- a/x-pack/plugins/console_extensions/server/spec/overrides/security.put_privileges.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "security.put_privileges": { - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-privileges.html" - } -} From ece82023fe3aa8a50ae873fffd121ab398bc459d Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 24 Mar 2020 19:26:35 -0400 Subject: [PATCH 41/56] [ML] DF Analytics: display multi-class results in evaluate panel (#60760) * update for multiclass confusion matrix * Limit initial columns shown - add ability to see all * update colData type and fix trailing col width * fix wrong size prop passed to icon in explorer * add missing translation * show other predicted class column * update types and add translations --- .../anomalies_table/influencers_cell.js | 4 +- .../components/entity_cell/entity_cell.js | 4 +- .../column_data.tsx | 130 +++++++++++------- .../evaluate_panel.tsx | 62 +++++++-- 4 files changed, 137 insertions(+), 63 deletions(-) diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/influencers_cell.js b/x-pack/plugins/ml/public/application/components/anomalies_table/influencers_cell.js index 7d00c9818a1e2..f4b16dab5ef52 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/influencers_cell.js +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/influencers_cell.js @@ -56,7 +56,7 @@ export class InfluencersCell extends Component { } > influencerFilter( @@ -83,7 +83,7 @@ export class InfluencersCell extends Component { } > influencerFilter( diff --git a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js index 259e0d335c40f..02a9e569f28a4 100644 --- a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js +++ b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js @@ -22,7 +22,7 @@ function getAddFilter({ entityName, entityValue, filter }) { } > filter(entityName, entityValue, '+')} iconType="plusInCircle" @@ -45,7 +45,7 @@ function getRemoveFilter({ entityName, entityValue, filter }) { } > filter(entityName, entityValue, '-')} iconType="minusInCircle" diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx index baf7fd32b0f60..14493ab024f34 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx @@ -4,79 +4,115 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { ConfusionMatrix, PredictedClass } from '../../../../common/analytics'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiDataGridControlColumn, + EuiPopover, + EuiText, +} from '@elastic/eui'; +import { ConfusionMatrix } from '../../../../common/analytics'; interface ColumnData { actual_class: string; actual_class_doc_count: number; - predicted_class?: string; - count?: number; - error_count?: number; + [key: string]: string | number; } export const ACTUAL_CLASS_ID = 'actual_class'; +export const OTHER_CLASS_ID = 'other'; +export const MAX_COLUMNS = 6; export function getColumnData(confusionMatrixData: ConfusionMatrix[]) { const colData: Partial = []; + const columns: Array<{ id: string; display?: any }> = [ + { + id: ACTUAL_CLASS_ID, + display: , + }, + ]; - confusionMatrixData.forEach((classData: any) => { - const correctlyPredictedClass = classData.predicted_classes.find( - (pc: PredictedClass) => pc.predicted_class === classData.actual_class - ); - const incorrectlyPredictedClass = classData.predicted_classes.find( - (pc: PredictedClass) => pc.predicted_class !== classData.actual_class - ); + let showOther = false; - let accuracy; - if (correctlyPredictedClass !== undefined) { - accuracy = correctlyPredictedClass.count / classData.actual_class_doc_count; - // round to 2 decimal places without converting to string; - accuracy = Math.round(accuracy * 100) / 100; - } + confusionMatrixData.forEach(classData => { + const otherCount = classData.other_predicted_class_doc_count; - let error; - if (incorrectlyPredictedClass !== undefined) { - error = incorrectlyPredictedClass.count / classData.actual_class_doc_count; - error = Math.round(error * 100) / 100; + if (otherCount > 0) { + showOther = true; } - let col: any = { + const col: any = { actual_class: classData.actual_class, actual_class_doc_count: classData.actual_class_doc_count, + other: otherCount, }; - if (correctlyPredictedClass !== undefined) { - col = { - ...col, - predicted_class: correctlyPredictedClass.predicted_class, - [correctlyPredictedClass.predicted_class]: accuracy, - count: correctlyPredictedClass.count, - accuracy, - }; - } + const predictedClasses = classData.predicted_classes || []; - if (incorrectlyPredictedClass !== undefined) { - col = { - ...col, - [incorrectlyPredictedClass.predicted_class]: error, - error_count: incorrectlyPredictedClass.count, - }; + columns.push({ id: classData.actual_class }); + + for (let i = 0; i < predictedClasses.length; i++) { + const predictedClass = predictedClasses[i].predicted_class; + const predictedClassCount = predictedClasses[i].count; + col[predictedClass] = predictedClassCount; } colData.push(col); }); - const columns: any = [ + if (showOther) { + columns.push({ id: OTHER_CLASS_ID }); + } + + return { columns, columnData: colData }; +} + +export function getTrailingControlColumns( + numColumns: number, + setShowFullColumns: any +): EuiDataGridControlColumn[] { + return [ { - id: ACTUAL_CLASS_ID, - display: , + id: 'actions', + width: 60, + headerCellRender: () => {`${numColumns} more`}, + rowCellRender: function RowCellRender() { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + return ( + setIsPopoverOpen(!isPopoverOpen)} + /> + } + closePopover={() => setIsPopoverOpen(false)} + ownFocus={true} + > + setShowFullColumns(true)}> + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.showAllColumns', + { + defaultMessage: 'Show all columns', + } + )} + + + + ); + }, }, ]; - - colData.forEach((data: any) => { - columns.push({ id: data.predicted_class }); - }); - - return { columns, columnData: colData }; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index 7bf55f4ecf392..1c5563bdb4f83 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -39,7 +39,12 @@ import { ANALYSIS_CONFIG_TYPE, } from '../../../../common/analytics'; import { LoadingPanel } from '../loading_panel'; -import { getColumnData, ACTUAL_CLASS_ID } from './column_data'; +import { + getColumnData, + ACTUAL_CLASS_ID, + MAX_COLUMNS, + getTrailingControlColumns, +} from './column_data'; const defaultPanelWidth = 500; @@ -57,6 +62,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const [confusionMatrixData, setConfusionMatrixData] = useState([]); const [columns, setColumns] = useState([]); const [columnsData, setColumnsData] = useState([]); + const [showFullColumns, setShowFullColumns] = useState(false); const [popoverContents, setPopoverContents] = useState([]); const [docsCount, setDocsCount] = useState(null); const [error, setError] = useState(null); @@ -168,8 +174,9 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const colId = children?.props?.columnId; const gridItem = columnData[rowIndex]; - if (gridItem !== undefined) { - const count = colId === gridItem.actual_class ? gridItem.count : gridItem.error_count; + if (gridItem !== undefined && colId !== ACTUAL_CLASS_ID) { + // @ts-ignore + const count = gridItem[colId]; return `${count} / ${gridItem.actual_class_doc_count} * 100 = ${cellContentsElement.textContent}`; } @@ -203,19 +210,26 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) setCellProps: any; }) => { const cellValue = columnsData[rowIndex][columnId]; + const actualCount = columnsData[rowIndex] && columnsData[rowIndex].actual_class_doc_count; + let accuracy: number | string = '0%'; + + if (columnId !== ACTUAL_CLASS_ID && actualCount) { + accuracy = cellValue / actualCount; + // round to 2 decimal places without converting to string; + accuracy = Math.round(accuracy * 100) / 100; + accuracy = `${Math.round(accuracy * 100)}%`; + } // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { if (columnId !== ACTUAL_CLASS_ID) { setCellProps({ style: { - backgroundColor: `rgba(0, 179, 164, ${cellValue})`, + backgroundColor: `rgba(0, 179, 164, ${accuracy})`, }, }); } }, [rowIndex, columnId, setCellProps]); - return ( - {typeof cellValue === 'number' ? `${Math.round(cellValue * 100)}%` : cellValue} - ); + return {columnId === ACTUAL_CLASS_ID ? cellValue : accuracy}; }; if (isLoading === true) { @@ -224,6 +238,15 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; + const showTrailingColumns = columnsData.length > MAX_COLUMNS; + const extraColumns = columnsData.length - MAX_COLUMNS; + const shownColumns = + showTrailingColumns === true && showFullColumns === false + ? columns.slice(0, MAX_COLUMNS + 1) + : columns; + const rowCount = + showTrailingColumns === true && showFullColumns === false ? MAX_COLUMNS : columnsData.length; + return ( = ({ jobConfig, jobStatus, searchQuery }) )} {/* BEGIN TABLE ELEMENTS */} - + = ({ jobConfig, jobStatus, searchQuery }) From f9d37b392afbe24a36e595ceb1cd754fec83a877 Mon Sep 17 00:00:00 2001 From: patrykkopycinski Date: Wed, 25 Mar 2020 00:49:10 +0100 Subject: [PATCH 42/56] [SIEM] Add rule notifications (#59004) ## Summary Allow defining notifications that will trigger whenever the rule created new signals. Requires: - https://github.com/elastic/kibana/pull/58395 - https://github.com/elastic/kibana/pull/58964 - https://github.com/elastic/kibana/pull/60832 ![Screenshot 2020-03-02 at 10 19 18](https://user-images.githubusercontent.com/5188868/75662390-4fe8bf00-5c6f-11ea-943f-591367348b91.png) ![Screenshot 2020-03-02 at 10 13 00](https://user-images.githubusercontent.com/5188868/75662421-5e36db00-5c6f-11ea-9317-d158cddf4344.png) ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios - [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server) - [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process) --- .../legacy/plugins/siem/common/constants.ts | 12 ++ .../siem/cypress/screens/create_new_rule.ts | 7 +- .../siem/cypress/tasks/create_new_rule.ts | 2 + .../detection_engine/rules/api.test.ts | 4 +- .../containers/detection_engine/rules/mock.ts | 7 + .../detection_engine/rules/types.ts | 29 ++- .../detection_engine/rules/use_rule.test.tsx | 2 + .../detection_engine/rules/use_rules.test.tsx | 4 + .../rules/all/__mocks__/mock.ts | 17 +- .../__snapshots__/index.test.tsx.snap | 28 +++ .../rules/components/next_step/index.test.tsx | 16 ++ .../rules/components/next_step/index.tsx | 32 +++ .../components/rule_actions_field/index.tsx | 86 ++++++++ .../components/step_about_rule/index.test.tsx | 11 ++ .../components/step_about_rule/index.tsx | 34 +--- .../step_about_rule_details/index.tsx | 15 +- .../components/step_define_rule/index.tsx | 28 +-- .../components/step_rule_actions/index.tsx | 184 ++++++++++++++++++ .../components/step_rule_actions/schema.tsx | 29 +++ .../step_rule_actions/translations.tsx | 21 ++ .../components/step_schedule_rule/index.tsx | 57 ++---- .../throttle_select_field/index.tsx | 36 ++++ .../rules/create/helpers.test.ts | 165 +++++++++++++++- .../detection_engine/rules/create/helpers.ts | 47 ++++- .../detection_engine/rules/create/index.tsx | 88 +++++++-- .../detection_engine/rules/edit/index.tsx | 90 ++++++++- .../detection_engine/rules/helpers.test.tsx | 35 +++- .../pages/detection_engine/rules/helpers.tsx | 74 ++++++- .../detection_engine/rules/translations.ts | 8 + .../pages/detection_engine/rules/types.ts | 21 +- .../plugins/siem/public/shared_imports.ts | 5 +- .../notifications/build_signals_query.test.ts | 2 +- .../notifications/build_signals_query.ts | 2 +- .../create_notifications.test.ts | 2 +- .../notifications/get_signals_count.ts | 6 +- .../rules_notification_alert_type.test.ts | 2 +- .../rules_notification_alert_type.ts | 4 +- .../schedule_notification_actions.ts | 8 +- .../update_notifications.test.ts | 2 +- .../notifications/utils.test.ts | 2 +- .../detection_engine/notifications/utils.ts | 6 +- .../routes/__mocks__/request_responses.ts | 5 +- .../signals/signal_rule_alert_type.ts | 3 +- 43 files changed, 1054 insertions(+), 184 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/__snapshots__/index.test.tsx.snap create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/translations.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.tsx diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index eb22c0bdf93c3..85c00562f986b 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -65,6 +65,8 @@ export const INTERNAL_IDENTIFIER = '__internal'; export const INTERNAL_RULE_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_id`; export const INTERNAL_RULE_ALERT_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_alert_id`; export const INTERNAL_IMMUTABLE_KEY = `${INTERNAL_IDENTIFIER}_immutable`; +export const INTERNAL_NOTIFICATION_ID_KEY = `${INTERNAL_IDENTIFIER}_notification_id`; +export const INTERNAL_NOTIFICATION_RULE_ID_KEY = `${INTERNAL_IDENTIFIER}_notification_rule_id`; /** * Detection engine routes @@ -99,4 +101,14 @@ export const UNAUTHENTICATED_USER = 'Unauthenticated'; */ export const MINIMUM_ML_LICENSE = 'platinum'; +/* + Rule notifications options +*/ +export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ + '.email', + '.slack', + '.pagerduty', + '.webhook', +]; +export const NOTIFICATION_THROTTLE_NO_ACTIONS = 'no_actions'; export const NOTIFICATION_THROTTLE_RULE = 'rule'; diff --git a/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts b/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts index 1ac9278c3ce1c..a61dc3fe61814 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts @@ -20,7 +20,9 @@ export const CREATE_AND_ACTIVATE_BTN = '[data-test-subj="create-activate"]'; export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; -export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="continue"]'; +export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]'; + +export const SCHEDULE_CONTINUE_BUTTON = '[data-test-subj="schedule-continue"]'; export const FALSE_POSITIVES_INPUT = '[data-test-subj="detectionEngineStepAboutRuleFalsePositives"] input'; @@ -43,7 +45,8 @@ export const RULE_DESCRIPTION_INPUT = export const RULE_NAME_INPUT = '[data-test-subj="detectionEngineStepAboutRuleName"] [data-test-subj="input"]'; -export const SEVERITY_DROPDOWN = '[data-test-subj="select"]'; +export const SEVERITY_DROPDOWN = + '[data-test-subj="detectionEngineStepAboutRuleSeverity"] [data-test-subj="select"]'; export const TAGS_INPUT = '[data-test-subj="detectionEngineStepAboutRuleTags"] [data-test-subj="comboBoxSearchInput"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts b/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts index 6bd5e0887e2fc..ccaa065754b5b 100644 --- a/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts +++ b/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts @@ -21,11 +21,13 @@ import { REFERENCE_URLS_INPUT, RULE_DESCRIPTION_INPUT, RULE_NAME_INPUT, + SCHEDULE_CONTINUE_BUTTON, SEVERITY_DROPDOWN, TAGS_INPUT, } from '../screens/create_new_rule'; export const createAndActivateRule = () => { + cy.get(SCHEDULE_CONTINUE_BUTTON).click({ force: true }); cy.get(CREATE_AND_ACTIVATE_BTN).click({ force: true }); cy.get(CREATE_AND_ACTIVATE_BTN).should('not.exist'); }; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts index 8fdc6a67f7d71..e8019659d49c6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts @@ -39,7 +39,7 @@ describe('Detections Rules API', () => { await addRule({ rule: ruleMock, signal: abortCtrl.signal }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules', { body: - '{"description":"some desc","enabled":true,"false_positives":[],"filters":[],"from":"now-360s","index":["apm-*-transaction*","auditbeat-*","endgame-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf","language":"kuery","risk_score":75,"name":"Test rule","query":"user.email: \'root@elastic.co\'","references":[],"severity":"high","tags":["APM"],"to":"now","type":"query","threat":[]}', + '{"description":"some desc","enabled":true,"false_positives":[],"filters":[],"from":"now-360s","index":["apm-*-transaction*","auditbeat-*","endgame-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf","language":"kuery","risk_score":75,"name":"Test rule","query":"user.email: \'root@elastic.co\'","references":[],"severity":"high","tags":["APM"],"to":"now","type":"query","threat":[],"throttle":null}', method: 'POST', signal: abortCtrl.signal, }); @@ -291,7 +291,7 @@ describe('Detections Rules API', () => { await duplicateRules({ rules: rulesMock.data }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_create', { body: - '[{"description":"Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":73,"name":"Credential Dumping - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection","filters":[],"references":[],"severity":"high","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"version":1},{"description":"Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":47,"name":"Adversary Behavior - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:rules_engine_event","filters":[],"references":[],"severity":"medium","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"version":1}]', + '[{"actions":[],"description":"Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":73,"name":"Credential Dumping - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection","filters":[],"references":[],"severity":"high","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"throttle":null,"version":1},{"actions":[],"description":"Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":47,"name":"Adversary Behavior - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:rules_engine_event","filters":[],"references":[],"severity":"medium","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"throttle":null,"version":1}]', method: 'POST', }); }); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts index 51526c0ab9949..59782e8a36338 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts @@ -32,9 +32,11 @@ export const ruleMock: NewRule = { to: 'now', type: 'query', threat: [], + throttle: null, }; export const savedRuleMock: Rule = { + actions: [], created_at: 'mm/dd/yyyyTHH:MM:sssz', created_by: 'mockUser', description: 'some desc', @@ -65,6 +67,7 @@ export const savedRuleMock: Rule = { to: 'now', type: 'query', threat: [], + throttle: null, updated_at: 'mm/dd/yyyyTHH:MM:sssz', updated_by: 'mockUser', }; @@ -75,6 +78,7 @@ export const rulesMock: FetchRulesResponse = { total: 2, data: [ { + actions: [], created_at: '2020-02-14T19:49:28.178Z', updated_at: '2020-02-14T19:49:28.320Z', created_by: 'elastic', @@ -103,9 +107,11 @@ export const rulesMock: FetchRulesResponse = { to: 'now', type: 'query', threat: [], + throttle: null, version: 1, }, { + actions: [], created_at: '2020-02-14T19:49:28.189Z', updated_at: '2020-02-14T19:49:28.326Z', created_by: 'elastic', @@ -133,6 +139,7 @@ export const rulesMock: FetchRulesResponse = { to: 'now', type: 'query', threat: [], + throttle: null, version: 1, }, ], diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index c75d7b78cf92f..3ec3e6d2b3036 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -13,6 +13,19 @@ export const RuleTypeSchema = t.keyof({ }); export type RuleType = t.TypeOf; +/** + * Params is an "record", since it is a type of AlertActionParams which is action templates. + * @see x-pack/plugins/alerting/common/alert.ts + */ +export const action = t.exact( + t.type({ + group: t.string, + id: t.string, + action_type_id: t.string, + params: t.record(t.string, t.any), + }) +); + export const NewRuleSchema = t.intersection([ t.type({ description: t.string, @@ -24,6 +37,7 @@ export const NewRuleSchema = t.intersection([ type: RuleTypeSchema, }), t.partial({ + actions: t.array(action), anomaly_threshold: t.number, created_by: t.string, false_positives: t.array(t.string), @@ -40,6 +54,7 @@ export const NewRuleSchema = t.intersection([ saved_id: t.string, tags: t.array(t.string), threat: t.array(t.unknown), + throttle: t.union([t.string, t.null]), to: t.string, updated_by: t.string, note: t.string, @@ -54,9 +69,15 @@ export interface AddRulesProps { signal: AbortSignal; } -const MetaRule = t.type({ - from: t.string, -}); +const MetaRule = t.intersection([ + t.type({ + from: t.string, + }), + t.partial({ + throttle: t.string, + kibanaSiemAppUrl: t.string, + }), +]); export const RuleSchema = t.intersection([ t.type({ @@ -81,6 +102,8 @@ export const RuleSchema = t.intersection([ threat: t.array(t.unknown), updated_at: t.string, updated_by: t.string, + actions: t.array(action), + throttle: t.union([t.string, t.null]), }), t.partial({ anomaly_threshold: t.number, diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx index e0bf2c4907370..ab09f796ad49b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx @@ -31,6 +31,7 @@ describe('useRule', () => { expect(result.current).toEqual([ false, { + actions: [], created_at: 'mm/dd/yyyyTHH:MM:sssz', created_by: 'mockUser', description: 'some desc', @@ -59,6 +60,7 @@ describe('useRule', () => { severity: 'high', tags: ['APM'], threat: [], + throttle: null, to: 'now', type: 'query', updated_at: 'mm/dd/yyyyTHH:MM:sssz', diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx index 242d715e20f77..5d13b57f862bc 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx @@ -58,6 +58,7 @@ describe('useRules', () => { { data: [ { + actions: [], created_at: '2020-02-14T19:49:28.178Z', created_by: 'elastic', description: @@ -82,6 +83,7 @@ describe('useRules', () => { severity: 'high', tags: ['Elastic', 'Endpoint'], threat: [], + throttle: null, to: 'now', type: 'query', updated_at: '2020-02-14T19:49:28.320Z', @@ -89,6 +91,7 @@ describe('useRules', () => { version: 1, }, { + actions: [], created_at: '2020-02-14T19:49:28.189Z', created_by: 'elastic', description: @@ -113,6 +116,7 @@ describe('useRules', () => { severity: 'medium', tags: ['Elastic', 'Endpoint'], threat: [], + throttle: null, to: 'now', type: 'query', updated_at: '2020-02-14T19:49:28.326Z', diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts index a6aefefedd5c3..6d76fde49634d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -6,7 +6,7 @@ import { esFilters } from '../../../../../../../../../../src/plugins/data/public'; import { Rule, RuleError } from '../../../../../containers/detection_engine/rules'; -import { AboutStepRule, DefineStepRule, ScheduleStepRule } from '../../types'; +import { AboutStepRule, ActionsStepRule, DefineStepRule, ScheduleStepRule } from '../../types'; import { FieldValueQueryBar } from '../../components/query_bar'; export const mockQueryBar: FieldValueQueryBar = { @@ -40,6 +40,7 @@ export const mockQueryBar: FieldValueQueryBar = { }; export const mockRule = (id: string): Rule => ({ + actions: [], created_at: '2020-01-10T21:11:45.839Z', updated_at: '2020-01-10T21:11:45.839Z', created_by: 'elastic', @@ -70,11 +71,13 @@ export const mockRule = (id: string): Rule => ({ to: 'now', type: 'saved_query', threat: [], + throttle: null, note: '# this is some markdown documentation', version: 1, }); export const mockRuleWithEverything = (id: string): Rule => ({ + actions: [], created_at: '2020-01-10T21:11:45.839Z', updated_at: '2020-01-10T21:11:45.839Z', created_by: 'elastic', @@ -142,6 +145,7 @@ export const mockRuleWithEverything = (id: string): Rule => ({ ], }, ], + throttle: null, note: '# this is some markdown documentation', version: 1, }); @@ -175,6 +179,14 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ note: '# this is some markdown documentation', }); +export const mockActionsStepRule = (isNew = false, enabled = false): ActionsStepRule => ({ + isNew, + actions: [], + kibanaSiemAppUrl: 'http://localhost:5601/app/siem', + enabled, + throttle: null, +}); + export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ isNew, ruleType: 'query', @@ -188,9 +200,8 @@ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ }, }); -export const mockScheduleStepRule = (isNew = false, enabled = false): ScheduleStepRule => ({ +export const mockScheduleStepRule = (isNew = false): ScheduleStepRule => ({ isNew, - enabled, interval: '5m', from: '6m', to: 'now', diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..433b38773c14a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/__snapshots__/index.test.tsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NextStep renders correctly against snapshot 1`] = ` + + + + + + Continue + + + + +`; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.test.tsx new file mode 100644 index 0000000000000..552ede90cd018 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { NextStep } from './index'; + +describe('NextStep', () => { + test('renders correctly against snapshot', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.tsx new file mode 100644 index 0000000000000..11332e7af9266 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; +import * as RuleI18n from '../../translations'; + +interface NextStepProps { + onClick: () => Promise; + isDisabled: boolean; + dataTestSubj?: string; +} + +export const NextStep = React.memo( + ({ onClick, isDisabled, dataTestSubj = 'nextStep-continue' }) => ( + <> + + + + + {RuleI18n.CONTINUE} + + + + + ) +); + +NextStep.displayName = 'NextStep'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx new file mode 100644 index 0000000000000..a746d381c494c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx @@ -0,0 +1,86 @@ +/* + * 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, { useCallback, useEffect, useState } from 'react'; +import deepMerge from 'deepmerge'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { loadActionTypes } from '../../../../../../../../../plugins/triggers_actions_ui/public/application/lib/action_connector_api'; +import { SelectField } from '../../../../../shared_imports'; +import { + ActionForm, + ActionType, +} from '../../../../../../../../../plugins/triggers_actions_ui/public'; +import { AlertAction } from '../../../../../../../../../plugins/alerting/common'; +import { useKibana } from '../../../../../lib/kibana'; +import { NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS } from '../../../../../../common/constants'; + +type ThrottleSelectField = typeof SelectField; + +const DEFAULT_ACTION_GROUP_ID = 'default'; +const DEFAULT_ACTION_MESSAGE = + 'Rule {{context.rule.name}} generated {{state.signals_count}} signals'; + +export const RuleActionsField: ThrottleSelectField = ({ field, messageVariables }) => { + const [supportedActionTypes, setSupportedActionTypes] = useState(); + const { + http, + triggers_actions_ui: { actionTypeRegistry }, + notifications, + } = useKibana().services; + + const setActionIdByIndex = useCallback( + (id: string, index: number) => { + const updatedActions = [...(field.value as Array>)]; + updatedActions[index] = deepMerge(updatedActions[index], { id }); + field.setValue(updatedActions); + }, + [field] + ); + + const setAlertProperty = useCallback( + (updatedActions: AlertAction[]) => field.setValue(updatedActions), + [field] + ); + + const setActionParamsProperty = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (key: string, value: any, index: number) => { + const updatedActions = [...(field.value as AlertAction[])]; + updatedActions[index].params[key] = value; + field.setValue(updatedActions); + }, + [field] + ); + + useEffect(() => { + (async function() { + const actionTypes = await loadActionTypes({ http }); + const supportedTypes = actionTypes.filter(actionType => + NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.includes(actionType.id) + ); + setSupportedActionTypes(supportedTypes); + })(); + }, []); + + if (!supportedActionTypes) return <>; + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.test.tsx index 0ed479e235151..3c28e697789ac 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.test.tsx @@ -15,6 +15,17 @@ import { stepAboutDefaultValue } from './default_value'; const theme = () => ({ eui: euiDarkVars, darkMode: true }); +/* eslint-disable no-console */ +// Silence until enzyme fixed to use ReactTestUtils.act() +const originalError = console.error; +beforeAll(() => { + console.error = jest.fn(); +}); +afterAll(() => { + console.error = originalError; +}); +/* eslint-enable no-console */ + describe('StepAboutRuleComponent', () => { test('it renders StepRuleDescription if isReadOnlyView is true and "name" property exists', () => { const wrapper = shallow( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index 58b6ca54f5bbd..eaf543780d777 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -4,22 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiAccordion, - EuiButton, - EuiHorizontalRule, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiButtonEmpty, -} from '@elastic/eui'; +import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiButtonEmpty } from '@elastic/eui'; import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { setFieldValue } from '../../helpers'; import { RuleStepProps, RuleStep, AboutStepRule } from '../../types'; -import * as RuleI18n from '../../translations'; import { AddItem } from '../add_item_form'; import { StepRuleDescription } from '../description_step'; import { AddMitreThreat } from '../mitre'; @@ -38,6 +29,7 @@ import { isUrlInvalid } from './helpers'; import { schema } from './schema'; import * as I18n from './translations'; import { StepContentWrapper } from '../step_content_wrapper'; +import { NextStep } from '../next_step'; import { MarkdownEditorForm } from '../../../../../components/markdown_editor/form'; const CommonUseField = getUseField({ component: Field }); @@ -276,27 +268,9 @@ const StepAboutRuleComponent: FC = ({ + {!isUpdateView && ( - <> - - - - - {RuleI18n.CONTINUE} - - - - + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.tsx index c61566cb841e8..5d9803214fa0a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.tsx @@ -15,7 +15,7 @@ import { EuiFlexGroup, EuiResizeObserver, } from '@elastic/eui'; -import React, { memo, useState } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import styled from 'styled-components'; import { isEmpty } from 'lodash/fp'; @@ -71,9 +71,12 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ const [selectedToggleOption, setToggleOption] = useState('details'); const [aboutPanelHeight, setAboutPanelHeight] = useState(0); - const onResize = (e: { height: number; width: number }) => { - setAboutPanelHeight(e.height); - }; + const onResize = useCallback( + (e: { height: number; width: number }) => { + setAboutPanelHeight(e.height); + }, + [setAboutPanelHeight] + ); return ( @@ -85,7 +88,7 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ )} {stepData != null && stepDataDetails != null && ( - + {!isEmpty(stepDataDetails.note) && stepDataDetails.note.trim() !== '' && ( = ({ )} - + {selectedToggleOption === 'details' ? ( {resizeRef => ( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index 4027c98a52ace..68ca1840871e3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -4,14 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiButtonEmpty, - EuiHorizontalRule, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiButton, -} from '@elastic/eui'; +import { EuiButtonEmpty, EuiFormRow } from '@elastic/eui'; import React, { FC, memo, useCallback, useState, useEffect, useContext } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -23,7 +16,6 @@ import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/trans import { MlCapabilitiesContext } from '../../../../../components/ml/permissions/ml_capabilities_provider'; import { useUiSetting$ } from '../../../../../lib/kibana'; import { setFieldValue, isMlRule } from '../../helpers'; -import * as RuleI18n from '../../translations'; import { DefineStepRule, RuleStep, RuleStepProps } from '../../types'; import { StepRuleDescription } from '../description_step'; import { QueryBarDefineRule } from '../query_bar'; @@ -32,6 +24,7 @@ import { AnomalyThresholdSlider } from '../anomaly_threshold_slider'; import { MlJobSelect } from '../ml_job_select'; import { PickTimeline } from '../pick_timeline'; import { StepContentWrapper } from '../step_content_wrapper'; +import { NextStep } from '../next_step'; import { Field, Form, @@ -269,22 +262,9 @@ const StepDefineRuleComponent: FC = ({ + {!isUpdateView && ( - <> - - - - - {RuleI18n.CONTINUE} - - - - + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.tsx new file mode 100644 index 0000000000000..9c16a61822662 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.tsx @@ -0,0 +1,184 @@ +/* + * 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 { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton, EuiSpacer } from '@elastic/eui'; +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import deepEqual from 'fast-deep-equal'; + +import { setFieldValue } from '../../helpers'; +import { RuleStep, RuleStepProps, ActionsStepRule } from '../../types'; +import { StepRuleDescription } from '../description_step'; +import { Form, UseField, useForm } from '../../../../../shared_imports'; +import { StepContentWrapper } from '../step_content_wrapper'; +import { ThrottleSelectField, THROTTLE_OPTIONS } from '../throttle_select_field'; +import { RuleActionsField } from '../rule_actions_field'; +import { useKibana } from '../../../../../lib/kibana'; +import { schema } from './schema'; +import * as I18n from './translations'; + +interface StepRuleActionsProps extends RuleStepProps { + defaultValues?: ActionsStepRule | null; + actionMessageParams: string[]; +} + +const stepActionsDefaultValue = { + enabled: true, + isNew: true, + actions: [], + kibanaSiemAppUrl: '', + throttle: THROTTLE_OPTIONS[0].value, +}; + +const GhostFormField = () => <>; + +const StepRuleActionsComponent: FC = ({ + addPadding = false, + defaultValues, + isReadOnlyView, + isLoading, + isUpdateView = false, + setStepData, + setForm, + actionMessageParams, +}) => { + const [myStepData, setMyStepData] = useState(stepActionsDefaultValue); + const { + services: { application }, + } = useKibana(); + + const { form } = useForm({ + defaultValue: myStepData, + options: { stripEmptyFields: false }, + schema, + }); + + const kibanaAbsoluteUrl = useMemo(() => application.getUrlForApp('siem', { absolute: true }), [ + application, + ]); + + const onSubmit = useCallback( + async (enabled: boolean) => { + if (setStepData) { + setStepData(RuleStep.ruleActions, null, false); + const { isValid: newIsValid, data } = await form.submit(); + if (newIsValid) { + setStepData(RuleStep.ruleActions, { ...data, enabled }, newIsValid); + setMyStepData({ ...data, isNew: false } as ActionsStepRule); + } + } + }, + [form] + ); + + useEffect(() => { + const { isNew, ...initDefaultValue } = myStepData; + if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) { + const myDefaultValues = { + ...defaultValues, + isNew: false, + }; + setMyStepData(myDefaultValues); + setFieldValue(form, schema, myDefaultValues); + } + }, [defaultValues]); + + useEffect(() => { + if (setForm != null) { + setForm(RuleStep.ruleActions, form); + } + }, [form]); + + const updateThrottle = useCallback(throttle => setMyStepData({ ...myStepData, throttle }), [ + myStepData, + setMyStepData, + ]); + + const throttleFieldComponentProps = useMemo( + () => ({ + idAria: 'detectionEngineStepRuleActionsThrottle', + isDisabled: isLoading, + dataTestSubj: 'detectionEngineStepRuleActionsThrottle', + hasNoInitialSelection: false, + handleChange: updateThrottle, + euiFieldProps: { + options: THROTTLE_OPTIONS, + }, + }), + [isLoading, updateThrottle] + ); + + return isReadOnlyView && myStepData != null ? ( + + + + ) : ( + <> + +
+ + {myStepData.throttle !== stepActionsDefaultValue.throttle && ( + <> + + + + + )} + +
+ + {!isUpdateView && ( + <> + + + + + {I18n.COMPLETE_WITHOUT_ACTIVATING} + + + + + {I18n.COMPLETE_WITH_ACTIVATING} + + + + + )} + + ); +}; + +export const StepRuleActions = memo(StepRuleActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx new file mode 100644 index 0000000000000..511427978db3a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx @@ -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. + */ + +import { i18n } from '@kbn/i18n'; + +import { FormSchema } from '../../../../../shared_imports'; + +export const schema: FormSchema = { + actions: {}, + kibanaSiemAppUrl: {}, + throttle: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepRuleActions.fieldThrottleLabel', + { + defaultMessage: 'Actions frequency', + } + ), + helpText: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepRuleActions.fieldThrottleHelpText', + { + defaultMessage: + 'Select when automated actions should be performed if a rule evaluates as true.', + } + ), + }, +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/translations.tsx new file mode 100644 index 0000000000000..67bcc1af8150b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/translations.tsx @@ -0,0 +1,21 @@ +/* + * 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'; + +export const COMPLETE_WITHOUT_ACTIVATING = i18n.translate( + 'xpack.siem.detectionEngine.createRule. stepScheduleRule.completeWithoutActivatingTitle', + { + defaultMessage: 'Create rule without activating it', + } +); + +export const COMPLETE_WITH_ACTIVATING = i18n.translate( + 'xpack.siem.detectionEngine.createRule. stepScheduleRule.completeWithActivatingTitle', + { + defaultMessage: 'Create & activate rule', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx index e365443a79fb8..de9abcefdea2e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import deepEqual from 'fast-deep-equal'; import styled from 'styled-components'; @@ -15,8 +14,8 @@ import { StepRuleDescription } from '../description_step'; import { ScheduleItem } from '../schedule_item_form'; import { Form, UseField, useForm } from '../../../../../shared_imports'; import { StepContentWrapper } from '../step_content_wrapper'; +import { NextStep } from '../next_step'; import { schema } from './schema'; -import * as I18n from './translations'; interface StepScheduleRuleProps extends RuleStepProps { defaultValues?: ScheduleStepRule | null; @@ -27,7 +26,6 @@ const RestrictedWidthContainer = styled.div` `; const stepScheduleDefaultValue = { - enabled: true, interval: '5m', isNew: true, from: '1m', @@ -51,19 +49,16 @@ const StepScheduleRuleComponent: FC = ({ schema, }); - const onSubmit = useCallback( - async (enabled: boolean) => { - if (setStepData) { - setStepData(RuleStep.scheduleRule, null, false); - const { isValid: newIsValid, data } = await form.submit(); - if (newIsValid) { - setStepData(RuleStep.scheduleRule, { ...data, enabled }, newIsValid); - setMyStepData({ ...data, isNew: false } as ScheduleStepRule); - } + const onSubmit = useCallback(async () => { + if (setStepData) { + setStepData(RuleStep.scheduleRule, null, false); + const { isValid: newIsValid, data } = await form.submit(); + if (newIsValid) { + setStepData(RuleStep.scheduleRule, { ...data }, newIsValid); + setMyStepData({ ...data, isNew: false } as ScheduleStepRule); } - }, - [form] - ); + } + }, [form]); useEffect(() => { const { isNew, ...initDefaultValue } = myStepData; @@ -118,37 +113,7 @@ const StepScheduleRuleComponent: FC = ({ {!isUpdateView && ( - <> - - - - - {I18n.COMPLETE_WITHOUT_ACTIVATING} - - - - - {I18n.COMPLETE_WITH_ACTIVATING} - - - - + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.tsx new file mode 100644 index 0000000000000..0cf15c41a0f91 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.tsx @@ -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 React, { useCallback } from 'react'; + +import { + NOTIFICATION_THROTTLE_RULE, + NOTIFICATION_THROTTLE_NO_ACTIONS, +} from '../../../../../../common/constants'; +import { SelectField } from '../../../../../shared_imports'; + +export const THROTTLE_OPTIONS = [ + { value: NOTIFICATION_THROTTLE_NO_ACTIONS, text: 'Perform no actions' }, + { value: NOTIFICATION_THROTTLE_RULE, text: 'On each rule execution' }, + { value: '1h', text: 'Hourly' }, + { value: '1d', text: 'Daily' }, + { value: '7d', text: 'Weekly' }, +]; + +type ThrottleSelectField = typeof SelectField; + +export const ThrottleSelectField: ThrottleSelectField = props => { + const onChange = useCallback( + e => { + const throttle = e.target.value; + props.field.setValue(throttle); + props.handleChange(throttle); + }, + [props.field.setValue, props.handleChange] + ); + const newEuiFieldProps = { ...props.euiFieldProps, onChange }; + return ; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts index dc0459c54adb0..212147ec6d4d8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts @@ -9,7 +9,9 @@ import { DefineStepRuleJson, ScheduleStepRuleJson, AboutStepRuleJson, + ActionsStepRuleJson, AboutStepRule, + ActionsStepRule, ScheduleStepRule, DefineStepRule, } from '../types'; @@ -18,6 +20,7 @@ import { formatDefineStepData, formatScheduleStepData, formatAboutStepData, + formatActionsStepData, formatRule, filterRuleFieldsForType, } from './helpers'; @@ -26,6 +29,7 @@ import { mockQueryBar, mockScheduleStepRule, mockAboutStepRule, + mockActionsStepRule, } from '../all/__mocks__/mock'; describe('helpers', () => { @@ -241,7 +245,6 @@ describe('helpers', () => { test('returns formatted object as ScheduleStepRuleJson', () => { const result: ScheduleStepRuleJson = formatScheduleStepData(mockData); const expected = { - enabled: false, from: 'now-660s', to: 'now', interval: '5m', @@ -260,7 +263,6 @@ describe('helpers', () => { delete mockStepData.to; const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); const expected = { - enabled: false, from: 'now-660s', to: 'now', interval: '5m', @@ -279,7 +281,6 @@ describe('helpers', () => { }; const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); const expected = { - enabled: false, from: 'now-660s', to: 'now', interval: '5m', @@ -298,7 +299,6 @@ describe('helpers', () => { }; const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); const expected = { - enabled: false, from: 'now-300s', to: 'now', interval: '5m', @@ -317,7 +317,6 @@ describe('helpers', () => { }; const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); const expected = { - enabled: false, from: 'now-360s', to: 'now', interval: 'random', @@ -503,19 +502,164 @@ describe('helpers', () => { }); }); + describe('formatActionsStepData', () => { + let mockData: ActionsStepRule; + + beforeEach(() => { + mockData = mockActionsStepRule(); + }); + + test('returns formatted object as ActionsStepRuleJson', () => { + const result: ActionsStepRuleJson = formatActionsStepData(mockData); + const expected = { + actions: [], + enabled: false, + meta: { + throttle: 'no_actions', + kibanaSiemAppUrl: 'http://localhost:5601/app/siem', + }, + throttle: null, + }; + + expect(result).toEqual(expected); + }); + + test('returns proper throttle value for no_actions', () => { + const mockStepData = { + ...mockData, + throttle: 'no_actions', + }; + const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); + const expected = { + actions: [], + enabled: false, + meta: { + throttle: mockStepData.throttle, + kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl, + }, + throttle: null, + }; + + expect(result).toEqual(expected); + }); + + test('returns proper throttle value for rule', () => { + const mockStepData = { + ...mockData, + throttle: 'rule', + actions: [ + { + group: 'default', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + }; + const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); + const expected = { + actions: [ + { + group: mockStepData.actions[0].group, + id: mockStepData.actions[0].id, + action_type_id: mockStepData.actions[0].actionTypeId, + params: mockStepData.actions[0].params, + }, + ], + enabled: false, + meta: { + throttle: mockStepData.throttle, + kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl, + }, + throttle: null, + }; + + expect(result).toEqual(expected); + }); + + test('returns proper throttle value for interval', () => { + const mockStepData = { + ...mockData, + throttle: '1d', + actions: [ + { + group: 'default', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + }; + const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); + const expected = { + actions: [ + { + group: mockStepData.actions[0].group, + id: mockStepData.actions[0].id, + action_type_id: mockStepData.actions[0].actionTypeId, + params: mockStepData.actions[0].params, + }, + ], + enabled: false, + meta: { + throttle: mockStepData.throttle, + kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl, + }, + throttle: mockStepData.throttle, + }; + + expect(result).toEqual(expected); + }); + + test('returns actions with action_type_id', () => { + const mockAction = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'ML Rule generated {{state.signals_count}} signals' }, + actionTypeId: '.slack', + }; + + const mockStepData = { + ...mockData, + actions: [mockAction], + }; + const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); + const expected = { + actions: [ + { + group: mockAction.group, + id: mockAction.id, + params: mockAction.params, + action_type_id: mockAction.actionTypeId, + }, + ], + enabled: false, + meta: { + throttle: null, + kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl, + }, + throttle: null, + }; + + expect(result).toEqual(expected); + }); + }); + describe('formatRule', () => { let mockAbout: AboutStepRule; let mockDefine: DefineStepRule; let mockSchedule: ScheduleStepRule; + let mockActions: ActionsStepRule; beforeEach(() => { mockAbout = mockAboutStepRule(); mockDefine = mockDefineStepRule(); mockSchedule = mockScheduleStepRule(); + mockActions = mockActionsStepRule(); }); test('returns NewRule with type of saved_query when saved_id exists', () => { - const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule); + const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions); expect(result.type).toEqual('saved_query'); }); @@ -528,13 +672,18 @@ describe('helpers', () => { saved_id: '', }, }; - const result: NewRule = formatRule(mockDefineStepRuleWithoutSavedId, mockAbout, mockSchedule); + const result: NewRule = formatRule( + mockDefineStepRuleWithoutSavedId, + mockAbout, + mockSchedule, + mockActions + ); expect(result.type).toEqual('query'); }); test('returns NewRule without id if ruleId does not exist', () => { - const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule); + const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions); expect(result.id).toBeUndefined(); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index f8900e6a1129e..7abe5a576c0e5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -6,16 +6,24 @@ import { has, isEmpty } from 'lodash/fp'; import moment from 'moment'; +import deepmerge from 'deepmerge'; +import { + NOTIFICATION_THROTTLE_RULE, + NOTIFICATION_THROTTLE_NO_ACTIONS, +} from '../../../../../common/constants'; import { NewRule, RuleType } from '../../../../containers/detection_engine/rules'; +import { transformAlertToRuleAction } from '../../../../../common/detection_engine/transform_actions'; import { AboutStepRule, DefineStepRule, ScheduleStepRule, + ActionsStepRule, DefineStepRuleJson, ScheduleStepRuleJson, AboutStepRuleJson, + ActionsStepRuleJson, } from '../types'; import { isMlRule } from '../helpers'; @@ -136,12 +144,39 @@ export const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRule }; }; +export const getAlertThrottle = (throttle: string | null) => + throttle && ![NOTIFICATION_THROTTLE_NO_ACTIONS, NOTIFICATION_THROTTLE_RULE].includes(throttle) + ? throttle + : null; + +export const formatActionsStepData = (actionsStepData: ActionsStepRule): ActionsStepRuleJson => { + const { + actions = [], + enabled, + kibanaSiemAppUrl, + throttle = NOTIFICATION_THROTTLE_NO_ACTIONS, + } = actionsStepData; + + return { + actions: actions.map(transformAlertToRuleAction), + enabled, + throttle: actions.length ? getAlertThrottle(throttle) : null, + meta: { + throttle: actions.length ? throttle : NOTIFICATION_THROTTLE_NO_ACTIONS, + kibanaSiemAppUrl, + }, + }; +}; + export const formatRule = ( defineStepData: DefineStepRule, aboutStepData: AboutStepRule, - scheduleData: ScheduleStepRule -): NewRule => ({ - ...formatDefineStepData(defineStepData), - ...formatAboutStepData(aboutStepData), - ...formatScheduleStepData(scheduleData), -}); + scheduleData: ScheduleStepRule, + actionsData: ActionsStepRule +): NewRule => + deepmerge.all([ + formatDefineStepData(defineStepData), + formatAboutStepData(aboutStepData), + formatScheduleStepData(scheduleData), + formatActionsStepData(actionsData), + ]) as NewRule; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index 67aaabfe70fda..0335216672915 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -5,7 +5,7 @@ */ import { EuiButtonEmpty, EuiAccordion, EuiHorizontalRule, EuiPanel, EuiSpacer } from '@elastic/eui'; -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState, useMemo } from 'react'; import { Redirect } from 'react-router-dom'; import styled, { StyledComponent } from 'styled-components'; @@ -21,14 +21,27 @@ import { FormData, FormHook } from '../../../../shared_imports'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; +import { StepRuleActions } from '../components/step_rule_actions'; import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page'; import * as RuleI18n from '../translations'; -import { redirectToDetections } from '../helpers'; -import { AboutStepRule, DefineStepRule, RuleStep, RuleStepData, ScheduleStepRule } from '../types'; +import { redirectToDetections, getActionMessageParams } from '../helpers'; +import { + AboutStepRule, + DefineStepRule, + RuleStep, + RuleStepData, + ScheduleStepRule, + ActionsStepRule, +} from '../types'; import { formatRule } from './helpers'; import * as i18n from './translations'; -const stepsRuleOrder = [RuleStep.defineRule, RuleStep.aboutRule, RuleStep.scheduleRule]; +const stepsRuleOrder = [ + RuleStep.defineRule, + RuleStep.aboutRule, + RuleStep.scheduleRule, + RuleStep.ruleActions, +]; const MyEuiPanel = styled(EuiPanel)<{ zindex?: number; @@ -79,22 +92,31 @@ const CreateRulePageComponent: React.FC = () => { const defineRuleRef = useRef(null); const aboutRuleRef = useRef(null); const scheduleRuleRef = useRef(null); + const ruleActionsRef = useRef(null); const stepsForm = useRef | null>>({ [RuleStep.defineRule]: null, [RuleStep.aboutRule]: null, [RuleStep.scheduleRule]: null, + [RuleStep.ruleActions]: null, }); const stepsData = useRef>({ [RuleStep.defineRule]: { isValid: false, data: {} }, [RuleStep.aboutRule]: { isValid: false, data: {} }, [RuleStep.scheduleRule]: { isValid: false, data: {} }, + [RuleStep.ruleActions]: { isValid: false, data: {} }, }); const [isStepRuleInReadOnlyView, setIsStepRuleInEditView] = useState>({ [RuleStep.defineRule]: false, [RuleStep.aboutRule]: false, [RuleStep.scheduleRule]: false, + [RuleStep.ruleActions]: false, }); const [{ isLoading, isSaved }, setRule] = usePersistRule(); + const actionMessageParams = useMemo( + () => + getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule).ruleType), + [stepsData.current['define-rule'].data] + ); const userHasNoPermissions = canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; @@ -103,7 +125,7 @@ const CreateRulePageComponent: React.FC = () => { stepsData.current[step] = { ...stepsData.current[step], data, isValid }; if (isValid) { const stepRuleIdx = stepsRuleOrder.findIndex(item => step === item); - if ([0, 1].includes(stepRuleIdx)) { + if ([0, 1, 2].includes(stepRuleIdx)) { if (isStepRuleInReadOnlyView[stepsRuleOrder[stepRuleIdx + 1]]) { setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); setIsStepRuleInEditView({ @@ -120,15 +142,17 @@ const CreateRulePageComponent: React.FC = () => { setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); } } else if ( - stepRuleIdx === 2 && + stepRuleIdx === 3 && stepsData.current[RuleStep.defineRule].isValid && - stepsData.current[RuleStep.aboutRule].isValid + stepsData.current[RuleStep.aboutRule].isValid && + stepsData.current[RuleStep.scheduleRule].isValid ) { setRule( formatRule( stepsData.current[RuleStep.defineRule].data as DefineStepRule, stepsData.current[RuleStep.aboutRule].data as AboutStepRule, - stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule + stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule, + stepsData.current[RuleStep.ruleActions].data as ActionsStepRule ) ); } @@ -177,6 +201,14 @@ const CreateRulePageComponent: React.FC = () => { /> ); + const ruleActionsButton = ( + + ); + const openCloseAccordion = (accordionId: RuleStep | null) => { if (accordionId != null) { if (accordionId === RuleStep.defineRule && defineRuleRef.current != null) { @@ -185,6 +217,8 @@ const CreateRulePageComponent: React.FC = () => { aboutRuleRef.current.onToggle(); } else if (accordionId === RuleStep.scheduleRule && scheduleRuleRef.current != null) { scheduleRuleRef.current.onToggle(); + } else if (accordionId === RuleStep.ruleActions && ruleActionsRef.current != null) { + ruleActionsRef.current.onToggle(); } } }; @@ -253,7 +287,7 @@ const CreateRulePageComponent: React.FC = () => { isLoading={isLoading || loading} title={i18n.PAGE_TITLE} /> - + { - + { - + { /> + + + + {i18n.EDIT_RULE} + + ) + } + > + + + + diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx index 8618bf9504861..f89e3206cc67d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx @@ -31,10 +31,17 @@ import { StepPanel } from '../components/step_panel'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; +import { StepRuleActions } from '../components/step_rule_actions'; import { formatRule } from '../create/helpers'; -import { getStepsData, redirectToDetections } from '../helpers'; +import { getStepsData, redirectToDetections, getActionMessageParams } from '../helpers'; import * as ruleI18n from '../translations'; -import { RuleStep, DefineStepRule, AboutStepRule, ScheduleStepRule } from '../types'; +import { + RuleStep, + DefineStepRule, + AboutStepRule, + ScheduleStepRule, + ActionsStepRule, +} from '../types'; import * as i18n from './translations'; interface StepRuleForm { @@ -50,6 +57,10 @@ interface ScheduleStepRuleForm extends StepRuleForm { data: ScheduleStepRule | null; } +interface ActionsStepRuleForm extends StepRuleForm { + data: ActionsStepRule | null; +} + const EditRulePageComponent: FC = () => { const [, dispatchToaster] = useStateToaster(); const { @@ -79,14 +90,20 @@ const EditRulePageComponent: FC = () => { data: null, isValid: false, }); + const [myActionsRuleForm, setMyActionsRuleForm] = useState({ + data: null, + isValid: false, + }); const [selectedTab, setSelectedTab] = useState(); const stepsForm = useRef | null>>({ [RuleStep.defineRule]: null, [RuleStep.aboutRule]: null, [RuleStep.scheduleRule]: null, + [RuleStep.ruleActions]: null, }); const [{ isLoading, isSaved }, setRule] = usePersistRule(); const [tabHasError, setTabHasError] = useState([]); + const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule]); const setStepsForm = useCallback( (step: RuleStep, form: FormHook) => { stepsForm.current[step] = form; @@ -162,6 +179,28 @@ const EditRulePageComponent: FC = () => { ), }, + { + id: RuleStep.ruleActions, + name: ruleI18n.ACTIONS, + content: ( + <> + + + {myActionsRuleForm.data != null && ( + + )} + + + + ), + }, ], [ loading, @@ -170,8 +209,10 @@ const EditRulePageComponent: FC = () => { myAboutRuleForm, myDefineRuleForm, myScheduleRuleForm, + myActionsRuleForm, setStepsForm, stepsForm, + actionMessageParams, ] ); @@ -179,14 +220,18 @@ const EditRulePageComponent: FC = () => { const activeFormId = selectedTab?.id as RuleStep; const activeForm = await stepsForm.current[activeFormId]?.submit(); - const invalidForms = [RuleStep.aboutRule, RuleStep.defineRule, RuleStep.scheduleRule].reduce< - RuleStep[] - >((acc, step) => { + const invalidForms = [ + RuleStep.aboutRule, + RuleStep.defineRule, + RuleStep.scheduleRule, + RuleStep.ruleActions, + ].reduce((acc, step) => { if ( (step === activeFormId && activeForm != null && !activeForm?.isValid) || (step === RuleStep.aboutRule && !myAboutRuleForm.isValid) || (step === RuleStep.defineRule && !myDefineRuleForm.isValid) || - (step === RuleStep.scheduleRule && !myScheduleRuleForm.isValid) + (step === RuleStep.scheduleRule && !myScheduleRuleForm.isValid) || + (step === RuleStep.ruleActions && !myActionsRuleForm.isValid) ) { return [...acc, step]; } @@ -205,21 +250,35 @@ const EditRulePageComponent: FC = () => { : myAboutRuleForm.data) as AboutStepRule, (activeFormId === RuleStep.scheduleRule ? activeForm.data - : myScheduleRuleForm.data) as ScheduleStepRule + : myScheduleRuleForm.data) as ScheduleStepRule, + (activeFormId === RuleStep.ruleActions + ? activeForm.data + : myActionsRuleForm.data) as ActionsStepRule ), ...(ruleId ? { id: ruleId } : {}), }); } else { setTabHasError(invalidForms); } - }, [stepsForm, myAboutRuleForm, myDefineRuleForm, myScheduleRuleForm, selectedTab, ruleId]); + }, [ + stepsForm, + myAboutRuleForm, + myDefineRuleForm, + myScheduleRuleForm, + myActionsRuleForm, + selectedTab, + ruleId, + ]); useEffect(() => { if (rule != null) { - const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ rule }); + const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({ + rule, + }); setMyAboutRuleForm({ data: aboutRuleData, isValid: true }); setMyDefineRuleForm({ data: defineRuleData, isValid: true }); setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true }); + setMyActionsRuleForm({ data: ruleActionsData, isValid: true }); } }, [rule]); @@ -228,6 +287,7 @@ const EditRulePageComponent: FC = () => { if (selectedTab != null) { const ruleStep = selectedTab.id as RuleStep; const respForm = await stepsForm.current[ruleStep]?.submit(); + if (respForm != null) { if (ruleStep === RuleStep.aboutRule) { setMyAboutRuleForm({ @@ -244,6 +304,11 @@ const EditRulePageComponent: FC = () => { data: respForm.data as ScheduleStepRule, isValid: respForm.isValid, }); + } else if (ruleStep === RuleStep.ruleActions) { + setMyActionsRuleForm({ + data: respForm.data as ActionsStepRule, + isValid: respForm.isValid, + }); } } } @@ -255,10 +320,13 @@ const EditRulePageComponent: FC = () => { useEffect(() => { if (rule != null) { - const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ rule }); + const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({ + rule, + }); setMyAboutRuleForm({ data: aboutRuleData, isValid: true }); setMyDefineRuleForm({ data: defineRuleData, isValid: true }); setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true }); + setMyActionsRuleForm({ data: ruleActionsData, isValid: true }); } }, [rule]); @@ -303,6 +371,8 @@ const EditRulePageComponent: FC = () => { return ruleI18n.DEFINITION; } else if (t === RuleStep.scheduleRule) { return ruleI18n.SCHEDULE; + } else if (t === RuleStep.ruleActions) { + return ruleI18n.RULE_ACTIONS; } return t; }) diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx index 3224c605192e6..fbdfcf4fc75d8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx @@ -10,6 +10,7 @@ import { getScheduleStepsData, getStepsData, getAboutStepsData, + getActionsStepsData, getHumanizedDuration, getModifiedAboutDetailsData, determineDetailsValue, @@ -17,16 +18,23 @@ import { import { mockRuleWithEverything, mockRule } from './all/__mocks__/mock'; import { esFilters } from '../../../../../../../../src/plugins/data/public'; import { Rule } from '../../../containers/detection_engine/rules'; -import { AboutStepRule, AboutStepRuleDetails, DefineStepRule, ScheduleStepRule } from './types'; +import { + AboutStepRule, + AboutStepRuleDetails, + DefineStepRule, + ScheduleStepRule, + ActionsStepRule, +} from './types'; describe('rule helpers', () => { describe('getStepsData', () => { - test('returns object with about, define, and schedule step properties formatted', () => { + test('returns object with about, define, schedule and actions step properties formatted', () => { const { defineRuleData, modifiedAboutRuleDetailsData, aboutRuleData, scheduleRuleData, + ruleActionsData, }: GetStepsData = getStepsData({ rule: mockRuleWithEverything('test-id'), }); @@ -98,7 +106,8 @@ describe('rule helpers', () => { }, ], }; - const scheduleRuleStepData = { enabled: true, from: '0s', interval: '5m', isNew: false }; + const scheduleRuleStepData = { from: '0s', interval: '5m', isNew: false }; + const ruleActionsStepData = { enabled: true, throttle: undefined, isNew: false, actions: [] }; const aboutRuleDataDetailsData = { note: '# this is some markdown documentation', description: '24/7', @@ -107,6 +116,7 @@ describe('rule helpers', () => { expect(defineRuleData).toEqual(defineRuleStepData); expect(aboutRuleData).toEqual(aboutRuleStepData); expect(scheduleRuleData).toEqual(scheduleRuleStepData); + expect(ruleActionsData).toEqual(ruleActionsStepData); expect(modifiedAboutRuleDetailsData).toEqual(aboutRuleDataDetailsData); }); }); @@ -274,7 +284,6 @@ describe('rule helpers', () => { const result: ScheduleStepRule = getScheduleStepsData(mockedRule); const expected = { isNew: false, - enabled: mockedRule.enabled, interval: mockedRule.interval, from: '0s', }; @@ -283,6 +292,24 @@ describe('rule helpers', () => { }); }); + describe('getActionsStepsData', () => { + test('returns expected ActionsStepRule rule object', () => { + const mockedRule = { + ...mockRule('test-id'), + actions: [], + }; + const result: ActionsStepRule = getActionsStepsData(mockedRule); + const expected = { + actions: [], + enabled: mockedRule.enabled, + isNew: false, + throttle: undefined, + }; + + expect(result).toEqual(expected); + }); + }); + describe('getModifiedAboutDetailsData', () => { test('returns object with "note" and "description" being those of passed in rule', () => { const result: AboutStepRuleDetails = getModifiedAboutDetailsData( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index 2ace154482a27..50b76552ddc8f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -7,8 +7,11 @@ import dateMath from '@elastic/datemath'; import { get } from 'lodash/fp'; import moment from 'moment'; +import memoizeOne from 'memoize-one'; import { useLocation } from 'react-router-dom'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { Filter } from '../../../../../../../../src/plugins/data/public'; import { Rule, RuleType } from '../../../containers/detection_engine/rules'; import { FormData, FormHook, FormSchema } from '../../../shared_imports'; @@ -18,6 +21,7 @@ import { DefineStepRule, IMitreEnterpriseAttack, ScheduleStepRule, + ActionsStepRule, } from './types'; export interface GetStepsData { @@ -25,6 +29,7 @@ export interface GetStepsData { modifiedAboutRuleDetailsData: AboutStepRuleDetails; defineRuleData: DefineStepRule; scheduleRuleData: ScheduleStepRule; + ruleActionsData: ActionsStepRule; } export const getStepsData = ({ @@ -38,8 +43,29 @@ export const getStepsData = ({ const aboutRuleData: AboutStepRule = getAboutStepsData(rule, detailsView); const modifiedAboutRuleDetailsData: AboutStepRuleDetails = getModifiedAboutDetailsData(rule); const scheduleRuleData: ScheduleStepRule = getScheduleStepsData(rule); + const ruleActionsData: ActionsStepRule = getActionsStepsData(rule); - return { aboutRuleData, modifiedAboutRuleDetailsData, defineRuleData, scheduleRuleData }; + return { + aboutRuleData, + modifiedAboutRuleDetailsData, + defineRuleData, + scheduleRuleData, + ruleActionsData, + }; +}; + +export const getActionsStepsData = ( + rule: Omit & { actions: RuleAlertAction[] } +): ActionsStepRule => { + const { enabled, actions = [], meta } = rule; + + return { + actions: actions?.map(transformRuleToAlertAction), + isNew: false, + throttle: meta?.throttle, + kibanaSiemAppUrl: meta?.kibanaSiemAppUrl, + enabled, + }; }; export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ @@ -60,12 +86,11 @@ export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ }); export const getScheduleStepsData = (rule: Rule): ScheduleStepRule => { - const { enabled, interval, from } = rule; + const { interval, from } = rule; const fromHumanizedValue = getHumanizedDuration(from, interval); return { isNew: false, - enabled, interval, from: fromHumanizedValue, }; @@ -200,3 +225,46 @@ export const redirectToDetections = ( isAuthenticated != null && hasEncryptionKey != null && (!isSignalIndexExists || !isAuthenticated || !hasEncryptionKey); + +export const getActionMessageRuleParams = (ruleType: RuleType): string[] => { + const commonRuleParamsKeys = [ + 'id', + 'name', + 'description', + 'false_positives', + 'rule_id', + 'max_signals', + 'risk_score', + 'output_index', + 'references', + 'severity', + 'timeline_id', + 'timeline_title', + 'threat', + 'type', + 'version', + // 'lists', + ]; + + const ruleParamsKeys = [ + ...commonRuleParamsKeys, + ...(isMlRule(ruleType) + ? ['anomaly_threshold', 'machine_learning_job_id'] + : ['index', 'filters', 'language', 'query', 'saved_id']), + ].sort(); + + return ruleParamsKeys; +}; + +export const getActionMessageParams = memoizeOne((ruleType: RuleType | undefined): string[] => { + if (!ruleType) { + return []; + } + const actionMessageRuleParams = getActionMessageRuleParams(ruleType); + + return [ + 'state.signals_count', + '{context.results_link}', + ...actionMessageRuleParams.map(param => `context.rule.${param}`), + ]; +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts index 5b1ee049371d2..8b5b7a07fda49 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts @@ -306,6 +306,10 @@ export const SCHEDULE_RULE = i18n.translate('xpack.siem.detectionEngine.rules.sc defaultMessage: 'Schedule rule', }); +export const RULE_ACTIONS = i18n.translate('xpack.siem.detectionEngine.rules.ruleActionsTitle', { + defaultMessage: 'Rule actions', +}); + export const DEFINITION = i18n.translate('xpack.siem.detectionEngine.rules.stepDefinitionTitle', { defaultMessage: 'Definition', }); @@ -318,6 +322,10 @@ export const SCHEDULE = i18n.translate('xpack.siem.detectionEngine.rules.stepSch defaultMessage: 'Schedule', }); +export const ACTIONS = i18n.translate('xpack.siem.detectionEngine.rules.stepActionsTitle', { + defaultMessage: 'Actions', +}); + export const OPTIONAL_FIELD = i18n.translate( 'xpack.siem.detectionEngine.rules.optionalFieldDescription', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index d4caa4639f338..c1db24991c17c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../../plugins/alerting/common'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; import { Filter } from '../../../../../../../../src/plugins/data/common'; import { RuleType } from '../../../containers/detection_engine/rules/types'; import { FieldValueQueryBar } from './components/query_bar'; @@ -27,6 +29,7 @@ export enum RuleStep { defineRule = 'define-rule', aboutRule = 'about-rule', scheduleRule = 'schedule-rule', + ruleActions = 'rule-actions', } export type RuleStatusType = 'passive' | 'active' | 'valid'; @@ -76,12 +79,18 @@ export interface DefineStepRule extends StepRuleData { } export interface ScheduleStepRule extends StepRuleData { - enabled: boolean; interval: string; from: string; to?: string; } +export interface ActionsStepRule extends StepRuleData { + actions: AlertAction[]; + enabled: boolean; + kibanaSiemAppUrl?: string; + throttle?: string | null; +} + export interface DefineStepRuleJson { anomaly_threshold?: number; index?: string[]; @@ -108,16 +117,18 @@ export interface AboutStepRuleJson { } export interface ScheduleStepRuleJson { - enabled: boolean; interval: string; from: string; to?: string; meta?: unknown; } -export type MyRule = Omit & { - immutable: boolean; -}; +export interface ActionsStepRuleJson { + actions: RuleAlertAction[]; + enabled: boolean; + throttle?: string | null; + meta?: unknown; +} export interface IMitreAttack { id: string; diff --git a/x-pack/legacy/plugins/siem/public/shared_imports.ts b/x-pack/legacy/plugins/siem/public/shared_imports.ts index edd7812b3bd16..c83433ef129c9 100644 --- a/x-pack/legacy/plugins/siem/public/shared_imports.ts +++ b/x-pack/legacy/plugins/siem/public/shared_imports.ts @@ -18,6 +18,9 @@ export { useForm, ValidationFunc, } from '../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; -export { Field } from '../../../../../src/plugins/es_ui_shared/static/forms/components'; +export { + Field, + SelectField, +} from '../../../../../src/plugins/es_ui_shared/static/forms/components'; export { fieldValidators } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers'; export { ERROR_CODE } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts index f83a8d40d6ae1..189c596a77125 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts @@ -39,7 +39,7 @@ describe('buildSignalsSearchQuery', () => { { range: { '@timestamp': { - gte: from, + gt: from, lte: to, }, }, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts index 001650e5b2005..b973d4c5f4e98 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts @@ -30,7 +30,7 @@ export const buildSignalsSearchQuery = ({ ruleId, index, from, to }: BuildSignal { range: { '@timestamp': { - gte: from, + gt: from, lte: to, }, }, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts index dea42b0c852f9..073251b68f414 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts @@ -42,7 +42,7 @@ describe('createNotifications', () => { const action = { group: 'default', id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', - params: { message: 'Rule generated {{state.signalsCount}} signals' }, + params: { message: 'Rule generated {{state.signals_count}} signals' }, action_type_id: '.slack', }; await createNotifications({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts index 6ae7922660bd7..33cee6d074b70 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts @@ -21,7 +21,7 @@ interface GetSignalsCount { ruleAlertId: string; ruleId: string; index: string; - kibanaUrl: string | undefined; + kibanaSiemAppUrl: string | undefined; callCluster: NotificationExecutorOptions['services']['callCluster']; } @@ -32,7 +32,7 @@ export const getSignalsCount = async ({ ruleId, index, callCluster, - kibanaUrl = '', + kibanaSiemAppUrl = '', }: GetSignalsCount): Promise => { const fromMoment = moment.isDate(from) ? moment(from) : parseScheduleDates(from); const toMoment = moment.isDate(to) ? moment(to) : parseScheduleDates(to); @@ -53,7 +53,7 @@ export const getSignalsCount = async ({ const result = await callCluster('count', query); const resultsLink = getNotificationResultsLink({ - baseUrl: kibanaUrl, + kibanaSiemAppUrl: `${kibanaSiemAppUrl}`, id: ruleAlertId, from: fromInMs, to: toInMs, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts index ff0126b129636..50ac10347e062 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -127,7 +127,7 @@ describe('rules_notification_alert_type', () => { expect(alertInstanceFactoryMock).toHaveBeenCalled(); expect(alertInstanceMock.replaceState).toHaveBeenCalledWith( - expect.objectContaining({ signalsCount: 10 }) + expect.objectContaining({ signals_count: 10 }) ); expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( 'default', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts index c5dc4c3a27e16..32e64138ff6e0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts @@ -40,14 +40,14 @@ export const rulesNotificationAlertType = ({ } const { params: ruleAlertParams, name: ruleName } = ruleAlertSavedObject.attributes; - const ruleParams = { ...ruleAlertParams, name: ruleName }; + const ruleParams = { ...ruleAlertParams, name: ruleName, id: ruleAlertSavedObject.id }; const { signalsCount, resultsLink } = await getSignalsCount({ from: previousStartedAt ?? `now-${ruleParams.interval}`, to: startedAt, index: ruleParams.outputIndex, ruleId: ruleParams.ruleId!, - kibanaUrl: ruleAlertParams.meta?.kibanaUrl as string, + kibanaSiemAppUrl: ruleAlertParams.meta?.kibanaSiemAppUrl as string, ruleAlertId: ruleAlertSavedObject.id, callCluster: services.callCluster, }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts index 9c38c88a12411..b858b25377ffe 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { mapKeys, snakeCase } from 'lodash/fp'; import { AlertInstance } from '../../../../../../../plugins/alerting/server'; import { RuleTypeParams } from '../types'; type NotificationRuleTypeParams = RuleTypeParams & { name: string; + id: string; }; interface ScheduleNotificationActions { @@ -26,9 +28,9 @@ export const scheduleNotificationActions = ({ }: ScheduleNotificationActions): AlertInstance => alertInstance .replaceState({ - signalsCount, + signals_count: signalsCount, }) .scheduleActions('default', { - resultsLink, - rule: ruleParams, + results_link: resultsLink, + rule: mapKeys(snakeCase, ruleParams), }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts index e1b452c911443..4c077dd9fc1fb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts @@ -88,7 +88,7 @@ describe('updateNotifications', () => { const action = { group: 'default', id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', - params: { message: 'Rule generated {{state.signalsCount}} signals' }, + params: { message: 'Rule generated {{state.signals_count}} signals' }, action_type_id: '.slack', }; await updateNotifications({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts index 4c3f311d10acc..0d363e1f6f3c2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts @@ -9,7 +9,7 @@ import { getNotificationResultsLink } from './utils'; describe('utils', () => { it('getNotificationResultsLink', () => { const resultLink = getNotificationResultsLink({ - baseUrl: 'http://localhost:5601', + kibanaSiemAppUrl: 'http://localhost:5601/app/siem', id: 'notification-id', from: '00000', to: '1111', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts index ed502d31d2fb5..b8a3c4199c4f0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts @@ -5,14 +5,14 @@ */ export const getNotificationResultsLink = ({ - baseUrl, + kibanaSiemAppUrl, id, from, to, }: { - baseUrl: string; + kibanaSiemAppUrl: string; id: string; from: string; to: string; }) => - `${baseUrl}/app/siem#/detections/rules/id/${id}?timerange=(global:(linkTo:!(timeline),timerange:(from:${from},kind:absolute,to:${to})),timeline:(linkTo:!(global),timerange:(from:${from},kind:absolute,to:${to})))`; + `${kibanaSiemAppUrl}#/detections/rules/id/${id}?timerange=(global:(linkTo:!(timeline),timerange:(from:${from},kind:absolute,to:${to})),timeline:(linkTo:!(global),timerange:(from:${from},kind:absolute,to:${to})))`; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 10e53d94de90a..0ecc1aa28e7e0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -335,7 +335,7 @@ export const createRuleWithActionsRequest = () => { { group: 'default', id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', - params: { message: 'Rule generated {{state.signalsCount}} signals' }, + params: { message: 'Rule generated {{state.signals_count}} signals' }, action_type_id: '.slack', }, ], @@ -668,7 +668,8 @@ export const getNotificationResult = (): RuleNotificationAlertType => ({ { actionTypeId: '.slack', params: { - message: 'Rule generated {{state.signalsCount}} signals\n\n{{rule.name}}\n{{resultsLink}}', + message: + 'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}', }, group: 'default', id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 03d48a6b27867..c004b3d0edd1c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -214,13 +214,14 @@ export const signalRulesAlertType = ({ const notificationRuleParams = { ...ruleParams, name, + id: savedObject.id, }; const { signalsCount, resultsLink } = await getSignalsCount({ from: `now-${interval}`, to: 'now', index: ruleParams.outputIndex, ruleId: ruleParams.ruleId!, - kibanaUrl: meta?.kibanaUrl as string, + kibanaSiemAppUrl: meta.kibanaSiemAppUrl as string, ruleAlertId: savedObject.id, callCluster: services.callCluster, }); From 49c7518976a8f17a430902193ff1b6e0e7171d52 Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Tue, 24 Mar 2020 16:50:09 -0700 Subject: [PATCH 43/56] Bump ems-client to 7.7.1 (#61153) * Bump ems-client to 7.7.1 * Update in x-pack dir, too * erg, yarn lock Co-authored-by: Elastic Machine --- package.json | 2 +- x-pack/package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 9c8cc6538269f..4baffa8719fe3 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "@elastic/apm-rum": "^4.6.0", "@elastic/charts": "^18.1.0", "@elastic/datemath": "5.0.2", - "@elastic/ems-client": "7.7.0", + "@elastic/ems-client": "7.7.1", "@elastic/eui": "21.0.1", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", diff --git a/x-pack/package.json b/x-pack/package.json index 1fd4c261474f7..5e8bffb27a3bf 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -181,7 +181,7 @@ "@babel/runtime": "^7.5.5", "@elastic/apm-rum-react": "^0.3.2", "@elastic/datemath": "5.0.2", - "@elastic/ems-client": "7.7.0", + "@elastic/ems-client": "7.7.1", "@elastic/eui": "21.0.1", "@elastic/filesaver": "1.1.2", "@elastic/maki": "6.2.0", diff --git a/yarn.lock b/yarn.lock index 5c988ccd3e736..27ba50b07280d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1915,10 +1915,10 @@ once "^1.4.0" pump "^3.0.0" -"@elastic/ems-client@7.7.0": - version "7.7.0" - resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-7.7.0.tgz#7d36d716dd941f060b9fcdae94f186a9aecc5cc2" - integrity sha512-JatsSyLik/8MTEOEimzEZ3NYjvGL1YzjbGujuSCgaXhPRqzu/wvMLEL8dlVpmYFZ7ALbGNsVdho4Hr8tngsIMw== +"@elastic/ems-client@7.7.1": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-7.7.1.tgz#cda9277cb851b6d1aa0408fe2814de98f1474fb8" + integrity sha512-8ikEUbsM+wxENqi/cwrmo4+2vwZkVoFDPSIrw3bQG2mQaE3l+3w1eMPKxsvQq0r79ivzXJ51gkvr8CffBkBkGw== dependencies: lodash "^4.17.15" node-fetch "^1.7.3" From f9ad60d4900a6b8a0a6849da464517eac80008c5 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Tue, 24 Mar 2020 16:55:05 -0700 Subject: [PATCH 44/56] Renames apm metric event 'service_map_object_hover' to 'service_map_node_or_edge_hover' for more clarity (#61178) --- .../plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index 168b470580279..54a1b4347e29b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -172,7 +172,7 @@ export function Cytoscape({ }); }; const mouseoverHandler: cytoscape.EventHandler = event => { - trackApmEvent({ metric: 'service_map_object_hover' }); + trackApmEvent({ metric: 'service_map_node_or_edge_hover' }); event.target.addClass('hover'); event.target.connectedEdges().addClass('nodeHover'); }; From 2ad68f0e9811f94115dae4df5fc976de2ce398d1 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Wed, 25 Mar 2020 00:20:40 +0000 Subject: [PATCH 45/56] [SIEM] Import timeline (#60880) * add import timelines route * update timeline * sync with master * wip * wip * update timeline * overwrite pinned events * clean up * init server side unit test * add server side unit test * clean up unit test * unit test * add unit tests * clean up * clean up * fix unit test * fix types and unit tests * rename constants * fix validation schemas * review * fix schemas * functional test * skip a functinal test * add unit tests * code review * review with angela * fix tests * update modal label * rename folder to align component name * fix types * fix unit test Co-authored-by: Elastic Machine Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> --- .../legacy/plugins/siem/common/constants.ts | 1 + .../__snapshots__/index.test.tsx.snap | 14 +- .../import_data_modal}/index.test.tsx | 19 +- .../import_data_modal}/index.tsx | 75 ++-- .../import_data_modal/translations.ts | 14 + .../public/components/open_timeline/index.tsx | 9 +- .../open_timeline/open_timeline.tsx | 39 +- .../components/open_timeline/translations.ts | 58 +++ .../public/components/open_timeline/types.ts | 7 +- .../public/containers/timeline/all/api.ts | 20 +- .../import_rule_modal/translations.ts | 68 ---- .../pages/detection_engine/rules/index.tsx | 18 +- .../detection_engine/rules/translations.ts | 54 +++ .../public/pages/timelines/timelines_page.tsx | 15 +- .../siem/server/lib/note/saved_object.ts | 4 +- .../server/lib/pinned_event/saved_object.ts | 5 +- .../create_timelines_stream_from_ndjson.ts | 46 +++ .../routes/__mocks__/import_timelines.ts | 177 +++++++++ .../routes/__mocks__/request_responses.ts | 22 +- .../timeline/routes/export_timelines_route.ts | 2 +- .../routes/import_timelines_route.test.ts | 341 ++++++++++++++++++ .../timeline/routes/import_timelines_route.ts | 226 ++++++++++++ .../routes/schemas/import_timelines_schema.ts | 57 +++ .../lib/timeline/routes/schemas/schemas.ts | 155 +++++++- .../{utils.ts => utils/export_timelines.ts} | 48 ++- .../timeline/routes/utils/import_timelines.ts | 191 ++++++++++ .../siem/server/lib/timeline/saved_object.ts | 15 +- x-pack/legacy/plugins/siem/server/plugin.ts | 3 +- .../plugins/siem/server/routes/index.ts | 6 +- .../apis/siem/saved_objects/timeline.ts | 4 +- 30 files changed, 1560 insertions(+), 153 deletions(-) rename x-pack/legacy/plugins/siem/public/{pages/detection_engine/rules/components/import_rule_modal => components/import_data_modal}/__snapshots__/index.test.tsx.snap (75%) rename x-pack/legacy/plugins/siem/public/{pages/detection_engine/rules/components/import_rule_modal => components/import_data_modal}/index.test.tsx (51%) rename x-pack/legacy/plugins/siem/public/{pages/detection_engine/rules/components/import_rule_modal => components/import_data_modal}/index.tsx (65%) create mode 100644 x-pack/legacy/plugins/siem/public/components/import_data_modal/translations.ts delete mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/import_timelines.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts rename x-pack/legacy/plugins/siem/server/lib/timeline/routes/{utils.ts => utils/export_timelines.ts} (85%) create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index 85c00562f986b..662fb8fb8ef68 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -82,6 +82,7 @@ export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL = `${DETECTION_ENGINE export const TIMELINE_URL = '/api/timeline'; export const TIMELINE_EXPORT_URL = `${TIMELINE_URL}/_export`; +export const TIMELINE_IMPORT_URL = `${TIMELINE_URL}/_import`; /** * Default signals index key for kibana.dev.yml diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/import_data_modal/__snapshots__/index.test.tsx.snap similarity index 75% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap rename to x-pack/legacy/plugins/siem/public/components/import_data_modal/__snapshots__/index.test.tsx.snap index 6b5ea2c5390f1..6503dd8dfb508 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/import_data_modal/__snapshots__/index.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ImportRuleModal renders correctly against snapshot 1`] = ` +exports[`ImportDataModal renders correctly against snapshot 1`] = ` - Import rule + title @@ -17,7 +17,7 @@ exports[`ImportRuleModal renders correctly against snapshot 1`] = ` size="s" >

- Select a SIEM rule (as exported from the Detection Engine UI) to import + description

@@ -39,9 +39,9 @@ exports[`ImportRuleModal renders correctly against snapshot 1`] = ` checked={false} compressed={false} disabled={false} - id="rule-overwrite-saved-object" + id="import-data-modal-checkbox-label" indeterminate={false} - label="Automatically overwrite saved objects with the same rule ID" + label="checkBoxLabel" onChange={[Function]} />
@@ -56,7 +56,7 @@ exports[`ImportRuleModal renders correctly against snapshot 1`] = ` fill={true} onClick={[Function]} > - Import rule + submitBtnText
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.test.tsx similarity index 51% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.test.tsx rename to x-pack/legacy/plugins/siem/public/components/import_data_modal/index.test.tsx index e10194853e7f9..85dcf9eeb3e5e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.test.tsx @@ -6,17 +6,26 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { ImportRuleModalComponent } from './index'; +import { ImportDataModalComponent } from './index'; +jest.mock('../../lib/kibana'); -jest.mock('../../../../../lib/kibana'); - -describe('ImportRuleModal', () => { +describe('ImportDataModal', () => { test('renders correctly against snapshot', () => { const wrapper = shallow( - 'successMessage')} + title="title" /> ); expect(wrapper).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.tsx similarity index 65% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx rename to x-pack/legacy/plugins/siem/public/components/import_data_modal/index.tsx index 49a181a1cd897..503710f1ee8aa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.tsx @@ -21,29 +21,49 @@ import { } from '@elastic/eui'; import React, { useCallback, useState } from 'react'; -import { importRules } from '../../../../../containers/detection_engine/rules'; +import { ImportRulesResponse, ImportRulesProps } from '../../containers/detection_engine/rules'; import { displayErrorToast, displaySuccessToast, useStateToaster, errorToToaster, -} from '../../../../../components/toasters'; +} from '../toasters'; import * as i18n from './translations'; -interface ImportRuleModalProps { - showModal: boolean; +interface ImportDataModalProps { + checkBoxLabel: string; closeModal: () => void; + description: string; + errorMessage: string; + failedDetailed: (id: string, statusCode: number, message: string) => string; importComplete: () => void; + importData: (arg: ImportRulesProps) => Promise; + showCheckBox: boolean; + showModal: boolean; + submitBtnText: string; + subtitle: string; + successMessage: (totalCount: number) => string; + title: string; } /** * Modal component for importing Rules from a json file */ -export const ImportRuleModalComponent = ({ - showModal, +export const ImportDataModalComponent = ({ + checkBoxLabel, closeModal, + description, + errorMessage, + failedDetailed, importComplete, -}: ImportRuleModalProps) => { + importData, + showCheckBox = true, + showModal, + submitBtnText, + subtitle, + successMessage, + title, +}: ImportDataModalProps) => { const [selectedFiles, setSelectedFiles] = useState(null); const [isImporting, setIsImporting] = useState(false); const [overwrite, setOverwrite] = useState(false); @@ -61,7 +81,7 @@ export const ImportRuleModalComponent = ({ const abortCtrl = new AbortController(); try { - const importResponse = await importRules({ + const importResponse = await importData({ fileToImport: selectedFiles[0], overwrite, signal: abortCtrl.signal, @@ -70,23 +90,20 @@ export const ImportRuleModalComponent = ({ // TODO: Improve error toast details for better debugging failed imports // e.g. When success == true && success_count === 0 that means no rules were overwritten, etc if (importResponse.success) { - displaySuccessToast( - i18n.SUCCESSFULLY_IMPORTED_RULES(importResponse.success_count), - dispatchToaster - ); + displaySuccessToast(successMessage(importResponse.success_count), dispatchToaster); } if (importResponse.errors.length > 0) { const formattedErrors = importResponse.errors.map(e => - i18n.IMPORT_FAILED_DETAILED(e.rule_id, e.error.status_code, e.error.message) + failedDetailed(e.rule_id, e.error.status_code, e.error.message) ); - displayErrorToast(i18n.IMPORT_FAILED, formattedErrors, dispatchToaster); + displayErrorToast(errorMessage, formattedErrors, dispatchToaster); } importComplete(); cleanupAndCloseModal(); } catch (error) { cleanupAndCloseModal(); - errorToToaster({ title: i18n.IMPORT_FAILED, error, dispatchToaster }); + errorToToaster({ title: errorMessage, error, dispatchToaster }); } } }, [selectedFiles, overwrite]); @@ -102,18 +119,18 @@ export const ImportRuleModalComponent = ({ - {i18n.IMPORT_RULE} + {title} -

{i18n.SELECT_RULE}

+

{description}

{ setSelectedFiles(files && files.length > 0 ? files : null); }} @@ -122,12 +139,14 @@ export const ImportRuleModalComponent = ({ isLoading={isImporting} /> - setOverwrite(!overwrite)} - /> + {showCheckBox && ( + setOverwrite(!overwrite)} + /> + )}
@@ -137,7 +156,7 @@ export const ImportRuleModalComponent = ({ disabled={selectedFiles == null || isImporting} fill > - {i18n.IMPORT_RULE} + {submitBtnText}
@@ -147,8 +166,8 @@ export const ImportRuleModalComponent = ({ ); }; -ImportRuleModalComponent.displayName = 'ImportRuleModalComponent'; +ImportDataModalComponent.displayName = 'ImportDataModalComponent'; -export const ImportRuleModal = React.memo(ImportRuleModalComponent); +export const ImportDataModal = React.memo(ImportDataModalComponent); -ImportRuleModal.displayName = 'ImportRuleModal'; +ImportDataModal.displayName = 'ImportDataModal'; diff --git a/x-pack/legacy/plugins/siem/public/components/import_data_modal/translations.ts b/x-pack/legacy/plugins/siem/public/components/import_data_modal/translations.ts new file mode 100644 index 0000000000000..3fe8f2e3ee4bb --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/import_data_modal/translations.ts @@ -0,0 +1,14 @@ +/* + * 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'; + +export const CANCEL_BUTTON = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.cancelTitle', + { + defaultMessage: 'Cancel', + } +); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx index 6d00edf28a88f..6c2cd21d808b7 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx @@ -52,7 +52,10 @@ interface OwnProps { } export type OpenTimelineOwnProps = OwnProps & - Pick & + Pick< + OpenTimelineProps, + 'defaultPageSize' | 'title' | 'importCompleteToggle' | 'setImportCompleteToggle' + > & PropsFromRedux; /** Returns a collection of selected timeline ids */ @@ -74,7 +77,9 @@ export const StatefulOpenTimelineComponent = React.memo( defaultPageSize, hideActions = [], isModal = false, + importCompleteToggle, onOpenTimeline, + setImportCompleteToggle, timeline, title, updateTimeline, @@ -264,6 +269,7 @@ export const StatefulOpenTimelineComponent = React.memo( defaultPageSize={defaultPageSize} isLoading={loading} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} + importCompleteToggle={importCompleteToggle} onAddTimelinesToFavorites={undefined} onDeleteSelected={onDeleteSelected} onlyFavorites={onlyFavorites} @@ -278,6 +284,7 @@ export const StatefulOpenTimelineComponent = React.memo( query={search} refetch={refetch} searchResults={timelines} + setImportCompleteToggle={setImportCompleteToggle} selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index b1b100349eb86..8b3da4427a362 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -12,8 +12,10 @@ import { OpenTimelineProps, OpenTimelineResult } from './types'; import { SearchRow } from './search_row'; import { TimelinesTable } from './timelines_table'; import { TitleRow } from './title_row'; - +import { ImportDataModal } from '../import_data_modal'; import * as i18n from './translations'; +import { importTimelines } from '../../containers/timeline/all/api'; + import { UtilityBarGroup, UtilityBarText, @@ -31,6 +33,7 @@ export const OpenTimeline = React.memo( defaultPageSize, isLoading, itemIdToExpandedNotesRowMap, + importCompleteToggle, onAddTimelinesToFavorites, onDeleteSelected, onlyFavorites, @@ -47,6 +50,7 @@ export const OpenTimeline = React.memo( searchResults, selectedItems, sortDirection, + setImportCompleteToggle, sortField, title, totalSearchResultsCount, @@ -93,9 +97,25 @@ export const OpenTimeline = React.memo( ); const onRefreshBtnClick = useCallback(() => { - if (typeof refetch === 'function') refetch(); + if (refetch != null) { + refetch(); + } }, [refetch]); + const handleCloseModal = useCallback(() => { + if (setImportCompleteToggle != null) { + setImportCompleteToggle(false); + } + }, [setImportCompleteToggle]); + const handleComplete = useCallback(() => { + if (setImportCompleteToggle != null) { + setImportCompleteToggle(false); + } + if (refetch != null) { + refetch(); + } + }, [setImportCompleteToggle, refetch]); + return ( <> ( onComplete={onCompleteEditTimelineAction} title={actionItem?.title ?? i18n.UNTITLED_TIMELINE} /> + defaultMessage: 'Successfully exported {totalTimelines, plural, =0 {all timelines} =1 {{totalTimelines} timeline} other {{totalTimelines} timelines}}', }); + +export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.importTimelineTitle', + { + defaultMessage: 'Import timeline', + } +); + +export const SELECT_TIMELINE = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.selectTimelineDescription', + { + defaultMessage: 'Select a SIEM timeline (as exported from the Timeline view) to import', + } +); + +export const INITIAL_PROMPT_TEXT = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.initialPromptTextDescription', + { + defaultMessage: 'Select or drag and drop a valid timelines_export.ndjson file', + } +); + +export const OVERWRITE_WITH_SAME_NAME = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.overwriteDescription', + { + defaultMessage: 'Automatically overwrite saved objects with the same timeline ID', + } +); + +export const SUCCESSFULLY_IMPORTED_TIMELINES = (totalCount: number) => + i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.successfullyImportedTimelinesTitle', + { + values: { totalCount }, + defaultMessage: + 'Successfully imported {totalCount} {totalCount, plural, =1 {timeline} other {timelines}}', + } + ); + +export const IMPORT_FAILED = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.importFailedTitle', + { + defaultMessage: 'Failed to import timelines', + } +); + +export const IMPORT_TIMELINE = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.importTitle', + { + defaultMessage: 'Import timeline…', + } +); + +export const IMPORT_FAILED_DETAILED = (id: string, statusCode: number, message: string) => + i18n.translate('xpack.siem.timelines.components.importTimelineModal.importFailedDetailedTitle', { + values: { id, statusCode, message }, + defaultMessage: 'Timeline ID: {id}\n Status Code: {statusCode}\n Message: {message}', + }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index b466ea32799d9..1265c056ec506 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -120,6 +120,8 @@ export interface OpenTimelineProps { isLoading: boolean; /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ itemIdToExpandedNotesRowMap: Record; + /** Display import timelines modal*/ + importCompleteToggle?: boolean; /** If this callback is specified, a "Favorite Selected" button will be displayed, and this callback will be invoked when the button is clicked */ onAddTimelinesToFavorites?: OnAddTimelinesToFavorites; /** If this callback is specified, a "Delete Selected" button will be displayed, and this callback will be invoked when the button is clicked */ @@ -144,13 +146,14 @@ export interface OpenTimelineProps { pageSize: number; /** The currently applied search criteria */ query: string; - /** Refetch timelines data */ + /** Refetch table */ refetch?: Refetch; - /** The results of executing a search */ searchResults: OpenTimelineResult[]; /** the currently-selected timelines in the table */ selectedItems: OpenTimelineResult[]; + /** Toggle export timelines modal*/ + setImportCompleteToggle?: React.Dispatch>; /** the requested sort direction of the query results */ sortDirection: 'asc' | 'desc'; /** the requested field to sort on */ diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts b/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts index edda2e30ea400..0479851fc5b55 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts @@ -4,9 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ImportRulesProps, ImportRulesResponse } from '../../detection_engine/rules'; import { KibanaServices } from '../../../lib/kibana'; +import { TIMELINE_IMPORT_URL, TIMELINE_EXPORT_URL } from '../../../../common/constants'; import { ExportSelectedData } from '../../../components/generic_downloader'; -import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; + +export const importTimelines = async ({ + fileToImport, + overwrite = false, + signal, +}: ImportRulesProps): Promise => { + const formData = new FormData(); + formData.append('file', fileToImport); + + return KibanaServices.get().http.fetch(`${TIMELINE_IMPORT_URL}`, { + method: 'POST', + headers: { 'Content-Type': undefined }, + query: { overwrite }, + body: formData, + signal, + }); +}; export const exportSelectedTimeline: ExportSelectedData = async ({ excludeExportDetails = false, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts deleted file mode 100644 index dab1c9490591f..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts +++ /dev/null @@ -1,68 +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 { i18n } from '@kbn/i18n'; - -export const IMPORT_RULE = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.importRuleTitle', - { - defaultMessage: 'Import rule', - } -); - -export const SELECT_RULE = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.selectRuleDescription', - { - defaultMessage: 'Select a SIEM rule (as exported from the Detection Engine UI) to import', - } -); - -export const INITIAL_PROMPT_TEXT = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.initialPromptTextDescription', - { - defaultMessage: 'Select or drag and drop a valid rules_export.ndjson file', - } -); - -export const OVERWRITE_WITH_SAME_NAME = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.overwriteDescription', - { - defaultMessage: 'Automatically overwrite saved objects with the same rule ID', - } -); - -export const CANCEL_BUTTON = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.cancelTitle', - { - defaultMessage: 'Cancel', - } -); - -export const SUCCESSFULLY_IMPORTED_RULES = (totalRules: number) => - i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.successfullyImportedRulesTitle', - { - values: { totalRules }, - defaultMessage: - 'Successfully imported {totalRules} {totalRules, plural, =1 {rule} other {rules}}', - } - ); - -export const IMPORT_FAILED = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.importFailedTitle', - { - defaultMessage: 'Failed to import rules', - } -); - -export const IMPORT_FAILED_DETAILED = (ruleId: string, statusCode: number, message: string) => - i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.importFailedDetailedTitle', - { - values: { ruleId, statusCode, message }, - defaultMessage: 'Rule ID: {ruleId}\n Status Code: {statusCode}\n Message: {message}', - } - ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx index b2c17fb8d38a8..d228ded5dd741 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx @@ -8,7 +8,7 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback, useRef, useState } from 'react'; import { Redirect } from 'react-router-dom'; -import { usePrePackagedRules } from '../../../containers/detection_engine/rules'; +import { usePrePackagedRules, importRules } from '../../../containers/detection_engine/rules'; import { DETECTION_ENGINE_PAGE_NAME, getDetectionEngineUrl, @@ -20,7 +20,7 @@ import { SpyRoute } from '../../../utils/route/spy_routes'; import { useUserInfo } from '../components/user_info'; import { AllRules } from './all'; -import { ImportRuleModal } from './components/import_rule_modal'; +import { ImportDataModal } from '../../../components/import_data_modal'; import { ReadOnlyCallOut } from './components/read_only_callout'; import { UpdatePrePackagedRulesCallOut } from './components/pre_packaged_rules/update_callout'; import { getPrePackagedRuleStatus, redirectToDetections } from './helpers'; @@ -96,10 +96,20 @@ const RulesPageComponent: React.FC = () => { return ( <> {userHasNoPermissions && } - setShowImportModal(false)} + description={i18n.SELECT_RULE} + errorMessage={i18n.IMPORT_FAILED} + failedDetailed={i18n.IMPORT_FAILED_DETAILED} importComplete={handleRefreshRules} + importData={importRules} + successMessage={i18n.SUCCESSFULLY_IMPORTED_RULES} + showCheckBox={true} + showModal={showImportModal} + submitBtnText={i18n.IMPORT_RULE_BTN_TITLE} + subtitle={i18n.INITIAL_PROMPT_TEXT} + title={i18n.IMPORT_RULE} /> defaultMessage: 'Reload {missingRules} deleted Elastic prebuilt {missingRules, plural, =1 {rule} other {rules}} ', }); + +export const IMPORT_RULE_BTN_TITLE = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.importRuleTitle', + { + defaultMessage: 'Import rule', + } +); + +export const SELECT_RULE = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.selectRuleDescription', + { + defaultMessage: 'Select a SIEM rule (as exported from the Detection Engine view) to import', + } +); + +export const INITIAL_PROMPT_TEXT = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.initialPromptTextDescription', + { + defaultMessage: 'Select or drag and drop a valid rules_export.ndjson file', + } +); + +export const OVERWRITE_WITH_SAME_NAME = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.overwriteDescription', + { + defaultMessage: 'Automatically overwrite saved objects with the same rule ID', + } +); + +export const SUCCESSFULLY_IMPORTED_RULES = (totalRules: number) => + i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.successfullyImportedRulesTitle', + { + values: { totalRules }, + defaultMessage: + 'Successfully imported {totalRules} {totalRules, plural, =1 {rule} other {rules}}', + } + ); + +export const IMPORT_FAILED = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.importFailedTitle', + { + defaultMessage: 'Failed to import rules', + } +); + +export const IMPORT_FAILED_DETAILED = (ruleId: string, statusCode: number, message: string) => + i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.importFailedDetailedTitle', + { + values: { ruleId, statusCode, message }, + defaultMessage: 'Rule ID: {ruleId}\n Status Code: {statusCode}\n Message: {message}', + } + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx index 6d30ea58089f0..38462e6526454 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx @@ -5,9 +5,10 @@ */ import ApolloClient from 'apollo-client'; -import React from 'react'; +import React, { useState, useCallback } from 'react'; import styled from 'styled-components'; +import { EuiButton } from '@elastic/eui'; import { HeaderPage } from '../../components/header_page'; import { StatefulOpenTimeline } from '../../components/open_timeline'; import { WrapperPage } from '../../components/wrapper_page'; @@ -27,16 +28,26 @@ type OwnProps = TimelinesProps; export const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; const TimelinesPageComponent: React.FC = ({ apolloClient }) => { + const [importCompleteToggle, setImportCompleteToggle] = useState(false); + const onImportTimelineBtnClick = useCallback(() => { + setImportCompleteToggle(true); + }, [setImportCompleteToggle]); return ( <> - + + + {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} + + diff --git a/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts index b6a43fc523adb..23162f38bffba 100644 --- a/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts @@ -141,6 +141,8 @@ export class Note { } // Update new note + + const existingNote = await this.getSavedNote(request, noteId); return { code: 200, message: 'success', @@ -150,7 +152,7 @@ export class Note { noteId, pickSavedNote(noteId, note, request.user), { - version: version || undefined, + version: existingNote.version || undefined, } ) ), diff --git a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts index 9ea950e8a443b..a95c1da197f57 100644 --- a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts @@ -89,7 +89,7 @@ export class PinnedEvent { public async persistPinnedEventOnTimeline( request: FrameworkRequest, - pinnedEventId: string | null, + pinnedEventId: string | null, // pinned event saved object id eventId: string, timelineId: string | null ): Promise { @@ -116,6 +116,7 @@ export class PinnedEvent { const isPinnedAlreadyExisting = allPinnedEventId.filter( pinnedEvent => pinnedEvent.eventId === eventId ); + if (isPinnedAlreadyExisting.length === 0) { const savedPinnedEvent: SavedPinnedEvent = { eventId, @@ -204,7 +205,7 @@ export const convertSavedObjectToSavedPinnedEvent = ( // then this interface does not allow types without index signature // this is limiting us with our type for now so the easy way was to use any -const pickSavedPinnedEvent = ( +export const pickSavedPinnedEvent = ( pinnedEventId: string | null, savedPinnedEvent: SavedPinnedEvent, userInfo: AuthenticatedUser | null diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts new file mode 100644 index 0000000000000..5373570a4f8cc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts @@ -0,0 +1,46 @@ +/* + * 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 { Transform } from 'stream'; +import { + createConcatStream, + createSplitStream, + createMapStream, +} from '../../../../../../../src/legacy/utils'; +import { + parseNdjsonStrings, + filterExportedCounts, + createLimitStream, +} from '../detection_engine/rules/create_rules_stream_from_ndjson'; +import { importTimelinesSchema } from './routes/schemas/import_timelines_schema'; +import { BadRequestError } from '../detection_engine/errors/bad_request_error'; +import { ImportTimelineResponse } from './routes/utils/import_timelines'; + +export const validateTimelines = (): Transform => { + return createMapStream((obj: ImportTimelineResponse) => { + if (!(obj instanceof Error)) { + const validated = importTimelinesSchema.validate(obj); + if (validated.error != null) { + return new BadRequestError(validated.error.message); + } else { + return validated.value; + } + } else { + return obj; + } + }); +}; + +export const createTimelinesStreamFromNdJson = (ruleLimit: number) => { + return [ + createSplitStream('\n'), + parseNdjsonStrings(), + filterExportedCounts(), + validateTimelines(), + createLimitStream(ruleLimit), + createConcatStream([]), + ]; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/import_timelines.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/import_timelines.ts new file mode 100644 index 0000000000000..74d3744e29299 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/import_timelines.ts @@ -0,0 +1,177 @@ +/* + * 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 { omit } from 'lodash/fp'; + +export const mockDuplicateIdErrors = []; + +export const mockParsedObjects = [ + { + savedObjectId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + version: 'WzEyMjUsMV0=', + columns: [], + dataProviders: [], + description: 'description', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: [Object] }, + title: 'My duplicate timeline', + dateRange: { start: 1584523907294, end: 1584610307294 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1584828930463, + createdBy: 'angela', + updated: 1584868346013, + updatedBy: 'angela', + eventNotes: [ + { + noteId: '73ac2370-6bc2-11ea-a90b-f5341fb7a189', + version: 'WzExMjgsMV0=', + eventId: 'ZaAi8nAB5OldxqFfdhke', + note: 'event note2', + timelineId: 'da49a0e0-6bc1-11ea-a90b-f5341fb7a189', + created: 1584829349563, + createdBy: 'angela', + updated: 1584829349563, + updatedBy: 'angela', + }, + { + noteId: 'f7b71620-6bc2-11ea-a0b6-33c7b2a78885', + version: 'WzExMzUsMV0=', + eventId: 'ZaAi8nAB5OldxqFfdhke', + note: 'event note2', + timelineId: 'da49a0e0-6bc1-11ea-a90b-f5341fb7a189', + created: 1584829571092, + createdBy: 'angela', + updated: 1584829571092, + updatedBy: 'angela', + }, + ], + globalNotes: [ + { + noteId: 'd2649d40-6bc5-11ea-86f0-5db0048c6086', + version: 'WzExNjQsMV0=', + note: 'global', + timelineId: 'd123dfe0-6bc5-11ea-86f0-5db0048c6086', + created: 1584830796969, + createdBy: 'angela', + updated: 1584830796969, + updatedBy: 'angela', + }, + ], + pinnedEventIds: ['k-gi8nABm-sIqJ_scOoS'], + }, +]; + +export const mockUniqueParsedObjects = [ + { + savedObjectId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + version: 'WzEyMjUsMV0=', + columns: [], + dataProviders: [], + description: 'description', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: [] }, + title: 'My duplicate timeline', + dateRange: { start: 1584523907294, end: 1584610307294 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1584828930463, + createdBy: 'angela', + updated: 1584868346013, + updatedBy: 'angela', + eventNotes: [ + { + noteId: '73ac2370-6bc2-11ea-a90b-f5341fb7a189', + version: 'WzExMjgsMV0=', + eventId: 'ZaAi8nAB5OldxqFfdhke', + note: 'event note1', + timelineId: 'da49a0e0-6bc1-11ea-a90b-f5341fb7a189', + created: 1584829349563, + createdBy: 'angela', + updated: 1584829349563, + updatedBy: 'angela', + }, + { + noteId: 'f7b71620-6bc2-11ea-a0b6-33c7b2a78885', + version: 'WzExMzUsMV0=', + eventId: 'ZaAi8nAB5OldxqFfdhke', + note: 'event note2', + timelineId: 'da49a0e0-6bc1-11ea-a90b-f5341fb7a189', + created: 1584829571092, + createdBy: 'angela', + updated: 1584829571092, + updatedBy: 'angela', + }, + ], + globalNotes: [ + { + noteId: 'd2649d40-6bc5-11ea-86f0-5db0048c6086', + version: 'WzExNjQsMV0=', + note: 'global', + timelineId: 'd123dfe0-6bc5-11ea-86f0-5db0048c6086', + created: 1584830796969, + createdBy: 'angela', + updated: 1584830796969, + updatedBy: 'angela', + }, + ], + pinnedEventIds: ['k-gi8nABm-sIqJ_scOoS'], + }, +]; + +export const mockGetTimelineValue = { + savedObjectId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + version: 'WzEyMjUsMV0=', + columns: [], + dataProviders: [], + description: 'description', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: [] }, + title: 'My duplicate timeline', + dateRange: { start: 1584523907294, end: 1584610307294 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1584828930463, + createdBy: 'angela', + updated: 1584868346013, + updatedBy: 'angela', + noteIds: [], + pinnedEventIds: ['k-gi8nABm-sIqJ_scOoS'], +}; + +export const mockParsedTimelineObject = omit( + [ + 'globalNotes', + 'eventNotes', + 'pinnedEventIds', + 'version', + 'savedObjectId', + 'created', + 'createdBy', + 'updated', + 'updatedBy', + ], + mockUniqueParsedObjects[0] +); + +export const mockConfig = { + get: () => { + return 100000000; + }, + has: jest.fn(), +}; + +export const mockGetCurrentUser = { + user: { + username: 'mockUser', + }, +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts index eae1ece7e789d..0e73e4bdd6c97 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TIMELINE_EXPORT_URL } from '../../../../../common/constants'; +import { TIMELINE_EXPORT_URL, TIMELINE_IMPORT_URL } from '../../../../../common/constants'; import { requestMock } from '../../../detection_engine/routes/__mocks__'; export const getExportTimelinesRequest = () => @@ -16,6 +16,26 @@ export const getExportTimelinesRequest = () => }, }); +export const getImportTimelinesRequest = (filename?: string) => + requestMock.create({ + method: 'post', + path: TIMELINE_IMPORT_URL, + query: { overwrite: false }, + body: { + file: { hapi: { filename: filename ?? 'filename.ndjson' } }, + }, + }); + +export const getImportTimelinesRequestEnableOverwrite = (filename?: string) => + requestMock.create({ + method: 'post', + path: TIMELINE_IMPORT_URL, + query: { overwrite: true }, + body: { + file: { hapi: { filename: filename ?? 'filename.ndjson' } }, + }, + }); + export const mockTimelinesSavedObjects = () => ({ saved_objects: [ { diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts index 3ded959aced36..b8e7be13fff34 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -21,7 +21,7 @@ import { exportTimelinesQuerySchema, } from './schemas/export_timelines_schema'; -import { getExportTimelineByObjectIds } from './utils'; +import { getExportTimelineByObjectIds } from './utils/export_timelines'; export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['config']) => { router.post( diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts new file mode 100644 index 0000000000000..e89aef4c70ecb --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts @@ -0,0 +1,341 @@ +/* + * 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 { getImportTimelinesRequest } from './__mocks__/request_responses'; +import { + serverMock, + requestContextMock, + requestMock, +} from '../../detection_engine/routes/__mocks__'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; +import { SecurityPluginSetup } from '../../../../../../../plugins/security/server'; + +import { + mockConfig, + mockUniqueParsedObjects, + mockParsedObjects, + mockDuplicateIdErrors, + mockGetCurrentUser, + mockGetTimelineValue, + mockParsedTimelineObject, +} from './__mocks__/import_timelines'; + +describe('import timelines', () => { + let config: jest.Mock; + let server: ReturnType; + let request: ReturnType; + let securitySetup: SecurityPluginSetup; + let { context } = requestContextMock.createTools(); + let mockGetTimeline: jest.Mock; + let mockPersistTimeline: jest.Mock; + let mockPersistPinnedEventOnTimeline: jest.Mock; + let mockPersistNote: jest.Mock; + const newTimelineSavedObjectId = '79deb4c0-6bc1-11ea-9999-f5341fb7a189'; + const newTimelineVersion = '9999'; + beforeEach(() => { + jest.resetModules(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + + server = serverMock.create(); + context = requestContextMock.createTools().context; + config = jest.fn().mockImplementation(() => { + return mockConfig; + }); + + securitySetup = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), + }, + authz: {}, + } as unknown) as SecurityPluginSetup; + + mockGetTimeline = jest.fn(); + mockPersistTimeline = jest.fn(); + mockPersistPinnedEventOnTimeline = jest.fn(); + mockPersistNote = jest.fn(); + + jest.doMock('../create_timelines_stream_from_ndjson', () => { + return { + createTimelinesStreamFromNdJson: jest.fn().mockReturnValue(mockParsedObjects), + }; + }); + + jest.doMock('../../../../../../../../src/legacy/utils', () => { + return { + createPromiseFromStreams: jest.fn().mockReturnValue(mockParsedObjects), + }; + }); + + jest.doMock('./utils/import_timelines', () => { + const originalModule = jest.requireActual('./utils/import_timelines'); + return { + ...originalModule, + getTupleDuplicateErrorsAndUniqueTimeline: jest + .fn() + .mockReturnValue([mockDuplicateIdErrors, mockUniqueParsedObjects]), + }; + }); + }); + + describe('Import a new timeline', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + Timeline: jest.fn().mockImplementation(() => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + persistTimeline: mockPersistTimeline.mockReturnValue({ + timeline: { savedObjectId: newTimelineSavedObjectId, version: newTimelineVersion }, + }), + }; + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + PinnedEvent: jest.fn().mockImplementation(() => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline, + }; + }), + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + Note: jest.fn().mockImplementation(() => { + return { + persistNote: mockPersistNote, + }; + }), + }; + }); + + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + importTimelinesRoute(server.router, config, securitySetup); + }); + + test('should use given timelineId to check if the timeline savedObject already exist', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockGetTimeline.mock.calls[0][1]).toEqual(mockUniqueParsedObjects[0].savedObjectId); + }); + + test('should Create a new timeline savedObject', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline).toHaveBeenCalled(); + }); + + test('should Create a new timeline savedObject without timelineId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][1]).toBeNull(); + }); + + test('should Create a new timeline savedObject without timeline version', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][2]).toBeNull(); + }); + + test('should Create a new timeline savedObject witn given timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][3]).toEqual(mockParsedTimelineObject); + }); + + test('should Create new pinned events', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline).toHaveBeenCalled(); + }); + + test('should Create a new pinned event without pinnedEventSavedObjectId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline.mock.calls[0][1]).toBeNull(); + }); + + test('should Create a new pinned event with pinnedEventId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline.mock.calls[0][2]).toEqual( + mockUniqueParsedObjects[0].pinnedEventIds[0] + ); + }); + + test('should Create a new pinned event with new timelineSavedObjectId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline.mock.calls[0][3]).toEqual(newTimelineSavedObjectId); + }); + + test('should Create notes', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote).toHaveBeenCalled(); + }); + + test('should provide no noteSavedObjectId when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][1]).toBeNull(); + }); + + test('should provide new timeline version when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][1]).toBeNull(); + }); + + test('should provide note content when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][2]).toEqual(newTimelineVersion); + }); + + test('should provide new notes when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][3]).toEqual({ + eventId: undefined, + note: mockUniqueParsedObjects[0].globalNotes[0].note, + timelineId: newTimelineSavedObjectId, + }); + expect(mockPersistNote.mock.calls[1][3]).toEqual({ + eventId: mockUniqueParsedObjects[0].eventNotes[0].eventId, + note: mockUniqueParsedObjects[0].eventNotes[0].note, + timelineId: newTimelineSavedObjectId, + }); + expect(mockPersistNote.mock.calls[2][3]).toEqual({ + eventId: mockUniqueParsedObjects[0].eventNotes[1].eventId, + note: mockUniqueParsedObjects[0].eventNotes[1].note, + timelineId: newTimelineSavedObjectId, + }); + }); + + test('returns 200 when import timeline successfully', async () => { + const response = await server.inject(getImportTimelinesRequest(), context); + expect(response.status).toEqual(200); + }); + }); + + describe('Import a timeline already exist but overwrite is not allowed', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + Timeline: jest.fn().mockImplementation(() => { + return { + getTimeline: mockGetTimeline.mockReturnValue(mockGetTimelineValue), + persistTimeline: mockPersistTimeline, + }; + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + PinnedEvent: jest.fn().mockImplementation(() => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline, + }; + }), + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + Note: jest.fn().mockImplementation(() => { + return { + persistNote: mockPersistNote, + }; + }), + }; + }); + + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + importTimelinesRoute(server.router, config, securitySetup); + }); + + test('returns error message', async () => { + const response = await server.inject(getImportTimelinesRequest(), context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: `timeline_id: "79deb4c0-6bc1-11ea-a90b-f5341fb7a189" already exists`, + }, + }, + ], + }); + }); + }); + + describe('request validation', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + Timeline: jest.fn().mockImplementation(() => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + persistTimeline: mockPersistTimeline.mockReturnValue({ + timeline: { savedObjectId: '79deb4c0-6bc1-11ea-9999-f5341fb7a189' }, + }), + }; + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + PinnedEvent: jest.fn().mockImplementation(() => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline.mockReturnValue( + new Error('Test error') + ), + }; + }), + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + Note: jest.fn().mockImplementation(() => { + return { + persistNote: mockPersistNote, + }; + }), + }; + }); + }); + test('disallows invalid query', async () => { + request = requestMock.create({ + method: 'post', + path: TIMELINE_EXPORT_URL, + body: { id: 'someId' }, + }); + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + + importTimelinesRoute(server.router, config, securitySetup); + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith( + 'child "file" fails because ["file" is required]' + ); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts new file mode 100644 index 0000000000000..fefe31b2f36d0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts @@ -0,0 +1,226 @@ +/* + * 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 { extname } from 'path'; +import { chunk, omit, set } from 'lodash/fp'; +import { + buildRouteValidation, + buildSiemResponse, + createBulkErrorObject, + BulkError, + transformError, +} from '../../detection_engine/routes/utils'; + +import { createTimelinesStreamFromNdJson } from '../create_timelines_stream_from_ndjson'; +import { createPromiseFromStreams } from '../../../../../../../../src/legacy/utils'; + +import { + createTimelines, + getTupleDuplicateErrorsAndUniqueTimeline, + isBulkError, + isImportRegular, + ImportTimelineResponse, + ImportTimelinesRequestParams, + ImportTimelinesSchema, + PromiseFromStreams, +} from './utils/import_timelines'; + +import { IRouter } from '../../../../../../../../src/core/server'; +import { TIMELINE_IMPORT_URL } from '../../../../common/constants'; +import { importTimelinesPayloadSchema } from './schemas/import_timelines_schema'; +import { importRulesSchema } from '../../detection_engine/routes/schemas/response/import_rules_schema'; +import { LegacyServices } from '../../../types'; + +import { Timeline } from '../saved_object'; +import { validate } from '../../detection_engine/routes/rules/validate'; +import { FrameworkRequest } from '../../framework'; +import { SecurityPluginSetup } from '../../../../../../../plugins/security/server'; + +const CHUNK_PARSED_OBJECT_SIZE = 10; + +const timelineLib = new Timeline(); + +export const importTimelinesRoute = ( + router: IRouter, + config: LegacyServices['config'], + securityPluginSetup: SecurityPluginSetup +) => { + router.post( + { + path: `${TIMELINE_IMPORT_URL}`, + validate: { + body: buildRouteValidation( + importTimelinesPayloadSchema + ), + }, + options: { + tags: ['access:siem'], + body: { + maxBytes: config().get('savedObjects.maxImportPayloadBytes'), + output: 'stream', + }, + }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + const savedObjectsClient = context.core.savedObjects.client; + if (!savedObjectsClient) { + return siemResponse.error({ statusCode: 404 }); + } + const { filename } = request.body.file.hapi; + + const fileExtension = extname(filename).toLowerCase(); + + if (fileExtension !== '.ndjson') { + return siemResponse.error({ + statusCode: 400, + body: `Invalid file extension ${fileExtension}`, + }); + } + + const objectLimit = config().get('savedObjects.maxImportExportSize'); + + try { + const readStream = createTimelinesStreamFromNdJson(objectLimit); + const parsedObjects = await createPromiseFromStreams([ + request.body.file, + ...readStream, + ]); + const [duplicateIdErrors, uniqueParsedObjects] = getTupleDuplicateErrorsAndUniqueTimeline( + parsedObjects, + false + ); + const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueParsedObjects); + let importTimelineResponse: ImportTimelineResponse[] = []; + + const user = await securityPluginSetup.authc.getCurrentUser(request); + let frameworkRequest = set('context.core.savedObjects.client', savedObjectsClient, request); + frameworkRequest = set('user', user, frameworkRequest); + + while (chunkParseObjects.length) { + const batchParseObjects = chunkParseObjects.shift() ?? []; + const newImportTimelineResponse = await Promise.all( + batchParseObjects.reduce>>( + (accum, parsedTimeline) => { + const importsWorkerPromise = new Promise( + async (resolve, reject) => { + if (parsedTimeline instanceof Error) { + // If the JSON object had a validation or parse error then we return + // early with the error and an (unknown) for the ruleId + resolve( + createBulkErrorObject({ + statusCode: 400, + message: parsedTimeline.message, + }) + ); + + return null; + } + const { + savedObjectId, + pinnedEventIds, + globalNotes, + eventNotes, + } = parsedTimeline; + const parsedTimelineObject = omit( + [ + 'globalNotes', + 'eventNotes', + 'pinnedEventIds', + 'version', + 'savedObjectId', + 'created', + 'createdBy', + 'updated', + 'updatedBy', + ], + parsedTimeline + ); + try { + let timeline = null; + try { + timeline = await timelineLib.getTimeline( + (frameworkRequest as unknown) as FrameworkRequest, + savedObjectId + ); + // eslint-disable-next-line no-empty + } catch (e) {} + + if (timeline == null) { + const newSavedObjectId = await createTimelines( + (frameworkRequest as unknown) as FrameworkRequest, + parsedTimelineObject, + null, // timelineSavedObjectId + null, // timelineVersion + pinnedEventIds, + [...globalNotes, ...eventNotes], + [] // existing note ids + ); + + resolve({ timeline_id: newSavedObjectId, status_code: 200 }); + } else { + resolve( + createBulkErrorObject({ + id: savedObjectId, + statusCode: 409, + message: `timeline_id: "${savedObjectId}" already exists`, + }) + ); + } + } catch (err) { + resolve( + createBulkErrorObject({ + id: savedObjectId, + statusCode: 400, + message: err.message, + }) + ); + } + } + ); + return [...accum, importsWorkerPromise]; + }, + [] + ) + ); + importTimelineResponse = [ + ...duplicateIdErrors, + ...importTimelineResponse, + ...newImportTimelineResponse, + ]; + } + + const errorsResp = importTimelineResponse.filter(resp => isBulkError(resp)) as BulkError[]; + const successes = importTimelineResponse.filter(resp => { + if (isImportRegular(resp)) { + return resp.status_code === 200; + } else { + return false; + } + }); + const importTimelines: ImportTimelinesSchema = { + success: errorsResp.length === 0, + success_count: successes.length, + errors: errorsResp, + }; + const [validated, errors] = validate(importTimelines, importRulesSchema); + + if (errors != null) { + return siemResponse.error({ statusCode: 500, body: errors }); + } else { + return response.ok({ body: validated ?? {} }); + } + } catch (err) { + const error = transformError(err); + + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts new file mode 100644 index 0000000000000..61ffa9681c53a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Joi from 'joi'; +import { + columns, + created, + createdBy, + dataProviders, + dateRange, + description, + eventNotes, + eventType, + favorite, + filters, + globalNotes, + kqlMode, + kqlQuery, + savedObjectId, + savedQueryId, + sort, + title, + updated, + updatedBy, + version, + pinnedEventIds, +} from './schemas'; + +export const importTimelinesPayloadSchema = Joi.object({ + file: Joi.object().required(), +}); + +export const importTimelinesSchema = Joi.object({ + columns, + created, + createdBy, + dataProviders, + dateRange, + description, + eventNotes, + eventType, + filters, + favorite, + globalNotes, + kqlMode, + kqlQuery, + savedObjectId, + savedQueryId, + sort, + title, + updated, + updatedBy, + version, + pinnedEventIds, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts index 67697c347634e..63aee97729141 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts @@ -5,9 +5,162 @@ */ import Joi from 'joi'; +const allowEmptyString = Joi.string().allow([null, '']); +const columnHeaderType = Joi.string(); +export const created = Joi.number().allow(null); +export const createdBy = Joi.string(); + +export const description = allowEmptyString; +export const end = Joi.number(); +export const eventId = allowEmptyString; +export const eventType = Joi.string(); + +export const filters = Joi.array() + .items( + Joi.object({ + meta: Joi.object({ + alias: allowEmptyString, + controlledBy: allowEmptyString, + disabled: Joi.boolean().allow(null), + field: allowEmptyString, + formattedValue: allowEmptyString, + index: { + type: 'keyword', + }, + key: { + type: 'keyword', + }, + negate: { + type: 'boolean', + }, + params: allowEmptyString, + type: { + type: 'keyword', + }, + value: allowEmptyString, + }), + exists: allowEmptyString, + match_all: allowEmptyString, + missing: allowEmptyString, + query: allowEmptyString, + range: allowEmptyString, + script: allowEmptyString, + }) + ) + .allow(null); + +const name = allowEmptyString; + +export const noteId = allowEmptyString; +export const note = allowEmptyString; + +export const start = Joi.number(); +export const savedQueryId = allowEmptyString; +export const savedObjectId = allowEmptyString; + +export const timelineId = allowEmptyString; +export const title = allowEmptyString; + +export const updated = Joi.number().allow(null); +export const updatedBy = allowEmptyString; +export const version = allowEmptyString; + +export const columns = Joi.array().items( + Joi.object({ + aggregatable: Joi.boolean().allow(null), + category: Joi.string(), + columnHeaderType, + description, + example: allowEmptyString, + indexes: allowEmptyString, + id: Joi.string(), + name, + placeholder: allowEmptyString, + searchable: Joi.boolean().allow(null), + type: Joi.string(), + }).required() +); +export const dataProviders = Joi.array() + .items( + Joi.object({ + id: Joi.string(), + name: allowEmptyString, + enabled: Joi.boolean().allow(null), + excluded: Joi.boolean().allow(null), + kqlQuery: allowEmptyString, + queryMatch: Joi.object({ + field: allowEmptyString, + displayField: allowEmptyString, + value: allowEmptyString, + displayValue: allowEmptyString, + operator: allowEmptyString, + }), + and: Joi.array() + .items( + Joi.object({ + id: Joi.string(), + name, + enabled: Joi.boolean().allow(null), + excluded: Joi.boolean().allow(null), + kqlQuery: allowEmptyString, + queryMatch: Joi.object({ + field: allowEmptyString, + displayField: allowEmptyString, + value: allowEmptyString, + displayValue: allowEmptyString, + operator: allowEmptyString, + }).allow(null), + }) + ) + .allow(null), + }) + ) + .allow(null); +export const dateRange = Joi.object({ + start, + end, +}); +export const favorite = Joi.array().items( + Joi.object({ + keySearch: Joi.string(), + fullName: Joi.string(), + userName: Joi.string(), + favoriteDate: Joi.number(), + }).allow(null) +); +const noteItem = Joi.object({ + noteId, + version, + eventId, + note, + timelineId, + created, + createdBy, + updated, + updatedBy, +}); +export const eventNotes = Joi.array().items(noteItem); +export const globalNotes = Joi.array().items(noteItem); +export const kqlMode = Joi.string(); +export const kqlQuery = Joi.object({ + filterQuery: Joi.object({ + kuery: Joi.object({ + kind: Joi.string(), + expression: allowEmptyString, + }), + serializedQuery: allowEmptyString, + }), +}); +export const pinnedEventIds = Joi.array() + .items(Joi.string()) + .allow(null); +export const sort = Joi.object({ + columnId: Joi.string(), + sortDirection: Joi.string(), +}); /* eslint-disable @typescript-eslint/camelcase */ export const ids = Joi.array().items(Joi.string()); export const exclude_export_details = Joi.boolean(); -export const file_name = Joi.string(); +export const file_name = allowEmptyString; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts similarity index 85% rename from x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts rename to x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts index 066862e025833..8a28100fbae82 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts @@ -3,37 +3,53 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { set as _set } from 'lodash/fp'; import { + noteSavedObjectType, + pinnedEventSavedObjectType, + timelineSavedObjectType, +} from '../../../../saved_objects'; +import { NoteSavedObject } from '../../../note/types'; +import { PinnedEventSavedObject } from '../../../pinned_event/types'; +import { convertSavedObjectToSavedTimeline } from '../../convert_saved_object_to_savedtimeline'; + +import { convertSavedObjectToSavedPinnedEvent } from '../../../pinned_event/saved_object'; +import { convertSavedObjectToSavedNote } from '../../../note/saved_object'; + +import { + SavedObjectsClient, SavedObjectsFindOptions, SavedObjectsFindResponse, -} from '../../../../../../../../src/core/server'; +} from '../../../../../../../../../src/core/server'; import { + ExportedTimelines, ExportTimelineSavedObjectsClient, ExportTimelineRequest, ExportedNotes, TimelineSavedObject, - ExportedTimelines, -} from '../types'; -import { - timelineSavedObjectType, - noteSavedObjectType, - pinnedEventSavedObjectType, -} from '../../../saved_objects'; - -import { convertSavedObjectToSavedNote } from '../../note/saved_object'; -import { convertSavedObjectToSavedPinnedEvent } from '../../pinned_event/saved_object'; -import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; -import { transformDataToNdjson } from '../../detection_engine/routes/rules/utils'; -import { NoteSavedObject } from '../../note/types'; -import { PinnedEventSavedObject } from '../../pinned_event/types'; +} from '../../types'; + +import { transformDataToNdjson } from '../../../detection_engine/routes/rules/utils'; +export type TimelineSavedObjectsClient = Pick< + SavedObjectsClient, + | 'get' + | 'errors' + | 'create' + | 'bulkCreate' + | 'delete' + | 'find' + | 'bulkGet' + | 'update' + | 'bulkUpdate' +>; const getAllSavedPinnedEvents = ( pinnedEventsSavedObjects: SavedObjectsFindResponse ): PinnedEventSavedObject[] => { return pinnedEventsSavedObjects != null - ? pinnedEventsSavedObjects.saved_objects.map(savedObject => + ? (pinnedEventsSavedObjects?.saved_objects ?? []).map(savedObject => convertSavedObjectToSavedPinnedEvent(savedObject) ) : []; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts new file mode 100644 index 0000000000000..5596d0c70f5ea --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts @@ -0,0 +1,191 @@ +/* + * 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 uuid from 'uuid'; +import { has } from 'lodash/fp'; +import { createBulkErrorObject, BulkError } from '../../../detection_engine/routes/utils'; +import { PinnedEvent } from '../../../pinned_event/saved_object'; +import { Note } from '../../../note/saved_object'; + +import { Timeline } from '../../saved_object'; +import { SavedTimeline } from '../../types'; +import { FrameworkRequest } from '../../../framework'; +import { SavedNote } from '../../../note/types'; +import { NoteResult } from '../../../../graphql/types'; +import { HapiReadableStream } from '../../../detection_engine/rules/types'; + +const pinnedEventLib = new PinnedEvent(); +const timelineLib = new Timeline(); +const noteLib = new Note(); + +export interface ImportTimelinesSchema { + success: boolean; + success_count: number; + errors: BulkError[]; +} + +export type ImportedTimeline = SavedTimeline & { + savedObjectId: string; + pinnedEventIds: string[]; + globalNotes: NoteResult[]; + eventNotes: NoteResult[]; +}; + +interface ImportRegular { + timeline_id: string; + status_code: number; + message?: string; +} + +export type ImportTimelineResponse = ImportRegular | BulkError; +export type PromiseFromStreams = ImportedTimeline; +export interface ImportTimelinesRequestParams { + body: { file: HapiReadableStream }; +} + +export const getTupleDuplicateErrorsAndUniqueTimeline = ( + timelines: PromiseFromStreams[], + isOverwrite: boolean +): [BulkError[], PromiseFromStreams[]] => { + const { errors, timelinesAcc } = timelines.reduce( + (acc, parsedTimeline) => { + if (parsedTimeline instanceof Error) { + acc.timelinesAcc.set(uuid.v4(), parsedTimeline); + } else { + const { savedObjectId } = parsedTimeline; + if (savedObjectId != null) { + if (acc.timelinesAcc.has(savedObjectId) && !isOverwrite) { + acc.errors.set( + uuid.v4(), + createBulkErrorObject({ + id: savedObjectId, + statusCode: 400, + message: `More than one timeline with savedObjectId: "${savedObjectId}" found`, + }) + ); + } + acc.timelinesAcc.set(savedObjectId, parsedTimeline); + } else { + acc.timelinesAcc.set(uuid.v4(), parsedTimeline); + } + } + + return acc; + }, // using map (preserves ordering) + { + errors: new Map(), + timelinesAcc: new Map(), + } + ); + + return [Array.from(errors.values()), Array.from(timelinesAcc.values())]; +}; + +export const saveTimelines = async ( + frameworkRequest: FrameworkRequest, + timeline: SavedTimeline, + timelineSavedObjectId?: string | null, + timelineVersion?: string | null +) => { + const newTimelineRes = await timelineLib.persistTimeline( + frameworkRequest, + timelineSavedObjectId ?? null, + timelineVersion ?? null, + timeline + ); + + return { + newTimelineSavedObjectId: newTimelineRes?.timeline?.savedObjectId ?? null, + newTimelineVersion: newTimelineRes?.timeline?.version ?? null, + }; +}; + +export const savePinnedEvents = ( + frameworkRequest: FrameworkRequest, + timelineSavedObjectId: string, + pinnedEventIds?: string[] | null +) => { + return ( + pinnedEventIds?.map(eventId => { + return pinnedEventLib.persistPinnedEventOnTimeline( + frameworkRequest, + null, // pinnedEventSavedObjectId + eventId, + timelineSavedObjectId + ); + }) ?? [] + ); +}; + +export const saveNotes = ( + frameworkRequest: FrameworkRequest, + timelineSavedObjectId: string, + timelineVersion?: string | null, + existingNoteIds?: string[], + newNotes?: NoteResult[] +) => { + return ( + newNotes?.map(note => { + const newNote: SavedNote = { + eventId: note.eventId, + note: note.note, + timelineId: timelineSavedObjectId, + }; + + return noteLib.persistNote( + frameworkRequest, + existingNoteIds?.find(nId => nId === note.noteId) ?? null, + timelineVersion ?? null, + newNote + ); + }) ?? [] + ); +}; + +export const createTimelines = async ( + frameworkRequest: FrameworkRequest, + timeline: SavedTimeline, + timelineSavedObjectId?: string | null, + timelineVersion?: string | null, + pinnedEventIds?: string[] | null, + notes?: NoteResult[], + existingNoteIds?: string[] +) => { + const { newTimelineSavedObjectId, newTimelineVersion } = await saveTimelines( + frameworkRequest, + timeline, + timelineSavedObjectId, + timelineVersion + ); + await Promise.all([ + savePinnedEvents( + frameworkRequest, + timelineSavedObjectId ?? newTimelineSavedObjectId, + pinnedEventIds + ), + saveNotes( + frameworkRequest, + timelineSavedObjectId ?? newTimelineSavedObjectId, + newTimelineVersion, + existingNoteIds, + notes + ), + ]); + + return newTimelineSavedObjectId; +}; + +export const isImportRegular = ( + importTimelineResponse: ImportTimelineResponse +): importTimelineResponse is ImportRegular => { + return !has('error', importTimelineResponse) && has('status_code', importTimelineResponse); +}; + +export const isBulkError = ( + importRuleResponse: ImportTimelineResponse +): importRuleResponse is BulkError => { + return has('error', importRuleResponse); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts index 88d7fcdb68164..bc6975331ad9b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts @@ -138,19 +138,19 @@ export class Timeline { timeline: SavedTimeline ): Promise { const savedObjectsClient = request.context.core.savedObjects.client; - try { if (timelineId == null) { // Create new timeline + const newTimeline = convertSavedObjectToSavedTimeline( + await savedObjectsClient.create( + timelineSavedObjectType, + pickSavedTimeline(timelineId, timeline, request.user) + ) + ); return { code: 200, message: 'success', - timeline: convertSavedObjectToSavedTimeline( - await savedObjectsClient.create( - timelineSavedObjectType, - pickSavedTimeline(timelineId, timeline, request.user) - ) - ), + timeline: newTimeline, }; } // Update Timeline @@ -162,6 +162,7 @@ export class Timeline { version: version || undefined, } ); + return { code: 200, message: 'success', diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index 44e6b7a32a842..2bce9b6a7e1aa 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -91,7 +91,8 @@ export class Plugin { initRoutes( router, __legacy.config, - plugins.encryptedSavedObjects?.usingEphemeralEncryptionKey ?? false + plugins.encryptedSavedObjects?.usingEphemeralEncryptionKey ?? false, + plugins.security ); plugins.features.registerFeature({ diff --git a/x-pack/legacy/plugins/siem/server/routes/index.ts b/x-pack/legacy/plugins/siem/server/routes/index.ts index 08ff9208ce20b..29c21ad157235 100644 --- a/x-pack/legacy/plugins/siem/server/routes/index.ts +++ b/x-pack/legacy/plugins/siem/server/routes/index.ts @@ -29,12 +29,15 @@ import { importRulesRoute } from '../lib/detection_engine/routes/rules/import_ru import { exportRulesRoute } from '../lib/detection_engine/routes/rules/export_rules_route'; import { findRulesStatusesRoute } from '../lib/detection_engine/routes/rules/find_rules_status_route'; import { getPrepackagedRulesStatusRoute } from '../lib/detection_engine/routes/rules/get_prepackaged_rules_status_route'; +import { importTimelinesRoute } from '../lib/timeline/routes/import_timelines_route'; import { exportTimelinesRoute } from '../lib/timeline/routes/export_timelines_route'; +import { SecurityPluginSetup } from '../../../../../plugins/security/server/'; export const initRoutes = ( router: IRouter, config: LegacyServices['config'], - usingEphemeralEncryptionKey: boolean + usingEphemeralEncryptionKey: boolean, + security: SecurityPluginSetup ) => { // Detection Engine Rule routes that have the REST endpoints of /api/detection_engine/rules // All REST rule creation, deletion, updating, etc...... @@ -55,6 +58,7 @@ export const initRoutes = ( importRulesRoute(router, config); exportRulesRoute(router, config); + importTimelinesRoute(router, config, security); exportTimelinesRoute(router, config); findRulesStatusesRoute(router); diff --git a/x-pack/test/api_integration/apis/siem/saved_objects/timeline.ts b/x-pack/test/api_integration/apis/siem/saved_objects/timeline.ts index a6ced270e2132..a7e7cf4476f3f 100644 --- a/x-pack/test/api_integration/apis/siem/saved_objects/timeline.ts +++ b/x-pack/test/api_integration/apis/siem/saved_objects/timeline.ts @@ -175,7 +175,7 @@ export default function({ getService }: FtrProviderContext) { expect(version).to.not.be.empty(); }); - it('Update a timeline with a new title', async () => { + it.skip('Update a timeline with a new title', async () => { const titleToSaved = 'hello title'; const response = await createBasicTimeline(client, titleToSaved); const { savedObjectId, version } = response.data && response.data.persistTimeline.timeline; @@ -192,7 +192,7 @@ export default function({ getService }: FtrProviderContext) { }, }); - expect(responseToTest.data!.persistTimeline.timeline.savedObjectId).to.be(savedObjectId); + expect(responseToTest.data!.persistTimeline.timeline.savedObjectId).to.eql(savedObjectId); expect(responseToTest.data!.persistTimeline.timeline.title).to.be(newTitle); expect(responseToTest.data!.persistTimeline.timeline.version).to.not.be.eql(version); }); From 8d45ead109410e1278fb823321078666b482da96 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Tue, 24 Mar 2020 18:08:41 -0700 Subject: [PATCH 46/56] Fixed issue on delete single or multiple alerts from the list cancel button doesn't work (#61187) --- .../components/delete_modal_confirmation.tsx | 3 +++ .../components/actions_connectors_list.tsx | 11 ++++------- .../sections/alerts_list/components/alerts_list.tsx | 12 +++++------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx index 80b59e15644ec..5862a567f71ba 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx @@ -14,6 +14,7 @@ export const DeleteModalConfirmation = ({ apiDeleteCall, onDeleted, onCancel, + onErrors, singleTitle, multipleTitle, }: { @@ -27,6 +28,7 @@ export const DeleteModalConfirmation = ({ }) => Promise<{ successes: string[]; errors: string[] }>; onDeleted: (deleted: string[]) => void; onCancel: () => void; + onErrors: () => void; singleTitle: string; multipleTitle: string; }) => { @@ -93,6 +95,7 @@ export const DeleteModalConfirmation = ({ } ) ); + onErrors(); } }} cancelButtonText={cancelButtonText} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index facfc8efa299e..0cb9bbbbfb261 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -389,17 +389,14 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { } setConnectorsToDelete([]); }} - onCancel={async () => { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.actionsConnectorsList.failedToDeleteActionsMessage', - { defaultMessage: 'Failed to delete connectors(s)' } - ), - }); + onErrors={async () => { // Refresh the actions from the server, some actions may have beend deleted await loadActions(); setConnectorsToDelete([]); }} + onCancel={async () => { + setConnectorsToDelete([]); + }} apiDeleteCall={deleteActions} idsToDelete={connectorsToDelete} singleTitle={i18n.translate( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 2be8418cd9802..8d675148690c7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -446,15 +446,13 @@ export const AlertsList: React.FunctionComponent = () => { } setAlertsToDelete([]); }} - onCancel={async () => { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertsList.failedToDeleteAlertsMessage', - { defaultMessage: 'Failed to delete alert(s)' } - ), - }); + onErrors={async () => { // Refresh the alerts from the server, some alerts may have beend deleted await loadAlertsData(); + setAlertsToDelete([]); + }} + onCancel={async () => { + setAlertsToDelete([]); }} apiDeleteCall={deleteAlerts} idsToDelete={alertsToDelete} From 683bf3a72ebc399464f25c6eba1a29846225bddd Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Tue, 24 Mar 2020 21:39:07 -0500 Subject: [PATCH 47/56] [SIEM] ML Rules Details (#61182) * Add basic help text to ML Job dropdown on Rule form * Use EUI's preferred layout for form fields * Add a link to ML in the Job select help text * Restrict timeline picker to EUI guidelines Don't display the row as fullwidth, lest the help text wrap across the entire page. It only looks okay now because it was a short sentence; adding the ML Job select with its wrapped text caused some visual weirdness, so this at least makes it consistent. * Add placeholder option to ML Job dropdown * Humanize rule type on Rule Description component This is displayed both on the readonly form view, and the Rule Details page. * Add useMlCapabilities hook This is a base hook that we can combine with our permissions helpers. * Restrict ML Rule creation to ML Admins If we're auto-activating jobs on their behalf, they'll need to be an admin. * Extract ML Job status helpers to separate file * WIP: Enrich Rule Description with ML Job Data This adds the auditMessage as well as a link to ML; actual status is next * Display job status as a badge on Rule Details Also simplifies the layout of these job details. * Port helper tests to new location * Fix DescriptionStep tests now that they use useSiemJobs UseSiemJobs uses uiSettings, so we need to use our kibana mocks here. * Fix responsiveness of ML Rule Details The long job names were causing the panel to overflow. --- .../ml/anomaly/use_anomalies_table_data.ts | 6 +- .../components/ml/helpers/index.test.ts | 57 ++++++++++++ .../public/components/ml/helpers/index.ts | 22 +++++ .../ml/tables/anomalies_host_table.tsx | 6 +- .../ml/tables/anomalies_network_table.tsx | 6 +- .../ml_popover/hooks/use_ml_capabilities.tsx | 11 +++ .../ml_popover/hooks/use_siem_jobs.tsx | 8 +- .../ml_popover/jobs_table/job_switch.test.tsx | 52 +---------- .../ml_popover/jobs_table/job_switch.tsx | 22 +---- .../components/ml_popover/ml_popover.tsx | 6 +- .../public/components/ml_popover/types.ts | 2 + .../page/hosts/host_overview/index.tsx | 6 +- .../page/network/ip_overview/index.tsx | 6 +- .../anomaly_threshold_slider/index.tsx | 7 +- .../description_step/helpers.test.tsx | 27 ++++++ .../components/description_step/helpers.tsx | 26 ++++++ .../description_step/index.test.tsx | 9 +- .../components/description_step/index.tsx | 28 ++++-- .../description_step/ml_job_description.tsx | 93 +++++++++++++++++++ .../description_step/translations.tsx | 28 ++++++ .../rules/components/ml_job_select/index.tsx | 46 ++++++++- .../rules/components/pick_timeline/index.tsx | 1 - .../components/select_rule_type/index.tsx | 6 +- .../step_about_rule_details/index.test.tsx | 9 +- .../components/step_define_rule/index.tsx | 10 +- .../step_define_rule/translations.tsx | 7 ++ .../siem/public/pages/hosts/details/index.tsx | 6 +- .../plugins/siem/public/pages/hosts/hosts.tsx | 4 +- .../siem/public/pages/network/index.tsx | 6 +- 29 files changed, 386 insertions(+), 137 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/ml/helpers/index.test.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/ml/helpers/index.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_ml_capabilities.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts b/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts index ad59d3dc436a7..c4ca7dc203619 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState, useEffect, useContext } from 'react'; +import { useState, useEffect } from 'react'; import { anomaliesTableData } from '../api/anomalies_table_data'; import { InfluencerInput, Anomalies, CriteriaFields } from '../types'; import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../permissions/ml_capabilities_provider'; import { useSiemJobs } from '../../ml_popover/hooks/use_siem_jobs'; +import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities'; import { useStateToaster, errorToToaster } from '../../toasters'; import * as i18n from './translations'; @@ -59,7 +59,7 @@ export const useAnomaliesTableData = ({ const [tableData, setTableData] = useState(null); const [, siemJobs] = useSiemJobs(true); const [loading, setLoading] = useState(true); - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const userPermissions = hasMlUserPermissions(capabilities); const [, dispatchToaster] = useStateToaster(); const timeZone = useTimeZone(); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/helpers/index.test.ts b/x-pack/legacy/plugins/siem/public/components/ml/helpers/index.test.ts new file mode 100644 index 0000000000000..693f0bd0dd0fd --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml/helpers/index.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isJobStarted, isJobLoading, isJobFailed } from './'; + +describe('isJobStarted', () => { + test('returns false if only jobState is enabled', () => { + expect(isJobStarted('started', 'closing')).toBe(false); + }); + + test('returns false if only datafeedState is enabled', () => { + expect(isJobStarted('stopping', 'opened')).toBe(false); + }); + + test('returns true if both enabled states are provided', () => { + expect(isJobStarted('started', 'opened')).toBe(true); + }); +}); + +describe('isJobLoading', () => { + test('returns true if both loading states are not provided', () => { + expect(isJobLoading('started', 'closing')).toBe(true); + }); + + test('returns true if only jobState is loading', () => { + expect(isJobLoading('starting', 'opened')).toBe(true); + }); + + test('returns true if only datafeedState is loading', () => { + expect(isJobLoading('started', 'opening')).toBe(true); + }); + + test('returns false if both disabling states are provided', () => { + expect(isJobLoading('stopping', 'closing')).toBe(true); + }); +}); + +describe('isJobFailed', () => { + test('returns true if only jobState is failure/deleted', () => { + expect(isJobFailed('failed', 'stopping')).toBe(true); + }); + + test('returns true if only dataFeed is failure/deleted', () => { + expect(isJobFailed('started', 'deleted')).toBe(true); + }); + + test('returns true if both enabled states are failure/deleted', () => { + expect(isJobFailed('failed', 'deleted')).toBe(true); + }); + + test('returns false only if both states are not failure/deleted', () => { + expect(isJobFailed('opened', 'stopping')).toBe(false); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/helpers/index.ts b/x-pack/legacy/plugins/siem/public/components/ml/helpers/index.ts new file mode 100644 index 0000000000000..c06596b49317d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml/helpers/index.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. + */ + +// Based on ML Job/Datafeed States from x-pack/legacy/plugins/ml/common/constants/states.js +const enabledStates = ['started', 'opened']; +const loadingStates = ['starting', 'stopping', 'opening', 'closing']; +const failureStates = ['deleted', 'failed']; + +export const isJobStarted = (jobState: string, datafeedState: string): boolean => { + return enabledStates.includes(jobState) && enabledStates.includes(datafeedState); +}; + +export const isJobLoading = (jobState: string, datafeedState: string): boolean => { + return loadingStates.includes(jobState) || loadingStates.includes(datafeedState); +}; + +export const isJobFailed = (jobState: string, datafeedState: string): boolean => { + return failureStates.includes(jobState) || failureStates.includes(datafeedState); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx index 9e58e39a08f67..16bde076ef763 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext } from 'react'; +import React from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -16,7 +16,7 @@ import { Loader } from '../../loader'; import { getIntervalFromAnomalies } from '../anomaly/get_interval_from_anomalies'; import { AnomaliesHostTableProps } from '../types'; import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../permissions/ml_capabilities_provider'; +import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities'; import { BasicTable } from './basic_table'; import { hostEquality } from './host_equality'; import { getCriteriaFromHostType } from '../criteria/get_criteria_from_host_type'; @@ -37,7 +37,7 @@ const AnomaliesHostTableComponent: React.FC = ({ skip, type, }) => { - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx index 05f3044ff2929..bba6355f0b8b9 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext } from 'react'; +import React from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -13,8 +13,8 @@ import { convertAnomaliesToNetwork } from './convert_anomalies_to_network'; import { Loader } from '../../loader'; import { AnomaliesNetworkTableProps } from '../types'; import { getAnomaliesNetworkTableColumnsCurated } from './get_anomalies_network_table_columns'; +import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities'; import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../permissions/ml_capabilities_provider'; import { BasicTable } from './basic_table'; import { networkEquality } from './network_equality'; import { getCriteriaFromNetworkType } from '../criteria/get_criteria_from_network_type'; @@ -35,7 +35,7 @@ const AnomaliesNetworkTableComponent: React.FC = ({ type, flowTarget, }) => { - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_ml_capabilities.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_ml_capabilities.tsx new file mode 100644 index 0000000000000..d897b2554b4fd --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_ml_capabilities.tsx @@ -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 { useContext } from 'react'; + +import { MlCapabilitiesContext } from '../../ml/permissions/ml_capabilities_provider'; + +export const useMlCapabilities = () => useContext(MlCapabilitiesContext); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx index 4e4cdbfc109a9..9a82859066f54 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx @@ -4,18 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useContext, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { checkRecognizer, getJobsSummary, getModules } from '../api'; import { SiemJob } from '../types'; import { hasMlUserPermissions } from '../../ml/permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../../ml/permissions/ml_capabilities_provider'; import { errorToToaster, useStateToaster } from '../../toasters'; import { useUiSetting$ } from '../../../lib/kibana'; import { DEFAULT_INDEX_KEY } from '../../../../common/constants'; import * as i18n from './translations'; import { createSiemJobs } from './use_siem_jobs_helpers'; +import { useMlCapabilities } from './use_ml_capabilities'; type Return = [boolean, SiemJob[]]; @@ -30,8 +30,8 @@ type Return = [boolean, SiemJob[]]; export const useSiemJobs = (refetchData: boolean): Return => { const [siemJobs, setSiemJobs] = useState([]); const [loading, setLoading] = useState(true); - const capabilities = useContext(MlCapabilitiesContext); - const userPermissions = hasMlUserPermissions(capabilities); + const mlCapabilities = useMlCapabilities(); + const userPermissions = hasMlUserPermissions(mlCapabilities); const [siemDefaultIndex] = useUiSetting$(DEFAULT_INDEX_KEY); const [, dispatchToaster] = useStateToaster(); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.test.tsx index 1186573e3e209..ade8c6fe80525 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.test.tsx @@ -7,7 +7,7 @@ import { shallow, mount } from 'enzyme'; import React from 'react'; -import { isChecked, isFailure, isJobLoading, JobSwitchComponent } from './job_switch'; +import { JobSwitchComponent } from './job_switch'; import { cloneDeep } from 'lodash/fp'; import { mockSiemJobs } from '../__mocks__/api'; import { SiemJob } from '../types'; @@ -75,54 +75,4 @@ describe('JobSwitch', () => { ); expect(wrapper.find('[data-test-subj="job-switch"]').exists()).toBe(false); }); - - describe('isChecked', () => { - test('returns false if only jobState is enabled', () => { - expect(isChecked('started', 'closing')).toBe(false); - }); - - test('returns false if only datafeedState is enabled', () => { - expect(isChecked('stopping', 'opened')).toBe(false); - }); - - test('returns true if both enabled states are provided', () => { - expect(isChecked('started', 'opened')).toBe(true); - }); - }); - - describe('isJobLoading', () => { - test('returns true if both loading states are not provided', () => { - expect(isJobLoading('started', 'closing')).toBe(true); - }); - - test('returns true if only jobState is loading', () => { - expect(isJobLoading('starting', 'opened')).toBe(true); - }); - - test('returns true if only datafeedState is loading', () => { - expect(isJobLoading('started', 'opening')).toBe(true); - }); - - test('returns false if both disabling states are provided', () => { - expect(isJobLoading('stopping', 'closing')).toBe(true); - }); - }); - - describe('isFailure', () => { - test('returns true if only jobState is failure/deleted', () => { - expect(isFailure('failed', 'stopping')).toBe(true); - }); - - test('returns true if only dataFeed is failure/deleted', () => { - expect(isFailure('started', 'deleted')).toBe(true); - }); - - test('returns true if both enabled states are failure/deleted', () => { - expect(isFailure('failed', 'deleted')).toBe(true); - }); - - test('returns false only if both states are not failure/deleted', () => { - expect(isFailure('opened', 'stopping')).toBe(false); - }); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx index 39c48413737e2..e5066eef18c8b 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx @@ -8,6 +8,7 @@ import styled from 'styled-components'; import React, { useState, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSwitch } from '@elastic/eui'; import { SiemJob } from '../types'; +import { isJobLoading, isJobStarted, isJobFailed } from '../../ml/helpers'; const StaticSwitch = styled(EuiSwitch)` .euiSwitch__thumb, @@ -24,23 +25,6 @@ export interface JobSwitchProps { onJobStateChange: (job: SiemJob, latestTimestampMs: number, enable: boolean) => Promise; } -// Based on ML Job/Datafeed States from x-pack/legacy/plugins/ml/common/constants/states.js -const enabledStates = ['started', 'opened']; -const loadingStates = ['starting', 'stopping', 'opening', 'closing']; -const failureStates = ['deleted', 'failed']; - -export const isChecked = (jobState: string, datafeedState: string): boolean => { - return enabledStates.includes(jobState) && enabledStates.includes(datafeedState); -}; - -export const isJobLoading = (jobState: string, datafeedState: string): boolean => { - return loadingStates.includes(jobState) || loadingStates.includes(datafeedState); -}; - -export const isFailure = (jobState: string, datafeedState: string): boolean => { - return failureStates.includes(jobState) || failureStates.includes(datafeedState); -}; - export const JobSwitchComponent = ({ job, isSiemJobsLoading, @@ -64,8 +48,8 @@ export const JobSwitchComponent = ({ ) : ( { const [filterProperties, setFilterProperties] = useState(defaultFilterProps); const [isLoadingSiemJobs, siemJobs] = useSiemJobs(refreshToggle); const [, dispatchToaster] = useStateToaster(); - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const docLinks = useKibana().services.docLinks; // Enable/Disable Job & Datafeed -- passed to JobsTable for use as callback on JobSwitch diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/types.ts b/x-pack/legacy/plugins/siem/public/components/ml_popover/types.ts index f3bf78fdbb94c..991c82cf701e8 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/types.ts @@ -5,6 +5,7 @@ */ import { MlError } from '../ml/types'; +import { AuditMessageBase } from '../../../../../../plugins/ml/common/types/audit_message'; export interface Group { id: string; @@ -101,6 +102,7 @@ export interface MlSetupArgs { * Representation of an ML Job as returned from the `ml/jobs/jobs_summary` API */ export interface JobSummary { + auditMessage?: AuditMessageBase; datafeedId: string; datafeedIndices: string[]; datafeedState: string; diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx index bf32a33af1eac..4d0e6a737d303 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx @@ -8,7 +8,7 @@ import { EuiFlexItem } from '@elastic/eui'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; import { getOr } from 'lodash/fp'; -import React, { useContext } from 'react'; +import React from 'react'; import { DEFAULT_DARK_MODE } from '../../../../../common/constants'; import { DescriptionList } from '../../../../../common/utility_types'; @@ -19,8 +19,8 @@ import { InspectButton, InspectButtonContainer } from '../../../inspect'; import { HostItem } from '../../../../graphql/types'; import { Loader } from '../../../loader'; import { IPDetailsLink } from '../../../links'; -import { MlCapabilitiesContext } from '../../../ml/permissions/ml_capabilities_provider'; import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions'; +import { useMlCapabilities } from '../../../ml_popover/hooks/use_ml_capabilities'; import { AnomalyScores } from '../../../ml/score/anomaly_scores'; import { Anomalies, NarrowDateRange } from '../../../ml/types'; import { DescriptionListStyled, OverviewWrapper } from '../../index'; @@ -56,7 +56,7 @@ export const HostOverview = React.memo( anomaliesData, narrowDateRange, }) => { - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const userPermissions = hasMlUserPermissions(capabilities); const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx index 901b82210a661..56b59ca97156f 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx @@ -7,7 +7,7 @@ import { EuiFlexItem } from '@elastic/eui'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; -import React, { useContext } from 'react'; +import React from 'react'; import { DEFAULT_DARK_MODE } from '../../../../../common/constants'; import { DescriptionList } from '../../../../../common/utility_types'; @@ -30,7 +30,7 @@ import { DescriptionListStyled, OverviewWrapper } from '../../index'; import { Loader } from '../../../loader'; import { Anomalies, NarrowDateRange } from '../../../ml/types'; import { AnomalyScores } from '../../../ml/score/anomaly_scores'; -import { MlCapabilitiesContext } from '../../../ml/permissions/ml_capabilities_provider'; +import { useMlCapabilities } from '../../../ml_popover/hooks/use_ml_capabilities'; import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions'; import { InspectButton, InspectButtonContainer } from '../../../inspect'; @@ -71,7 +71,7 @@ export const IpOverview = React.memo( anomaliesData, narrowDateRange, }) => { - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const userPermissions = hasMlUserPermissions(capabilities); const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); const typeData: Overview = data[flowTarget]!; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx index 1e18023e0c326..19d1c698cbd9b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx @@ -5,7 +5,7 @@ */ import React, { useCallback } from 'react'; -import { EuiFlexGrid, EuiFlexItem, EuiRange, EuiFormRow } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiRange, EuiFormRow } from '@elastic/eui'; import { FieldHook } from '../../../../../shared_imports'; @@ -31,12 +31,11 @@ export const AnomalyThresholdSlider: React.FC = ({ return ( - + = ({ tickInterval={25} /> - +
); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.test.tsx index 7a3f0105d3d15..af946c6f02cbb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.test.tsx @@ -22,6 +22,7 @@ import { buildSeverityDescription, buildUrlsDescription, buildNoteDescription, + buildRuleTypeDescription, } from './helpers'; import { ListItems } from './types'; @@ -385,4 +386,30 @@ describe('helpers', () => { expect(result).toHaveLength(0); }); }); + + describe('buildRuleTypeDescription', () => { + it('returns the label for a machine_learning type', () => { + const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'machine_learning'); + + expect(result.title).toEqual('Test label'); + }); + + it('returns a humanized description for a machine_learning type', () => { + const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'machine_learning'); + + expect(result.description).toEqual('Machine Learning'); + }); + + it('returns the label for a query type', () => { + const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'query'); + + expect(result.title).toEqual('Test label'); + }); + + it('returns a humanized description for a query type', () => { + const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'query'); + + expect(result.description).toEqual('Query'); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx index 7b22078c89d1b..f9b255a95d869 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx @@ -27,6 +27,8 @@ import * as i18n from './translations'; import { BuildQueryBarDescription, BuildThreatDescription, ListItems } from './types'; import { SeverityBadge } from '../severity_badge'; import ListTreeIcon from './assets/list_tree_icon.svg'; +import { RuleType } from '../../../../../containers/detection_engine/rules'; +import { assertUnreachable } from '../../../../../lib/helpers'; const NoteDescriptionContainer = styled(EuiFlexItem)` height: 105px; @@ -266,3 +268,27 @@ export const buildNoteDescription = (label: string, note: string): ListItems[] = } return []; }; + +export const buildRuleTypeDescription = (label: string, ruleType: RuleType): ListItems[] => { + switch (ruleType) { + case 'machine_learning': { + return [ + { + title: label, + description: i18n.ML_TYPE_DESCRIPTION, + }, + ]; + } + case 'query': + case 'saved_query': { + return [ + { + title: label, + description: i18n.QUERY_TYPE_DESCRIPTION, + }, + ]; + } + default: + return assertUnreachable(ruleType); + } +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.test.tsx index 557da8677f777..a01aec0ccf2cf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.test.tsx @@ -27,6 +27,8 @@ import { schema } from '../step_about_rule/schema'; import { ListItems } from './types'; import { AboutStepRule } from '../../types'; +jest.mock('../../../../../lib/kibana'); + describe('description_step', () => { const setupMock = coreMock.createSetup(); const uiSettingsMock = (pinnedByDefault: boolean) => (key: string) => { @@ -41,13 +43,6 @@ describe('description_step', () => { let mockAboutStep: AboutStepRule; beforeEach(() => { - // jest carries state between mocked implementations when using - // spyOn. So now we're doing all three of these. - // https://github.com/facebook/jest/issues/7136#issuecomment-565976599 - jest.resetAllMocks(); - jest.restoreAllMocks(); - jest.clearAllMocks(); - setupMock.uiSettings.get.mockImplementation(uiSettingsMock(true)); mockFilterManager = new FilterManager(setupMock.uiSettings); mockAboutStep = mockAboutStepRule(); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index 43b4a5f781b89..69c4ee1017155 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -15,6 +15,7 @@ import { esFilters, FilterManager, } from '../../../../../../../../../../src/plugins/data/public'; +import { RuleType } from '../../../../../containers/detection_engine/rules'; import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations'; import { useKibana } from '../../../../../lib/kibana'; import { IMitreEnterpriseAttack } from '../../types'; @@ -29,7 +30,10 @@ import { buildUnorderedListArrayDescription, buildUrlsDescription, buildNoteDescription, + buildRuleTypeDescription, } from './helpers'; +import { useSiemJobs } from '../../../../../components/ml_popover/hooks/use_siem_jobs'; +import { buildMlJobDescription } from './ml_job_description'; const DescriptionListContainer = styled(EuiDescriptionList)` &.euiDescriptionList--column .euiDescriptionList__title { @@ -55,15 +59,22 @@ export const StepRuleDescriptionComponent: React.FC = }) => { const kibana = useKibana(); const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); + const [, siemJobs] = useSiemJobs(true); const keys = Object.keys(schema); - const listItems = keys.reduce( - (acc: ListItems[], key: string) => [ - ...acc, - ...buildListItems(data, pick(key, schema), filterManager, indexPatterns), - ], - [] - ); + const listItems = keys.reduce((acc: ListItems[], key: string) => { + if (key === 'machineLearningJobId') { + return [ + ...acc, + buildMlJobDescription( + get(key, data) as string, + (get(key, schema) as { label: string }).label, + siemJobs + ), + ]; + } + return [...acc, ...buildListItems(data, pick(key, schema), filterManager, indexPatterns)]; + }, []); if (columns === 'multi') { return ( @@ -176,6 +187,9 @@ export const getDescriptionItem = ( } else if (field === 'note') { const val: string = get(field, data); return buildNoteDescription(label, val); + } else if (field === 'ruleType') { + const ruleType: RuleType = get(field, data); + return buildRuleTypeDescription(label, ruleType); } const description: string = get(field, data); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx new file mode 100644 index 0000000000000..6697268defbac --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx @@ -0,0 +1,93 @@ +/* + * 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 styled from 'styled-components'; +import { EuiBadge, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; + +import { useKibana } from '../../../../../lib/kibana'; +import { SiemJob } from '../../../../../components/ml_popover/types'; +import { ListItems } from './types'; +import { isJobStarted } from '../../../../../components/ml/helpers'; +import { ML_JOB_STARTED, ML_JOB_STOPPED } from './translations'; + +enum MessageLevels { + info = 'info', + warning = 'warning', + error = 'error', +} + +const AuditIcon: React.FC<{ + message: SiemJob['auditMessage']; +}> = ({ message }) => { + if (!message) { + return null; + } + + let color = 'primary'; + let icon = 'alert'; + + if (message.level === MessageLevels.info) { + icon = 'iInCircle'; + } else if (message.level === MessageLevels.warning) { + color = 'warning'; + } else if (message.level === MessageLevels.error) { + color = 'danger'; + } + + return ( + + + + ); +}; + +export const JobStatusBadge: React.FC<{ job: SiemJob }> = ({ job }) => { + const isStarted = isJobStarted(job.jobState, job.datafeedState); + + return isStarted ? ( + {ML_JOB_STARTED} + ) : ( + {ML_JOB_STOPPED} + ); +}; + +const JobLink = styled(EuiLink)` + margin-right: ${({ theme }) => theme.eui.euiSizeS}; +`; + +const Wrapper = styled.div` + overflow: hidden; +`; + +export const MlJobDescription: React.FC<{ job: SiemJob }> = ({ job }) => { + const jobUrl = useKibana().services.application.getUrlForApp('ml#/jobs'); + + return ( + +
+ + {job.id} + + +
+ +
+ ); +}; + +export const buildMlJobDescription = ( + jobId: string, + label: string, + siemJobs: SiemJob[] +): ListItems => { + const siemJob = siemJobs.find(job => job.id === jobId); + + return { + title: label, + description: siemJob ? : jobId, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx index 9695fd21067ee..b494d824679f3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx @@ -17,3 +17,31 @@ export const QUERY_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule export const SAVED_ID_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule.savedIdLabel', { defaultMessage: 'Saved query name', }); + +export const ML_TYPE_DESCRIPTION = i18n.translate( + 'xpack.siem.detectionEngine.createRule.mlRuleTypeDescription', + { + defaultMessage: 'Machine Learning', + } +); + +export const QUERY_TYPE_DESCRIPTION = i18n.translate( + 'xpack.siem.detectionEngine.createRule.queryRuleTypeDescription', + { + defaultMessage: 'Query', + } +); + +export const ML_JOB_STARTED = i18n.translate( + 'xpack.siem.detectionEngine.ruleDescription.mlJobStartedDescription', + { + defaultMessage: 'Started', + } +); + +export const ML_JOB_STOPPED = i18n.translate( + 'xpack.siem.detectionEngine.ruleDescription.mlJobStoppedDescription', + { + defaultMessage: 'Stopped', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx index bc32162c2660b..3d253b71b53d6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx @@ -5,12 +5,39 @@ */ import React, { useCallback } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSuperSelect, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiLink, + EuiSuperSelect, + EuiText, +} from '@elastic/eui'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; import { useSiemJobs } from '../../../../../components/ml_popover/hooks/use_siem_jobs'; +import { useKibana } from '../../../../../lib/kibana'; +import { ML_JOB_SELECT_PLACEHOLDER_TEXT } from '../step_define_rule/translations'; -const JobDisplay = ({ title, description }: { title: string; description: string }) => ( +const HelpText: React.FC<{ href: string }> = ({ href }) => ( + + + + ), + }} + /> +); + +const JobDisplay: React.FC<{ title: string; description: string }> = ({ title, description }) => ( <> {title} @@ -28,23 +55,32 @@ export const MlJobSelect: React.FC = ({ describedByIds = [], f const jobId = field.value as string; const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const [isLoading, siemJobs] = useSiemJobs(false); + const mlUrl = useKibana().services.application.getUrlForApp('ml'); const handleJobChange = useCallback( (machineLearningJobId: string) => { field.setValue(machineLearningJobId); }, [field] ); + const placeholderOption = { + value: 'placeholder', + inputDisplay: ML_JOB_SELECT_PLACEHOLDER_TEXT, + dropdownDisplay: ML_JOB_SELECT_PLACEHOLDER_TEXT, + disabled: true, + }; - const options = siemJobs.map(job => ({ + const jobOptions = siemJobs.map(job => ({ value: job.id, inputDisplay: job.id, dropdownDisplay: , })); + const options = [placeholderOption, ...jobOptions]; + return ( } isInvalid={isInvalid} error={errorMessage} data-test-subj="mlJobSelect" @@ -57,7 +93,7 @@ export const MlJobSelect: React.FC = ({ describedByIds = [], f isLoading={isLoading} onChange={handleJobChange} options={options} - valueOfSelected={jobId} + valueOfSelected={jobId || 'placeholder'} />
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx index 923ec3a7f0066..27d668dc6166c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx @@ -59,7 +59,6 @@ export const PickTimeline = ({ helpText={field.helpText} error={errorMessage} isInvalid={isInvalid} - fullWidth data-test-subj={dataTestSubj} describedByIds={idAria ? [idAria] : undefined} > diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx index 219b3d6dc4d58..af7150e083817 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx @@ -48,14 +48,16 @@ interface SelectRuleTypeProps { describedByIds?: string[]; field: FieldHook; hasValidLicense?: boolean; + isMlAdmin?: boolean; isReadOnly?: boolean; } export const SelectRuleType: React.FC = ({ describedByIds = [], field, - hasValidLicense = false, isReadOnly = false, + hasValidLicense = false, + isMlAdmin = false, }) => { const ruleType = field.value as RuleType; const setType = useCallback( @@ -66,7 +68,7 @@ export const SelectRuleType: React.FC = ({ ); const setMl = useCallback(() => setType('machine_learning'), [setType]); const setQuery = useCallback(() => setType('query'), [setType]); - const mlCardDisabled = isReadOnly || !hasValidLicense; + const mlCardDisabled = isReadOnly || !hasValidLicense || !isMlAdmin; return ( ({ eui: euiDarkVars, darkMode: true }); describe('StepAboutRuleToggleDetails', () => { let mockRule: AboutStepRule; beforeEach(() => { - // jest carries state between mocked implementations when using - // spyOn. So now we're doing all three of these. - // https://github.com/facebook/jest/issues/7136#issuecomment-565976599 - jest.resetAllMocks(); - jest.restoreAllMocks(); - jest.clearAllMocks(); - mockRule = mockAboutStepRule(); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index 68ca1840871e3..6c46ab0b171a2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -5,7 +5,7 @@ */ import { EuiButtonEmpty, EuiFormRow } from '@elastic/eui'; -import React, { FC, memo, useCallback, useState, useEffect, useContext } from 'react'; +import React, { FC, memo, useCallback, useState, useEffect } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -13,7 +13,7 @@ import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/pu import { useFetchIndexPatterns } from '../../../../../containers/detection_engine/rules'; import { DEFAULT_INDEX_KEY } from '../../../../../../common/constants'; import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations'; -import { MlCapabilitiesContext } from '../../../../../components/ml/permissions/ml_capabilities_provider'; +import { useMlCapabilities } from '../../../../../components/ml_popover/hooks/use_ml_capabilities'; import { useUiSetting$ } from '../../../../../lib/kibana'; import { setFieldValue, isMlRule } from '../../helpers'; import { DefineStepRule, RuleStep, RuleStepProps } from '../../types'; @@ -37,6 +37,7 @@ import { import { schema } from './schema'; import * as i18n from './translations'; import { filterRuleFieldsForType, RuleFields } from '../../create/helpers'; +import { hasMlAdminPermissions } from '../../../../../components/ml/permissions/has_ml_admin_permissions'; const CommonUseField = getUseField({ component: Field }); @@ -85,7 +86,7 @@ const StepDefineRuleComponent: FC = ({ setForm, setStepData, }) => { - const mlCapabilities = useContext(MlCapabilitiesContext); + const mlCapabilities = useMlCapabilities(); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); const [indexModified, setIndexModified] = useState(false); const [localIsMlRule, setIsMlRule] = useState(false); @@ -162,8 +163,9 @@ const StepDefineRuleComponent: FC = ({ component={SelectRuleType} componentProps={{ describedByIds: ['detectionEngineStepDefineRuleType'], - hasValidLicense: mlCapabilities.isPlatinumOrTrialLicense, isReadOnly: isUpdateView, + hasValidLicense: mlCapabilities.isPlatinumOrTrialLicense, + isMlAdmin: hasMlAdminPermissions(mlCapabilities), }} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx index 8394f090e346c..1d8821aceb249 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx @@ -55,3 +55,10 @@ export const IMPORT_TIMELINE_QUERY = i18n.translate( defaultMessage: 'Import query from saved timeline', } ); + +export const ML_JOB_SELECT_PLACEHOLDER_TEXT = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.mlJobSelectPlaceholderText', + { + defaultMessage: 'Select a job', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/details/index.tsx index 8af4731e4dda4..a12c95b3b5a6f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/details/index.tsx @@ -5,7 +5,7 @@ */ import { EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; -import React, { useContext, useEffect, useCallback, useMemo } from 'react'; +import React, { useEffect, useCallback, useMemo } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { StickyContainer } from 'react-sticky'; @@ -15,7 +15,7 @@ import { LastEventTime } from '../../../components/last_event_time'; import { AnomalyTableProvider } from '../../../components/ml/anomaly/anomaly_table_provider'; import { hostToCriteria } from '../../../components/ml/criteria/host_to_criteria'; import { hasMlUserPermissions } from '../../../components/ml/permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../../../components/ml/permissions/ml_capabilities_provider'; +import { useMlCapabilities } from '../../../components/ml_popover/hooks/use_ml_capabilities'; import { scoreIntervalToDateTime } from '../../../components/ml/score/score_interval_to_datetime'; import { SiemNavigation } from '../../../components/navigation'; import { KpiHostsComponent } from '../../../components/page/hosts'; @@ -62,7 +62,7 @@ const HostDetailsComponent = React.memo( useEffect(() => { setHostDetailsTablesActivePageToZero(); }, [setHostDetailsTablesActivePageToZero, detailName]); - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const kibana = useKibana(); const hostDetailsPageFilters: Filter[] = useMemo(() => getHostDetailsPageFilters(detailName), [ detailName, diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx index a7aa9920b7d08..d574925a91600 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx @@ -14,7 +14,6 @@ import { FiltersGlobal } from '../../components/filters_global'; import { HeaderPage } from '../../components/header_page'; import { LastEventTime } from '../../components/last_event_time'; import { hasMlUserPermissions } from '../../components/ml/permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../../components/ml/permissions/ml_capabilities_provider'; import { SiemNavigation } from '../../components/navigation'; import { KpiHostsComponent } from '../../components/page/hosts'; import { manageQuery } from '../../components/page/manage_query'; @@ -30,6 +29,7 @@ import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from import { SpyRoute } from '../../utils/route/spy_routes'; import { esQuery } from '../../../../../../../src/plugins/data/public'; +import { useMlCapabilities } from '../../components/ml_popover/hooks/use_ml_capabilities'; import { HostsEmptyPage } from './hosts_empty_page'; import { HostsTabs } from './hosts_tabs'; import { navTabsHosts } from './nav_tabs'; @@ -52,7 +52,7 @@ export const HostsComponent = React.memo( to, hostsPagePath, }) => { - const capabilities = React.useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const kibana = useKibana(); const { tabName } = useParams(); const tabsFilters = React.useMemo(() => { diff --git a/x-pack/legacy/plugins/siem/public/pages/network/index.tsx b/x-pack/legacy/plugins/siem/public/pages/network/index.tsx index 48fc1421d90bb..babc153823b5a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/network/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/network/index.tsx @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { Redirect, Route, Switch, RouteComponentProps } from 'react-router-dom'; -import { MlCapabilitiesContext } from '../../components/ml/permissions/ml_capabilities_provider'; +import { useMlCapabilities } from '../../components/ml_popover/hooks/use_ml_capabilities'; import { hasMlUserPermissions } from '../../components/ml/permissions/has_ml_user_permissions'; import { FlowTarget } from '../../graphql/types'; @@ -24,7 +24,7 @@ const networkPagePath = `/:pageName(${SiemPageName.network})`; const ipDetailsPageBasePath = `${networkPagePath}/ip/:detailName`; const NetworkContainerComponent: React.FC = () => { - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const capabilitiesFetched = capabilities.capabilitiesFetched; const userHasMlUserPermissions = useMemo(() => hasMlUserPermissions(capabilities), [ capabilities, From aa73e2aee3738fb6437781e242dd3618d09401bd Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Tue, 24 Mar 2020 23:19:56 -0400 Subject: [PATCH 48/56] [Alerting] change index action config executionTimeField to nullable (#61127) resolves https://github.com/elastic/kibana/issues/61056 When the index action params moved into config, the `schema.maybe()` on the `executionTimeField` should have been changed to `schema.nullable()`, otherwise you can never "unset" the field, once it's set. Changes rippled down to the UI as well. To be extra safe, we also check that the `executionTimeField` isn't an empty string when trimmed, as ES will not accept a document with a property that is the empty string. --- .../builtin_action_types/es_index.test.ts | 21 ++++++++++++++----- .../server/builtin_action_types/es_index.ts | 7 ++++--- .../builtin_action_types/es_index.test.tsx | 20 ++++++++++++++++++ .../builtin_action_types/es_index.tsx | 19 +++++++++++++---- .../components/builtin_action_types/types.ts | 2 +- .../actions/builtin_action_types/es_index.ts | 3 ++- .../actions/builtin_action_types/es_index.ts | 3 ++- 7 files changed, 60 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts index 7eded9bb40964..ec495aed7675a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -52,6 +52,7 @@ describe('config validation', () => { ...config, index: 'testing-123', refresh: false, + executionTimeField: null, }); config.executionTimeField = 'field-123'; @@ -62,6 +63,14 @@ describe('config validation', () => { executionTimeField: 'field-123', }); + config.executionTimeField = null; + expect(validateConfig(actionType, config)).toEqual({ + ...config, + index: 'testing-123', + refresh: false, + executionTimeField: null, + }); + delete config.index; expect(() => { @@ -73,9 +82,11 @@ describe('config validation', () => { expect(() => { validateConfig(actionType, { index: 'testing-123', executionTimeField: true }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: [executionTimeField]: expected value of type [string] but got [boolean]"` - ); + }).toThrowErrorMatchingInlineSnapshot(` +"error validating action type config: [executionTimeField]: types that failed validation: +- [executionTimeField.0]: expected value of type [string] but got [boolean] +- [executionTimeField.1]: expected value to equal [null]" +`); delete config.refresh; expect(() => { @@ -138,12 +149,12 @@ describe('params validation', () => { describe('execute()', () => { test('ensure parameters are as expected', async () => { const secrets = {}; - let config: ActionTypeConfigType; + let config: Partial; let params: ActionParamsType; let executorOptions: ActionTypeExecutorOptions; // minimal params - config = { index: 'index-value', refresh: false, executionTimeField: undefined }; + config = { index: 'index-value', refresh: false }; params = { documents: [{ jim: 'bob' }], }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts index b86f0029b5383..ff7b27b3f51fc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts @@ -18,7 +18,7 @@ export type ActionTypeConfigType = TypeOf; const ConfigSchema = schema.object({ index: schema.string(), refresh: schema.boolean({ defaultValue: false }), - executionTimeField: schema.maybe(schema.string()), + executionTimeField: schema.nullable(schema.string()), }); // params definition @@ -63,8 +63,9 @@ async function executor( const bulkBody = []; for (const document of params.documents) { - if (config.executionTimeField != null) { - document[config.executionTimeField] = new Date(); + const timeField = config.executionTimeField == null ? '' : config.executionTimeField.trim(); + if (timeField !== '') { + document[timeField] = new Date(); } bulkBody.push({ index: {} }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx index f1d4790e67bbe..6ce954f61bcdb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx @@ -52,6 +52,26 @@ describe('index connector validation', () => { }); }); +describe('index connector validation with minimal config', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + name: 'es_index', + config: { + index: 'test_es_index', + }, + } as EsIndexActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + index: [], + }, + }); + }); +}); + describe('action params validation', () => { test('action params validation succeeds when action params is valid', () => { const actionParams = { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx index 00660b214c7cf..706d746b92995 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx @@ -79,7 +79,7 @@ const IndexActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, errors, http }) => { const { index, refresh, executionTimeField } = action.config; const [hasTimeFieldCheckbox, setTimeFieldCheckboxState] = useState( - executionTimeField !== undefined + executionTimeField != null ); const [indexPatterns, setIndexPatterns] = useState([]); @@ -206,6 +206,11 @@ const IndexActionConnectorFields: React.FunctionComponent { setTimeFieldCheckboxState(!hasTimeFieldCheckbox); + // if changing from checked to not checked (hasTimeField === true), + // set time field to null + if (hasTimeFieldCheckbox) { + editActionConfig('executionTimeField', null); + } }} label={ <> @@ -245,13 +250,13 @@ const IndexActionConnectorFields: React.FunctionComponent { - editActionConfig('executionTimeField', e.target.value); + editActionConfig('executionTimeField', nullableString(e.target.value)); }} onBlur={() => { if (executionTimeField === undefined) { - editActionConfig('executionTimeField', ''); + editActionConfig('executionTimeField', null); } }} /> @@ -312,3 +317,9 @@ const IndexParamsFields: React.FunctionComponent ); }; + +// if the string == null or is empty, return null, else return string +function nullableString(str: string | null | undefined) { + if (str == null || str.trim() === '') return null; + return str; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts index 2e0576d933f90..fd35db4304275 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts @@ -83,7 +83,7 @@ export interface EmailActionConnector extends ActionConnector { interface EsIndexConfig { index: string; - executionTimeField?: string; + executionTimeField?: string | null; refresh?: boolean; } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts index 6d76a00d39b97..01eaf92da33fe 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts @@ -45,6 +45,7 @@ export default function indexTest({ getService }: FtrProviderContext) { config: { index: ES_TEST_INDEX_NAME, refresh: false, + executionTimeField: null, }, }); createdActionID = createdAction.id; @@ -58,7 +59,7 @@ export default function indexTest({ getService }: FtrProviderContext) { id: fetchedAction.id, name: 'An index action', actionTypeId: '.index', - config: { index: ES_TEST_INDEX_NAME, refresh: false }, + config: { index: ES_TEST_INDEX_NAME, refresh: false, executionTimeField: null }, }); // create action with all config props diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/es_index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/es_index.ts index 5cc3d7275a7bd..3713e9c24419f 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/es_index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/es_index.ts @@ -43,6 +43,7 @@ export default function indexTest({ getService }: FtrProviderContext) { config: { index: ES_TEST_INDEX_NAME, refresh: false, + executionTimeField: null, }, }); createdActionID = createdAction.id; @@ -56,7 +57,7 @@ export default function indexTest({ getService }: FtrProviderContext) { id: fetchedAction.id, name: 'An index action', actionTypeId: '.index', - config: { index: ES_TEST_INDEX_NAME, refresh: false }, + config: { index: ES_TEST_INDEX_NAME, refresh: false, executionTimeField: null }, }); // create action with all config props From 29a3f559855c92e10197e2d2358248fe0af6925d Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Tue, 24 Mar 2020 21:24:37 -0600 Subject: [PATCH 49/56] [Maps] clean up icon category UI (#61116) * [Maps] clean up icon category UI * fix jest tests * add unit test for getFirstUnusedSymbol * remove duplicate icon stop values Co-authored-by: Elastic Machine --- .../migrate_symbol_style_descriptor.test.js | 2 +- .../vector/components/color/_color_stops.scss | 10 --- .../vector/components/color/color_stops.js | 2 +- .../vector/components/symbol/icon_select.js | 1 + .../vector/components/symbol/icon_stops.js | 81 ++++++++++++------- .../components/symbol/icon_stops.test.js | 59 ++++++++++++++ .../layers/styles/vector/symbol_utils.js | 10 +++ .../layers/styles/vector/vector_style.test.js | 2 +- x-pack/plugins/maps/common/constants.ts | 2 +- 9 files changed, 128 insertions(+), 41 deletions(-) create mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/symbol/icon_stops.test.js diff --git a/x-pack/legacy/plugins/maps/common/migrations/migrate_symbol_style_descriptor.test.js b/x-pack/legacy/plugins/maps/common/migrations/migrate_symbol_style_descriptor.test.js index 2811b83f46d8f..fc0151083855c 100644 --- a/x-pack/legacy/plugins/maps/common/migrations/migrate_symbol_style_descriptor.test.js +++ b/x-pack/legacy/plugins/maps/common/migrations/migrate_symbol_style_descriptor.test.js @@ -120,7 +120,7 @@ describe('migrateSymbolStyleDescriptor', () => { }, icon: { type: STYLE_TYPE.STATIC, - options: { value: 'airfield' }, + options: { value: 'marker' }, }, }, }, diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/_color_stops.scss b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/_color_stops.scss index 519e97f4b30cd..09a9ad59bce3c 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/_color_stops.scss +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/_color_stops.scss @@ -4,15 +4,5 @@ & + & { margin-top: $euiSizeS; } - - &:hover, - &:focus { - .mapColorStop__icons { - visibility: visible; - opacity: 1; - display: block; - animation: mapColorStopBecomeVisible $euiAnimSpeedFast $euiAnimSlightResistance; - } - } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops.js index 3e9b9e2aafc47..059543d705fc7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops.js @@ -12,7 +12,7 @@ import { EuiButtonIcon, EuiColorPicker, EuiFlexGroup, EuiFlexItem, EuiFormRow } function getColorStopRow({ index, errors, stopInput, onColorChange, color, deleteButton, onAdd }) { const colorPickerButtons = ( -
+
{deleteButton} { @@ -19,9 +20,31 @@ function isDuplicateStop(targetStop, iconStops) { return stops.length > 1; } +export function getFirstUnusedSymbol(symbolOptions, iconStops) { + const firstUnusedPreferredIconId = PREFERRED_ICONS.find(iconId => { + const isSymbolBeingUsed = iconStops.some(({ icon }) => { + return icon === iconId; + }); + return !isSymbolBeingUsed; + }); + + if (firstUnusedPreferredIconId) { + return firstUnusedPreferredIconId; + } + + const firstUnusedSymbol = symbolOptions.find(({ value }) => { + const isSymbolBeingUsed = iconStops.some(({ icon }) => { + return icon === value; + }); + return !isSymbolBeingUsed; + }); + + return firstUnusedSymbol ? firstUnusedSymbol.value : DEFAULT_ICON; +} + const DEFAULT_ICON_STOPS = [ - { stop: null, icon: DEFAULT_ICON }, //first stop is the "other" color - { stop: '', icon: DEFAULT_ICON }, + { stop: null, icon: PREFERRED_ICONS[0] }, //first stop is the "other" color + { stop: '', icon: PREFERRED_ICONS[1] }, ]; export function IconStops({ @@ -58,7 +81,7 @@ export function IconStops({ ...iconStops.slice(0, index + 1), { stop: '', - icon: DEFAULT_ICON, + icon: getFirstUnusedSymbol(symbolOptions, iconStops), }, ...iconStops.slice(index + 1), ], @@ -66,12 +89,12 @@ export function IconStops({ }; const onRemove = () => { onChange({ - iconStops: [...iconStops.slice(0, index), ...iconStops.slice(index + 1)], + customMapStops: [...iconStops.slice(0, index), ...iconStops.slice(index + 1)], }); }; let deleteButton; - if (index > 0) { + if (iconStops.length > 2 && index !== 0) { deleteButton = ( + {deleteButton} + +
+ ); + const errors = []; // TODO check for duplicate values and add error messages here @@ -116,29 +152,20 @@ export function IconStops({ error={errors} display="rowCompressed" > -
- - {stopInput} - - - - -
- {deleteButton} - + + {stopInput} + + + -
-
+ + ); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/symbol/icon_stops.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/symbol/icon_stops.test.js new file mode 100644 index 0000000000000..ffe9b6feef462 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/symbol/icon_stops.test.js @@ -0,0 +1,59 @@ +/* + * 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 { getFirstUnusedSymbol } from './icon_stops'; + +describe('getFirstUnusedSymbol', () => { + const symbolOptions = [{ value: 'icon1' }, { value: 'icon2' }]; + + test('Should return first unused icon from PREFERRED_ICONS', () => { + const iconStops = [ + { stop: 'category1', icon: 'circle' }, + { stop: 'category2', icon: 'marker' }, + ]; + const nextIcon = getFirstUnusedSymbol(symbolOptions, iconStops); + expect(nextIcon).toBe('square'); + }); + + test('Should fallback to first unused general icons when all PREFERRED_ICONS are used', () => { + const iconStops = [ + { stop: 'category1', icon: 'circle' }, + { stop: 'category2', icon: 'marker' }, + { stop: 'category3', icon: 'square' }, + { stop: 'category4', icon: 'star' }, + { stop: 'category5', icon: 'triangle' }, + { stop: 'category6', icon: 'hospital' }, + { stop: 'category7', icon: 'circle-stroked' }, + { stop: 'category8', icon: 'marker-stroked' }, + { stop: 'category9', icon: 'square-stroked' }, + { stop: 'category10', icon: 'star-stroked' }, + { stop: 'category11', icon: 'triangle-stroked' }, + { stop: 'category12', icon: 'icon1' }, + ]; + const nextIcon = getFirstUnusedSymbol(symbolOptions, iconStops); + expect(nextIcon).toBe('icon2'); + }); + + test('Should fallback to default icon when all icons are used', () => { + const iconStops = [ + { stop: 'category1', icon: 'circle' }, + { stop: 'category2', icon: 'marker' }, + { stop: 'category3', icon: 'square' }, + { stop: 'category4', icon: 'star' }, + { stop: 'category5', icon: 'triangle' }, + { stop: 'category6', icon: 'hospital' }, + { stop: 'category7', icon: 'circle-stroked' }, + { stop: 'category8', icon: 'marker-stroked' }, + { stop: 'category9', icon: 'square-stroked' }, + { stop: 'category10', icon: 'star-stroked' }, + { stop: 'category11', icon: 'triangle-stroked' }, + { stop: 'category12', icon: 'icon1' }, + { stop: 'category13', icon: 'icon2' }, + ]; + const nextIcon = getFirstUnusedSymbol(symbolOptions, iconStops); + expect(nextIcon).toBe('marker'); + }); +}); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/symbol_utils.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/symbol_utils.js index affb9c1805170..c1c4375faaeb1 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/symbol_utils.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/symbol_utils.js @@ -101,6 +101,16 @@ const ICON_PALETTES = [ }, ]; +// PREFERRED_ICONS is used to provide less random default icon values for forms that need default icon values +export const PREFERRED_ICONS = []; +ICON_PALETTES.forEach(iconPalette => { + iconPalette.icons.forEach(iconId => { + if (!PREFERRED_ICONS.includes(iconId)) { + PREFERRED_ICONS.push(iconId); + } + }); +}); + export function getIconPaletteOptions(isDarkMode) { return ICON_PALETTES.map(({ id, icons }) => { const iconsDisplay = icons.map(iconId => { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js index d669fd280e32c..426f1d6afa952 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js @@ -96,7 +96,7 @@ describe('getDescriptorWithMissingStylePropsRemoved', () => { }, icon: { options: { - value: 'airfield', + value: 'marker', }, type: 'STATIC', }, diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 12b03f0386304..814825483d0dd 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -188,7 +188,7 @@ export enum LABEL_BORDER_SIZES { LARGE = 'LARGE', } -export const DEFAULT_ICON = 'airfield'; +export const DEFAULT_ICON = 'marker'; export enum VECTOR_STYLES { SYMBOLIZE_AS = 'symbolizeAs', From 96852249e80d718cd08e51055df4229460c38dda Mon Sep 17 00:00:00 2001 From: "Devin W. Hurley" Date: Tue, 24 Mar 2020 23:49:08 -0400 Subject: [PATCH 50/56] [SIEM] [Detection Engine] Rule activity monitoring (#60816) * backend rule monitoring with gap desc, last look back date, and time duration of search after and bulk create operations * adds new properties to mocked request_response status saved object * first pass at UI table * migrate rule monitoring backend to work with refactor of rule executor, fix some formatting stuff on the frontend, update the mapping for gap to be a string instead of a float * trying to write a test for rules statuses hook * fixed hooks tests * fixes merge conflicts from rebase with master * add columns for indexing and query time lapse * i18n * i18n for tabs * don't change the mappings in ml es_archives * remove accidental commit of interval change for shell script detection engine rule * removes inline object from prop * fix merge conflicts * backend changes from pr comments * updates ui changes from pr feedback * fix tests and add formatting for dates * remove null from rulesStatuses initial state and replace with empty array --- .../detection_engine/rules/__mocks__/api.ts | 30 +++++ .../containers/detection_engine/rules/api.ts | 26 ++++ .../detection_engine/rules/types.ts | 4 + .../rules/use_rule_status.test.tsx | 104 ++++++++++++++- .../rules/use_rule_status.tsx | 65 ++++++++- .../detection_engine/rules/all/columns.tsx | 124 +++++++++++++++++- .../detection_engine/rules/all/index.tsx | 90 ++++++------- .../components/all_rules_tables/index.tsx | 121 +++++++++++++++++ .../detection_engine/rules/translations.ts | 50 +++++++ .../routes/__mocks__/request_responses.ts | 8 ++ .../rules/saved_object_mappings.ts | 19 ++- .../lib/detection_engine/rules/types.ts | 4 + .../signals/bulk_create_ml_signals.ts | 6 +- .../get_current_status_saved_object.ts | 4 + .../signals/search_after_bulk_create.test.ts | 32 ++--- .../signals/search_after_bulk_create.ts | 64 ++++++--- .../signals/signal_rule_alert_type.ts | 45 +++++-- .../signals/single_bulk_create.test.ts | 20 +-- .../signals/single_bulk_create.ts | 16 ++- .../signals/single_search_after.test.ts | 4 +- .../signals/single_search_after.ts | 11 +- .../lib/detection_engine/signals/utils.ts | 2 + .../signals/write_current_status_succeeded.ts | 15 +++ .../write_gap_error_to_saved_object.ts | 1 + ...e_signal_rule_exception_to_saved_object.ts | 15 +++ 25 files changed, 759 insertions(+), 121 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/all_rules_tables/index.tsx diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/__mocks__/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/__mocks__/api.ts index 9f37f3fecd508..6c9964af25430 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/__mocks__/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/__mocks__/api.ts @@ -52,6 +52,36 @@ export const getRuleStatusById = async ({ last_success_at: 'mm/dd/yyyyTHH:MM:sssz', last_failure_message: null, last_success_message: 'it is a success', + gap: null, + bulk_create_time_durations: ['2235.01'], + search_after_time_durations: ['616.97'], + last_look_back_date: '2020-03-19T00:32:07.996Z', + }, + failures: [], + }, + }); + +export const getRulesStatusByIds = async ({ + ids, + signal, +}: { + ids: string[]; + signal: AbortSignal; +}): Promise => + Promise.resolve({ + '12345678987654321': { + current_status: { + alert_id: 'alertId', + status_date: 'mm/dd/yyyyTHH:MM:sssz', + status: 'succeeded', + last_failure_at: null, + last_success_at: 'mm/dd/yyyyTHH:MM:sssz', + last_failure_message: null, + last_success_message: 'it is a success', + gap: null, + bulk_create_time_durations: ['2235.01'], + search_after_time_durations: ['616.97'], + last_look_back_date: '2020-03-19T00:32:07.996Z', }, failures: [], }, diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index 126de9762a696..4b0e0030be53d 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -271,6 +271,32 @@ export const getRuleStatusById = async ({ signal, }); +/** + * Return rule statuses given list of alert ids + * + * @param ids array of string of Rule ID's (not rule_id) + * @param signal AbortSignal for cancelling request + * + * @throws An error if response is not OK + */ +export const getRulesStatusByIds = async ({ + ids, + signal, +}: { + ids: string[]; + signal: AbortSignal; +}): Promise => { + const res = await KibanaServices.get().http.fetch( + DETECTION_ENGINE_RULES_STATUS_URL, + { + method: 'GET', + query: { ids: JSON.stringify(ids) }, + signal, + } + ); + return res; +}; + /** * Fetch all unique Tags used by Rules * diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index 3ec3e6d2b3036..53a1c0770028c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -235,6 +235,10 @@ export interface RuleInfoStatus { last_success_at: string | null; last_failure_message: string | null; last_success_message: string | null; + last_look_back_date: string | null | undefined; + gap: string | null | undefined; + bulk_create_time_durations: string[] | null | undefined; + search_after_time_durations: string[] | null | undefined; } export type RuleStatusResponse = Record; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx index 25011adcfe98b..834fc1b4196da 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx @@ -4,13 +4,63 @@ * you may not use this file except in compliance with the Elastic License. */ -import { renderHook, act } from '@testing-library/react-hooks'; -import { useRuleStatus, ReturnRuleStatus } from './use_rule_status'; +import { renderHook, act, cleanup } from '@testing-library/react-hooks'; +import { + useRuleStatus, + ReturnRuleStatus, + useRulesStatuses, + ReturnRulesStatuses, +} from './use_rule_status'; import * as api from './api'; +import { RuleType } from '../rules/types'; jest.mock('./api'); +const testRule = { + created_at: 'mm/dd/yyyyTHH:MM:sssz', + created_by: 'mockUser', + description: 'some desc', + enabled: true, + false_positives: [], + filters: [], + from: 'now-360s', + id: '12345678987654321', + immutable: false, + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + interval: '5m', + language: 'kuery', + name: 'Test rule', + max_signals: 100, + query: "user.email: 'root@elastic.co'", + references: [], + risk_score: 75, + rule_id: 'bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf', + severity: 'high', + tags: ['APM'], + threat: [], + to: 'now', + type: 'query' as RuleType, + updated_at: 'mm/dd/yyyyTHH:MM:sssz', + updated_by: 'mockUser', +}; + describe('useRuleStatus', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + afterEach(async () => { + cleanup(); + }); + test('init', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => @@ -39,6 +89,10 @@ describe('useRuleStatus', () => { last_success_message: 'it is a success', status: 'succeeded', status_date: 'mm/dd/yyyyTHH:MM:sssz', + gap: null, + bulk_create_time_durations: ['2235.01'], + search_after_time_durations: ['616.97'], + last_look_back_date: '2020-03-19T00:32:07.996Z', }, failures: [], }, @@ -62,4 +116,50 @@ describe('useRuleStatus', () => { expect(spyOngetRuleStatusById).toHaveBeenCalledTimes(2); }); }); + + test('init rules statuses', async () => { + const payload = [testRule]; + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useRulesStatuses(payload) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ loading: false, rulesStatuses: [] }); + }); + }); + + test('fetch rules statuses', async () => { + const payload = [testRule]; + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useRulesStatuses(payload) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: false, + rulesStatuses: [ + { + current_status: { + alert_id: 'alertId', + bulk_create_time_durations: ['2235.01'], + gap: null, + last_failure_at: null, + last_failure_message: null, + last_look_back_date: '2020-03-19T00:32:07.996Z', + last_success_at: 'mm/dd/yyyyTHH:MM:sssz', + last_success_message: 'it is a success', + search_after_time_durations: ['616.97'], + status: 'succeeded', + status_date: 'mm/dd/yyyyTHH:MM:sssz', + }, + failures: [], + id: '12345678987654321', + activate: true, + name: 'Test rule', + }, + ], + }); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx index 8d06e037e0979..0d37cce1fd85c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx @@ -7,12 +7,17 @@ import { useEffect, useRef, useState } from 'react'; import { errorToToaster, useStateToaster } from '../../../components/toasters'; -import { getRuleStatusById } from './api'; +import { RuleStatusRowItemType } from '../../../pages/detection_engine/rules/all/columns'; +import { getRuleStatusById, getRulesStatusByIds } from './api'; import * as i18n from './translations'; -import { RuleStatus } from './types'; +import { RuleStatus, Rules } from './types'; type Func = (ruleId: string) => void; export type ReturnRuleStatus = [boolean, RuleStatus | null, Func | null]; +export interface ReturnRulesStatuses { + loading: boolean; + rulesStatuses: RuleStatusRowItemType[] | null; +} /** * Hook for using to get a Rule from the Detection Engine API @@ -33,7 +38,6 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus = const fetchData = async (idToFetch: string) => { try { setLoading(true); - const ruleStatusResponse = await getRuleStatusById({ id: idToFetch, signal: abortCtrl.signal, @@ -64,3 +68,58 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus = return [loading, ruleStatus, fetchRuleStatus.current]; }; + +/** + * Hook for using to get all the statuses for all given rule ids + * + * @param ids desired Rule ID's (not rule_id) + * + */ +export const useRulesStatuses = (rules: Rules): ReturnRulesStatuses => { + const [rulesStatuses, setRuleStatuses] = useState([]); + const [loading, setLoading] = useState(false); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const fetchData = async (ids: string[]) => { + try { + setLoading(true); + const ruleStatusesResponse = await getRulesStatusByIds({ + ids, + signal: abortCtrl.signal, + }); + + if (isSubscribed) { + setRuleStatuses( + rules.map(rule => ({ + id: rule.id, + activate: rule.enabled, + name: rule.name, + ...ruleStatusesResponse[rule.id], + })) + ); + } + } catch (error) { + if (isSubscribed) { + setRuleStatuses([]); + errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + } + } + if (isSubscribed) { + setLoading(false); + } + }; + if (rules != null && rules.length > 0) { + fetchData(rules.map(r => r.id)); + } + return () => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [rules]); + + return { loading, rulesStatuses }; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index be64499fd47fa..7bfccc554b7e6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -14,10 +14,11 @@ import { EuiText, EuiHealth, } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; import * as H from 'history'; import React, { Dispatch } from 'react'; -import { Rule } from '../../../../containers/detection_engine/rules'; +import { Rule, RuleStatus } from '../../../../containers/detection_engine/rules'; import { getEmptyTagValue } from '../../../../components/empty_value'; import { FormattedDate } from '../../../../components/formatted_date'; import { getRuleDetailsUrl } from '../../../../components/link_to/redirect_to_detection_engine'; @@ -34,6 +35,7 @@ import { exportRulesAction, } from './actions'; import { Action } from './reducer'; +import { LocalizedDateTooltip } from '../../../../components/localized_date_tooltip'; export const getActions = ( dispatch: React.Dispatch, @@ -75,7 +77,12 @@ export const getActions = ( }, ]; +export type RuleStatusRowItemType = RuleStatus & { + name: string; + id: string; +}; type RulesColumns = EuiBasicTableColumn | EuiTableActionsColumnType; +type RulesStatusesColumns = EuiBasicTableColumn; interface GetColumns { dispatch: React.Dispatch; @@ -132,7 +139,9 @@ export const getColumns = ({ return value == null ? ( getEmptyTagValue() ) : ( - + + + ); }, sortable: true, @@ -196,3 +205,114 @@ export const getColumns = ({ return hasNoPermissions ? cols : [...cols, ...actions]; }; + +export const getMonitoringColumns = (): RulesStatusesColumns[] => { + const cols: RulesStatusesColumns[] = [ + { + field: 'name', + name: i18n.COLUMN_RULE, + render: (value: RuleStatus['current_status']['status'], item: RuleStatusRowItemType) => { + return ( + + {value} + + ); + }, + truncateText: true, + width: '24%', + }, + { + field: 'current_status.bulk_create_time_durations', + name: i18n.COLUMN_INDEXING_TIMES, + render: (value: RuleStatus['current_status']['bulk_create_time_durations']) => ( + + {value != null && value.length > 0 + ? Math.max(...value?.map(item => Number.parseFloat(item))) + : null} + + ), + truncateText: true, + width: '14%', + }, + { + field: 'current_status.search_after_time_durations', + name: i18n.COLUMN_QUERY_TIMES, + render: (value: RuleStatus['current_status']['search_after_time_durations']) => ( + + {value != null && value.length > 0 + ? Math.max(...value?.map(item => Number.parseFloat(item))) + : null} + + ), + truncateText: true, + width: '14%', + }, + { + field: 'current_status.gap', + name: i18n.COLUMN_GAP, + render: (value: RuleStatus['current_status']['gap']) => ( + + {value} + + ), + truncateText: true, + width: '14%', + }, + { + field: 'current_status.last_look_back_date', + name: i18n.COLUMN_LAST_LOOKBACK_DATE, + render: (value: RuleStatus['current_status']['last_look_back_date']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + + ); + }, + truncateText: true, + width: '16%', + }, + { + field: 'current_status.status_date', + name: i18n.COLUMN_LAST_COMPLETE_RUN, + render: (value: RuleStatus['current_status']['status_date']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + + + + ); + }, + sortable: true, + truncateText: true, + width: '20%', + }, + { + field: 'current_status.status', + name: i18n.COLUMN_LAST_RESPONSE, + render: (value: RuleStatus['current_status']['status']) => { + return ( + <> + + {value ?? getEmptyTagValue()} + + + ); + }, + width: '16%', + truncateText: true, + }, + { + field: 'activate', + name: i18n.COLUMN_ACTIVATE, + render: (value: Rule['enabled']) => ( + + {value ? i18n.ACTIVE : i18n.INACTIVE} + + ), + width: '95px', + }, + ]; + + return cols; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 621c70e391319..1a0de46729312 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -4,20 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiBasicTable, - EuiContextMenuPanel, - EuiEmptyPrompt, - EuiLoadingContent, - EuiSpacer, -} from '@elastic/eui'; +import { EuiBasicTable, EuiContextMenuPanel, EuiLoadingContent, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; -import styled from 'styled-components'; import uuid from 'uuid'; import { useRules, + useRulesStatuses, CreatePreBuiltRules, FilterOptions, Rule, @@ -37,20 +31,16 @@ import { Loader } from '../../../../components/loader'; import { Panel } from '../../../../components/panel'; import { PrePackagedRulesPrompt } from '../components/pre_packaged_rules/load_empty_prompt'; import { GenericDownloader } from '../../../../components/generic_downloader'; +import { AllRulesTables } from '../components/all_rules_tables'; import { getPrePackagedRuleStatus } from '../helpers'; import * as i18n from '../translations'; import { EuiBasicTableOnChange } from '../types'; import { getBatchItems } from './batch_actions'; -import { getColumns } from './columns'; +import { getColumns, getMonitoringColumns } from './columns'; import { showRulesTable } from './helpers'; import { allRulesReducer, State } from './reducer'; import { RulesTableFilters } from './rules_table_filters/rules_table_filters'; -// EuiBasicTable give me a hardtime with adding the ref attributes so I went the easy way -// after few hours of fight with typescript !!!! I lost :( -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const MyEuiBasicTable = styled(EuiBasicTable as any)`` as any; - const initialState: State = { exportRuleIds: [], filterOptions: { @@ -117,6 +107,7 @@ export const AllRules = React.memo( }, dispatch, ] = useReducer(allRulesReducer(tableRef), initialState); + const { loading: isLoadingRulesStatuses, rulesStatuses } = useRulesStatuses(rules); const history = useHistory(); const [, dispatchToaster] = useStateToaster(); @@ -135,6 +126,13 @@ export const AllRules = React.memo( dispatchRulesInReducer: setRules, }); + const sorting = useMemo( + () => ({ + sort: { field: 'enabled', direction: filterOptions.sortOrder }, + }), + [filterOptions.sortOrder] + ); + const prePackagedRuleStatus = getPrePackagedRuleStatus( rulesInstalled, rulesNotInstalled, @@ -158,6 +156,16 @@ export const AllRules = React.memo( [dispatch, dispatchToaster, loadingRuleIds, reFetchRulesData, rules, selectedRuleIds] ); + const paginationMemo = useMemo( + () => ({ + pageIndex: pagination.page - 1, + pageSize: pagination.perPage, + totalItemCount: pagination.total, + pageSizeOptions: [5, 10, 20, 50, 100, 200, 300], + }), + [pagination] + ); + const tableOnChangeCallback = useCallback( ({ page, sort }: EuiBasicTableOnChange) => { dispatch({ @@ -172,7 +180,7 @@ export const AllRules = React.memo( [dispatch] ); - const columns = useMemo(() => { + const rulesColumns = useMemo(() => { return getColumns({ dispatch, dispatchToaster, @@ -187,6 +195,8 @@ export const AllRules = React.memo( }); }, [dispatch, dispatchToaster, history, loadingRuleIds, loadingRulesAction, reFetchRulesData]); + const monitoringColumns = useMemo(() => getMonitoringColumns(), []); + useEffect(() => { if (reFetchRulesData != null) { setRefreshRulesData(reFetchRulesData); @@ -194,10 +204,10 @@ export const AllRules = React.memo( }, [reFetchRulesData, setRefreshRulesData]); useEffect(() => { - if (initLoading && !loading && !isLoadingRules) { + if (initLoading && !loading && !isLoadingRules && !isLoadingRulesStatuses) { setInitLoading(false); } - }, [initLoading, loading, isLoadingRules]); + }, [initLoading, loading, isLoadingRules, isLoadingRulesStatuses]); const handleCreatePrePackagedRules = useCallback(async () => { if (createPrePackagedRules != null && reFetchRulesData != null) { @@ -225,12 +235,6 @@ export const AllRules = React.memo( }); }, []); - const emptyPrompt = useMemo(() => { - return ( - {i18n.NO_RULES}} titleSize="xs" body={i18n.NO_RULES_BODY} /> - ); - }, []); - const isLoadingAnActionOnRule = useMemo(() => { if ( loadingRuleIds.length > 0 && @@ -264,7 +268,7 @@ export const AllRules = React.memo( /> - + <> ( /> - {(loading || isLoadingRules || isLoadingAnActionOnRule) && !initLoading && ( - - )} + {(loading || isLoadingRules || isLoadingAnActionOnRule || isLoadingRulesStatuses) && + !initLoading && ( + + )} {rulesCustomInstalled != null && rulesCustomInstalled === 0 && - prePackagedRuleStatus === 'ruleNotInstalled' && ( + prePackagedRuleStatus === 'ruleNotInstalled' && + !initLoading && ( ( - )} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/all_rules_tables/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/all_rules_tables/index.tsx new file mode 100644 index 0000000000000..92ccbc864ab5a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/all_rules_tables/index.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 { EuiBasicTable, EuiTab, EuiTabs, EuiEmptyPrompt } from '@elastic/eui'; +import React, { useMemo, memo, useState } from 'react'; +import styled from 'styled-components'; + +import * as i18n from '../../translations'; +import { RuleStatusRowItemType } from '../../../../../pages/detection_engine/rules/all/columns'; +import { Rules } from '../../../../../containers/detection_engine/rules'; + +// EuiBasicTable give me a hardtime with adding the ref attributes so I went the easy way +// after few hours of fight with typescript !!!! I lost :( +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const MyEuiBasicTable = styled(EuiBasicTable as any)`` as any; + +interface AllRulesTablesProps { + euiBasicTableSelectionProps: unknown; + hasNoPermissions: boolean; + monitoringColumns: unknown; + paginationMemo: unknown; + rules: Rules; + rulesColumns: unknown; + rulesStatuses: RuleStatusRowItemType[] | null; + sorting: unknown; + tableOnChangeCallback: unknown; + tableRef?: unknown; +} + +enum AllRulesTabs { + rules = 'rules', + monitoring = 'monitoring', +} + +const allRulesTabs = [ + { + id: AllRulesTabs.rules, + name: i18n.RULES_TAB, + disabled: false, + }, + { + id: AllRulesTabs.monitoring, + name: i18n.MONITORING_TAB, + disabled: false, + }, +]; + +const AllRulesTablesComponent: React.FC = ({ + euiBasicTableSelectionProps, + hasNoPermissions, + monitoringColumns, + paginationMemo, + rules, + rulesColumns, + rulesStatuses, + sorting, + tableOnChangeCallback, + tableRef, +}) => { + const [allRulesTab, setAllRulesTab] = useState(AllRulesTabs.rules); + const emptyPrompt = useMemo(() => { + return ( + {i18n.NO_RULES}} titleSize="xs" body={i18n.NO_RULES_BODY} /> + ); + }, []); + const tabs = useMemo( + () => ( + + {allRulesTabs.map(tab => ( + setAllRulesTab(tab.id)} + isSelected={tab.id === allRulesTab} + disabled={tab.disabled} + key={tab.id} + > + {tab.name} + + ))} + + ), + [allRulesTabs, allRulesTab, setAllRulesTab] + ); + return ( + <> + {tabs} + {allRulesTab === AllRulesTabs.rules && ( + + )} + {allRulesTab === AllRulesTabs.monitoring && ( + + )} + + ); +}; + +export const AllRulesTables = memo(AllRulesTablesComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts index 1c547e215dfdd..882263934477d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts @@ -44,6 +44,20 @@ export const BATCH_ACTIONS = i18n.translate( } ); +export const ACTIVE = i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.activeRuleDescription', + { + defaultMessage: 'active', + } +); + +export const INACTIVE = i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.inactiveRuleDescription', + { + defaultMessage: 'inactive', + } +); + export const BATCH_ACTION_ACTIVATE_SELECTED = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.batchActions.activateSelectedTitle', { @@ -255,6 +269,42 @@ export const COLUMN_ACTIVATE = i18n.translate( } ); +export const COLUMN_INDEXING_TIMES = i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.columns.indexingTimes', + { + defaultMessage: 'Indexing Time (ms)', + } +); + +export const COLUMN_QUERY_TIMES = i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.columns.queryTimes', + { + defaultMessage: 'Query Time (ms)', + } +); + +export const COLUMN_GAP = i18n.translate('xpack.siem.detectionEngine.rules.allRules.columns.gap', { + defaultMessage: 'Gap (if any)', +}); + +export const COLUMN_LAST_LOOKBACK_DATE = i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.columns.lastLookBackDate', + { + defaultMessage: 'Last Look-Back Date', + } +); + +export const RULES_TAB = i18n.translate('xpack.siem.detectionEngine.rules.allRules.tabs.rules', { + defaultMessage: 'Rules', +}); + +export const MONITORING_TAB = i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.tabs.monitoring', + { + defaultMessage: 'Monitoring', + } +); + export const CUSTOM_RULES = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.filters.customRulesTitle', { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 0ecc1aa28e7e0..24f50c5ce87a0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -592,6 +592,10 @@ export const getFindResultStatus = (): SavedObjectsFindResponse; -} = { +export const ruleStatusSavedObjectMappings = { [ruleStatusSavedObjectType]: { properties: { alertId: { @@ -35,6 +30,18 @@ export const ruleStatusSavedObjectMappings: { lastSuccessMessage: { type: 'text', }, + lastLookBackDate: { + type: 'date', + }, + gap: { + type: 'text', + }, + bulkCreateTimeDurations: { + type: 'float', + }, + searchAfterTimeDurations: { + type: 'float', + }, }, }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts index 1efa46c6b8b57..ada11174c5340 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts @@ -60,6 +60,10 @@ export interface IRuleStatusAttributes extends Record { lastSuccessAt: string | null | undefined; lastSuccessMessage: string | null | undefined; status: RuleStatusString | null | undefined; + lastLookBackDate: string | null | undefined; + gap: string | null | undefined; + bulkCreateTimeDurations: string[] | null | undefined; + searchAfterTimeDurations: string[] | null | undefined; } export interface RuleStatusResponse { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts index 66e9f42061658..c1b61ef24462d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts @@ -11,7 +11,7 @@ import { Logger } from '../../../../../../../../src/core/server'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; import { RuleAlertAction } from '../../../../common/detection_engine/types'; import { RuleTypeParams } from '../types'; -import { singleBulkCreate } from './single_bulk_create'; +import { singleBulkCreate, SingleBulkCreateResponse } from './single_bulk_create'; import { AnomalyResults, Anomaly } from '../../machine_learning'; interface BulkCreateMlSignalsParams { @@ -75,7 +75,9 @@ const transformAnomalyResultsToEcs = (results: AnomalyResults): SearchResponse { +export const bulkCreateMlSignals = async ( + params: BulkCreateMlSignalsParams +): Promise => { const anomalyResults = params.someResult; const ecsResults = transformAnomalyResultsToEcs(anomalyResults); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_current_status_saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_current_status_saved_object.ts index e5057b6b68997..1fee8bcd6c2f0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_current_status_saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_current_status_saved_object.ts @@ -36,6 +36,10 @@ export const getCurrentStatusSavedObject = async ({ lastSuccessAt: null, lastFailureMessage: null, lastSuccessMessage: null, + gap: null, + bulkCreateTimeDurations: [], + searchAfterTimeDurations: [], + lastLookBackDate: null, }); return currentStatusSavedObject; } else { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 315a5dd88d94e..b12c21b7a5b56 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -34,7 +34,7 @@ describe('searchAfterAndBulkCreate', () => { test('if successful with empty search results', async () => { const sampleParams = sampleRuleAlertParams(); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: sampleEmptyDocSearchResults(), ruleParams: sampleParams, services: mockService, @@ -56,7 +56,7 @@ describe('searchAfterAndBulkCreate', () => { throttle: null, }); expect(mockService.callCluster).toHaveBeenCalledTimes(0); - expect(result).toEqual(true); + expect(success).toEqual(true); }); test('if successful iteration of while loop with maxDocs', async () => { @@ -92,7 +92,7 @@ describe('searchAfterAndBulkCreate', () => { }, ], }); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(3, 1, someGuids.slice(6, 9)), ruleParams: sampleParams, services: mockService, @@ -114,14 +114,14 @@ describe('searchAfterAndBulkCreate', () => { throttle: null, }); expect(mockService.callCluster).toHaveBeenCalledTimes(5); - expect(result).toEqual(true); + expect(success).toEqual(true); }); test('if unsuccessful first bulk create', async () => { const someGuids = Array.from({ length: 4 }).map(x => uuid.v4()); const sampleParams = sampleRuleAlertParams(10); mockService.callCluster.mockReturnValue(sampleBulkCreateDuplicateResult); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), ruleParams: sampleParams, services: mockService, @@ -143,7 +143,7 @@ describe('searchAfterAndBulkCreate', () => { throttle: null, }); expect(mockLogger.error).toHaveBeenCalled(); - expect(result).toEqual(false); + expect(success).toEqual(false); }); test('if unsuccessful iteration of searchAfterAndBulkCreate due to empty sort ids', async () => { @@ -157,7 +157,7 @@ describe('searchAfterAndBulkCreate', () => { }, ], }); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: sampleDocSearchResultsNoSortId(), ruleParams: sampleParams, services: mockService, @@ -179,7 +179,7 @@ describe('searchAfterAndBulkCreate', () => { throttle: null, }); expect(mockLogger.error).toHaveBeenCalled(); - expect(result).toEqual(false); + expect(success).toEqual(false); }); test('if unsuccessful iteration of searchAfterAndBulkCreate due to empty sort ids and 0 total hits', async () => { @@ -193,7 +193,7 @@ describe('searchAfterAndBulkCreate', () => { }, ], }); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: sampleDocSearchResultsNoSortIdNoHits(), ruleParams: sampleParams, services: mockService, @@ -214,7 +214,7 @@ describe('searchAfterAndBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(result).toEqual(true); + expect(success).toEqual(true); }); test('if successful iteration of while loop with maxDocs and search after returns results with no sort ids', async () => { @@ -231,7 +231,7 @@ describe('searchAfterAndBulkCreate', () => { ], }) .mockReturnValueOnce(sampleDocSearchResultsNoSortId()); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), ruleParams: sampleParams, services: mockService, @@ -252,7 +252,7 @@ describe('searchAfterAndBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(result).toEqual(true); + expect(success).toEqual(true); }); test('if successful iteration of while loop with maxDocs and search after returns empty results with no sort ids', async () => { @@ -269,7 +269,7 @@ describe('searchAfterAndBulkCreate', () => { ], }) .mockReturnValueOnce(sampleEmptyDocSearchResults()); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), ruleParams: sampleParams, services: mockService, @@ -290,7 +290,7 @@ describe('searchAfterAndBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(result).toEqual(true); + expect(success).toEqual(true); }); test('if returns false when singleSearchAfter throws an exception', async () => { @@ -309,7 +309,7 @@ describe('searchAfterAndBulkCreate', () => { .mockImplementation(() => { throw Error('Fake Error'); }); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), ruleParams: sampleParams, services: mockService, @@ -330,6 +330,6 @@ describe('searchAfterAndBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(result).toEqual(false); + expect(success).toEqual(false); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts index 4f1a187a82937..ff263333fb798 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -34,6 +34,13 @@ interface SearchAfterAndBulkCreateParams { throttle: string | null; } +export interface SearchAfterAndBulkCreateReturnType { + success: boolean; + searchAfterTimes: string[]; + bulkCreateTimes: string[]; + lastLookBackDate: Date | null | undefined; +} + // search_after through documents and re-index using bulk endpoint. export const searchAfterAndBulkCreate = async ({ someResult, @@ -55,13 +62,20 @@ export const searchAfterAndBulkCreate = async ({ pageSize, tags, throttle, -}: SearchAfterAndBulkCreateParams): Promise => { +}: SearchAfterAndBulkCreateParams): Promise => { + const toReturn: SearchAfterAndBulkCreateReturnType = { + success: false, + searchAfterTimes: [], + bulkCreateTimes: [], + lastLookBackDate: null, + }; if (someResult.hits.hits.length === 0) { - return true; + toReturn.success = true; + return toReturn; } logger.debug('[+] starting bulk insertion'); - await singleBulkCreate({ + const { bulkCreateDuration } = await singleBulkCreate({ someResult, ruleParams, services, @@ -79,6 +93,13 @@ export const searchAfterAndBulkCreate = async ({ tags, throttle, }); + toReturn.lastLookBackDate = + someResult.hits.hits.length > 0 + ? new Date(someResult.hits.hits[someResult.hits.hits.length - 1]?._source['@timestamp']) + : null; + if (bulkCreateDuration) { + toReturn.bulkCreateTimes.push(bulkCreateDuration); + } const totalHits = typeof someResult.hits.total === 'number' ? someResult.hits.total : someResult.hits.total.value; // maxTotalHitsSize represents the total number of docs to @@ -94,9 +115,11 @@ export const searchAfterAndBulkCreate = async ({ let sortIds = someResult.hits.hits[0].sort; if (sortIds == null && totalHits > 0) { logger.error('sortIds was empty on first search but expected more'); - return false; + toReturn.success = false; + return toReturn; } else if (sortIds == null && totalHits === 0) { - return true; + toReturn.success = true; + return toReturn; } let sortId; if (sortIds != null) { @@ -105,7 +128,10 @@ export const searchAfterAndBulkCreate = async ({ while (hitsSize < maxTotalHitsSize && hitsSize !== 0) { try { logger.debug(`sortIds: ${sortIds}`); - const searchAfterResult: SignalSearchResponse = await singleSearchAfter({ + const { + searchResult, + searchDuration, + }: { searchResult: SignalSearchResponse; searchDuration: string } = await singleSearchAfter({ searchAfterSortId: sortId, index: inputIndexPattern, from: ruleParams.from, @@ -115,20 +141,23 @@ export const searchAfterAndBulkCreate = async ({ filter, pageSize, // maximum number of docs to receive per search result. }); - if (searchAfterResult.hits.hits.length === 0) { - return true; + toReturn.searchAfterTimes.push(searchDuration); + if (searchResult.hits.hits.length === 0) { + toReturn.success = true; + return toReturn; } - hitsSize += searchAfterResult.hits.hits.length; + hitsSize += searchResult.hits.hits.length; logger.debug(`size adjusted: ${hitsSize}`); - sortIds = searchAfterResult.hits.hits[0].sort; + sortIds = searchResult.hits.hits[0].sort; if (sortIds == null) { logger.debug('sortIds was empty on search'); - return true; // no more search results + toReturn.success = true; + return toReturn; // no more search results } sortId = sortIds[0]; logger.debug('next bulk index'); - await singleBulkCreate({ - someResult: searchAfterResult, + const { bulkCreateDuration: bulkDuration } = await singleBulkCreate({ + someResult: searchResult, ruleParams, services, logger, @@ -146,11 +175,16 @@ export const searchAfterAndBulkCreate = async ({ throttle, }); logger.debug('finished next bulk index'); + if (bulkDuration) { + toReturn.bulkCreateTimes.push(bulkDuration); + } } catch (exc) { logger.error(`[-] search_after and bulk threw an error ${exc}`); - return false; + toReturn.success = false; + return toReturn; } } logger.debug(`[+] completed bulk index of ${maxTotalHitsSize}`); - return true; + toReturn.success = true; + return toReturn; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index c004b3d0edd1c..ab9def14bef65 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { performance } from 'perf_hooks'; import { Logger } from 'src/core/server'; import { SIGNALS_ID, @@ -13,10 +14,13 @@ import { import { buildEventsSearchQuery } from './build_events_query'; import { getInputIndex } from './get_input_output_index'; -import { searchAfterAndBulkCreate } from './search_after_bulk_create'; +import { + searchAfterAndBulkCreate, + SearchAfterAndBulkCreateReturnType, +} from './search_after_bulk_create'; import { getFilter } from './get_filter'; import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types'; -import { getGapBetweenRuns } from './utils'; +import { getGapBetweenRuns, makeFloatString } from './utils'; import { writeSignalRuleExceptionToSavedObject } from './write_signal_rule_exception_to_saved_object'; import { signalParamsSchema } from './signal_params_schema'; import { siemRuleActionGroups } from './siem_rule_action_groups'; @@ -92,7 +96,6 @@ export const signalRulesAlertType = ({ const updatedAt = savedObject.updated_at ?? ''; const gap = getGapBetweenRuns({ previousStartedAt, interval, from, to }); - await writeGapErrorToSavedObject({ alertId, logger, @@ -105,7 +108,12 @@ export const signalRulesAlertType = ({ }); const searchAfterSize = Math.min(params.maxSignals, DEFAULT_SEARCH_AFTER_PAGE_SIZE); - let creationSucceeded = false; + let creationSucceeded: SearchAfterAndBulkCreateReturnType = { + success: false, + bulkCreateTimes: [], + searchAfterTimes: [], + lastLookBackDate: null, + }; try { if (type === 'machine_learning') { @@ -130,7 +138,7 @@ export const signalRulesAlertType = ({ ); } - creationSucceeded = await bulkCreateMlSignals({ + const { success, bulkCreateDuration } = await bulkCreateMlSignals({ actions, throttle, someResult: anomalyResults, @@ -148,6 +156,10 @@ export const signalRulesAlertType = ({ enabled, tags, }); + creationSucceeded.success = success; + if (bulkCreateDuration) { + creationSucceeded.bulkCreateTimes.push(bulkCreateDuration); + } } else { const inputIndex = await getInputIndex(services, version, index); const esFilter = await getFilter({ @@ -175,7 +187,10 @@ export const signalRulesAlertType = ({ logger.debug( `[+] Initial search call of signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` ); + const start = performance.now(); const noReIndexResult = await services.callCluster('search', noReIndex); + const end = performance.now(); + if (noReIndexResult.hits.total.value !== 0) { logger.info( `Found ${ @@ -207,9 +222,10 @@ export const signalRulesAlertType = ({ tags, throttle, }); + creationSucceeded.searchAfterTimes.push(makeFloatString(end - start)); } - if (creationSucceeded) { + if (creationSucceeded.success) { if (meta?.throttle === NOTIFICATION_THROTTLE_RULE && actions.length) { const notificationRuleParams = { ...ruleParams, @@ -242,11 +258,14 @@ export const signalRulesAlertType = ({ } logger.debug( - `Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}", output_index: "${outputIndex}"` + `Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` ); await writeCurrentStatusSucceeded({ services, currentStatusSavedObject, + bulkCreateTimes: creationSucceeded.bulkCreateTimes, + searchAfterTimes: creationSucceeded.searchAfterTimes, + lastLookBackDate: creationSucceeded.lastLookBackDate?.toISOString() ?? null, }); } else { await writeSignalRuleExceptionToSavedObject({ @@ -254,22 +273,28 @@ export const signalRulesAlertType = ({ alertId, currentStatusSavedObject, logger, - message: `Bulk Indexing signals failed. Check logs for further details Rule name: "${name}" id: "${alertId}" rule_id: "${ruleId}" output_index: "${outputIndex}"`, + message: `Bulk Indexing signals failed. Check logs for further details \nRule name: "${name}"\nid: "${alertId}"\nrule_id: "${ruleId}"\n`, services, ruleStatusSavedObjects, ruleId: ruleId ?? '(unknown rule id)', + bulkCreateTimes: creationSucceeded.bulkCreateTimes, + searchAfterTimes: creationSucceeded.searchAfterTimes, + lastLookBackDate: creationSucceeded.lastLookBackDate?.toISOString() ?? null, }); } - } catch (error) { + } catch (err) { await writeSignalRuleExceptionToSavedObject({ name, alertId, currentStatusSavedObject, logger, - message: error?.message ?? '(no error message given)', + message: `Bulk Indexing signals failed. Check logs for further details \nRule name: "${name}"\nid: "${alertId}"\nrule_id: "${ruleId}"\n`, services, ruleStatusSavedObjects, ruleId: ruleId ?? '(unknown rule id)', + bulkCreateTimes: creationSucceeded.bulkCreateTimes, + searchAfterTimes: creationSucceeded.searchAfterTimes, + lastLookBackDate: creationSucceeded.lastLookBackDate?.toISOString() ?? null, }); } }, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts index afabd4c44de7d..93f9c24a057f2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts @@ -144,7 +144,7 @@ describe('singleBulkCreate', () => { }, ], }); - const successfulsingleBulkCreate = await singleBulkCreate({ + const { success } = await singleBulkCreate({ someResult: sampleDocSearchResultsNoSortId(), ruleParams: sampleParams, services: mockService, @@ -162,7 +162,7 @@ describe('singleBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(successfulsingleBulkCreate).toEqual(true); + expect(success).toEqual(true); }); test('create successful bulk create with docs with no versioning', async () => { @@ -176,7 +176,7 @@ describe('singleBulkCreate', () => { }, ], }); - const successfulsingleBulkCreate = await singleBulkCreate({ + const { success } = await singleBulkCreate({ someResult: sampleDocSearchResultsNoSortIdNoVersion(), ruleParams: sampleParams, services: mockService, @@ -194,13 +194,13 @@ describe('singleBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(successfulsingleBulkCreate).toEqual(true); + expect(success).toEqual(true); }); test('create unsuccessful bulk create due to empty search results', async () => { const sampleParams = sampleRuleAlertParams(); mockService.callCluster.mockReturnValue(false); - const successfulsingleBulkCreate = await singleBulkCreate({ + const { success } = await singleBulkCreate({ someResult: sampleEmptyDocSearchResults(), ruleParams: sampleParams, services: mockService, @@ -218,14 +218,14 @@ describe('singleBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(successfulsingleBulkCreate).toEqual(true); + expect(success).toEqual(true); }); test('create successful bulk create when bulk create has duplicate errors', async () => { const sampleParams = sampleRuleAlertParams(); const sampleSearchResult = sampleDocSearchResultsNoSortId; mockService.callCluster.mockReturnValue(sampleBulkCreateDuplicateResult); - const successfulsingleBulkCreate = await singleBulkCreate({ + const { success } = await singleBulkCreate({ someResult: sampleSearchResult(), ruleParams: sampleParams, services: mockService, @@ -245,14 +245,14 @@ describe('singleBulkCreate', () => { }); expect(mockLogger.error).not.toHaveBeenCalled(); - expect(successfulsingleBulkCreate).toEqual(true); + expect(success).toEqual(true); }); test('create successful bulk create when bulk create has multiple error statuses', async () => { const sampleParams = sampleRuleAlertParams(); const sampleSearchResult = sampleDocSearchResultsNoSortId; mockService.callCluster.mockReturnValue(sampleBulkCreateErrorResult); - const successfulsingleBulkCreate = await singleBulkCreate({ + const { success } = await singleBulkCreate({ someResult: sampleSearchResult(), ruleParams: sampleParams, services: mockService, @@ -272,7 +272,7 @@ describe('singleBulkCreate', () => { }); expect(mockLogger.error).toHaveBeenCalled(); - expect(successfulsingleBulkCreate).toEqual(true); + expect(success).toEqual(true); }); test('filter duplicate rules will return an empty array given an empty array', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts index e2e4471f609ac..0192ff76efa54 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts @@ -10,7 +10,7 @@ import { AlertServices } from '../../../../../../../plugins/alerting/server'; import { SignalSearchResponse, BulkResponse } from './types'; import { RuleAlertAction } from '../../../../common/detection_engine/types'; import { RuleTypeParams } from '../types'; -import { generateId } from './utils'; +import { generateId, makeFloatString } from './utils'; import { buildBulkBody } from './build_bulk_body'; import { Logger } from '../../../../../../../../src/core/server'; @@ -55,6 +55,11 @@ export const filterDuplicateRules = ( }); }; +export interface SingleBulkCreateResponse { + success: boolean; + bulkCreateDuration?: string; +} + // Bulk Index documents. export const singleBulkCreate = async ({ someResult, @@ -73,11 +78,10 @@ export const singleBulkCreate = async ({ enabled, tags, throttle, -}: SingleBulkCreateParams): Promise => { +}: SingleBulkCreateParams): Promise => { someResult.hits.hits = filterDuplicateRules(id, someResult); - if (someResult.hits.hits.length === 0) { - return true; + return { success: true }; } // index documents after creating an ID based on the // source documents' originating index, and the original @@ -123,7 +127,7 @@ export const singleBulkCreate = async ({ body: bulkBody, }); const end = performance.now(); - logger.debug(`individual bulk process time took: ${Number(end - start).toFixed(2)} milliseconds`); + logger.debug(`individual bulk process time took: ${makeFloatString(end - start)} milliseconds`); logger.debug(`took property says bulk took: ${response.took} milliseconds`); if (response.errors) { @@ -141,5 +145,5 @@ export const singleBulkCreate = async ({ ); } } - return true; + return { success: true, bulkCreateDuration: makeFloatString(end - start) }; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts index 1685c6518def3..9b726c38d3d96 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts @@ -42,7 +42,7 @@ describe('singleSearchAfter', () => { test('if singleSearchAfter works with a given sort id', async () => { const searchAfterSortId = '1234567891111'; mockService.callCluster.mockReturnValue(sampleDocSearchResultsWithSortId); - const searchAfterResult = await singleSearchAfter({ + const { searchResult } = await singleSearchAfter({ searchAfterSortId, index: [], from: 'now-360s', @@ -52,7 +52,7 @@ describe('singleSearchAfter', () => { pageSize: 1, filter: undefined, }); - expect(searchAfterResult).toEqual(sampleDocSearchResultsWithSortId); + expect(searchResult).toEqual(sampleDocSearchResultsWithSortId); }); test('if singleSearchAfter throws error', async () => { const searchAfterSortId = '1234567891111'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts index bb12b5a802f8f..6fc8fe4bd24d9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { performance } from 'perf_hooks'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; import { Logger } from '../../../../../../../../src/core/server'; import { SignalSearchResponse } from './types'; import { buildEventsSearchQuery } from './build_events_query'; +import { makeFloatString } from './utils'; interface SingleSearchAfterParams { searchAfterSortId: string | undefined; @@ -30,7 +32,10 @@ export const singleSearchAfter = async ({ filter, logger, pageSize, -}: SingleSearchAfterParams): Promise => { +}: SingleSearchAfterParams): Promise<{ + searchResult: SignalSearchResponse; + searchDuration: string; +}> => { if (searchAfterSortId == null) { throw Error('Attempted to search after with empty sort id'); } @@ -43,11 +48,13 @@ export const singleSearchAfter = async ({ size: pageSize, searchAfterSortId, }); + const start = performance.now(); const nextSearchAfterResult: SignalSearchResponse = await services.callCluster( 'search', searchAfterQuery ); - return nextSearchAfterResult; + const end = performance.now(); + return { searchResult: nextSearchAfterResult, searchDuration: makeFloatString(end - start) }; } catch (exc) { logger.error(`[-] nextSearchAfter threw an error ${exc}`); throw exc; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts index 8e7fb9c38d658..49af310db559f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts @@ -89,3 +89,5 @@ export const getGapBetweenRuns = ({ const drift = diff.subtract(intervalDuration); return drift.subtract(driftTolerance); }; + +export const makeFloatString = (num: number): string => Number(num).toFixed(2); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_current_status_succeeded.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_current_status_succeeded.ts index 6b06235b29063..50136790c3479 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_current_status_succeeded.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_current_status_succeeded.ts @@ -13,17 +13,32 @@ import { IRuleSavedAttributesSavedObjectAttributes } from '../rules/types'; interface GetRuleStatusSavedObject { services: AlertServices; currentStatusSavedObject: SavedObject; + lastLookBackDate: string | null | undefined; + bulkCreateTimes: string[] | null | undefined; + searchAfterTimes: string[] | null | undefined; } export const writeCurrentStatusSucceeded = async ({ services, currentStatusSavedObject, + lastLookBackDate, + bulkCreateTimes, + searchAfterTimes, }: GetRuleStatusSavedObject): Promise => { const sDate = new Date().toISOString(); currentStatusSavedObject.attributes.status = 'succeeded'; currentStatusSavedObject.attributes.statusDate = sDate; currentStatusSavedObject.attributes.lastSuccessAt = sDate; currentStatusSavedObject.attributes.lastSuccessMessage = 'succeeded'; + if (lastLookBackDate != null) { + currentStatusSavedObject.attributes.lastLookBackDate = lastLookBackDate; + } + if (bulkCreateTimes != null) { + currentStatusSavedObject.attributes.bulkCreateTimeDurations = bulkCreateTimes; + } + if (searchAfterTimes != null) { + currentStatusSavedObject.attributes.searchAfterTimeDurations = searchAfterTimes; + } await services.savedObjectsClient.update(ruleStatusSavedObjectType, currentStatusSavedObject.id, { ...currentStatusSavedObject.attributes, }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_gap_error_to_saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_gap_error_to_saved_object.ts index 3650548c80ad5..e47e5388527da 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_gap_error_to_saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_gap_error_to_saved_object.ts @@ -48,6 +48,7 @@ export const writeGapErrorToSavedObject = async ({ lastSuccessAt: currentStatusSavedObject.attributes.lastSuccessAt, lastFailureMessage: `Signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}" has a time gap of ${gap.humanize()} (${gap.asMilliseconds()}ms), and could be missing signals within that time. Consider increasing your look behind time or adding more Kibana instances.`, lastSuccessMessage: currentStatusSavedObject.attributes.lastSuccessMessage, + gap: gap.humanize(), }); if (ruleStatusSavedObjects.saved_objects.length >= 6) { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_signal_rule_exception_to_saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_signal_rule_exception_to_saved_object.ts index 5ca0808902a52..2a14184859591 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_signal_rule_exception_to_saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_signal_rule_exception_to_saved_object.ts @@ -19,6 +19,9 @@ interface SignalRuleExceptionParams { message: string; services: AlertServices; name: string; + lastLookBackDate?: string | null | undefined; + bulkCreateTimes?: string[] | null | undefined; + searchAfterTimes?: string[] | null | undefined; } export const writeSignalRuleExceptionToSavedObject = async ({ @@ -30,6 +33,9 @@ export const writeSignalRuleExceptionToSavedObject = async ({ ruleStatusSavedObjects, ruleId, name, + lastLookBackDate, + bulkCreateTimes, + searchAfterTimes, }: SignalRuleExceptionParams): Promise => { logger.error( `Error from signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}" message: ${message}` @@ -39,6 +45,15 @@ export const writeSignalRuleExceptionToSavedObject = async ({ currentStatusSavedObject.attributes.statusDate = sDate; currentStatusSavedObject.attributes.lastFailureAt = sDate; currentStatusSavedObject.attributes.lastFailureMessage = message; + if (lastLookBackDate) { + currentStatusSavedObject.attributes.lastLookBackDate = lastLookBackDate; + } + if (bulkCreateTimes) { + currentStatusSavedObject.attributes.bulkCreateTimeDurations = bulkCreateTimes; + } + if (searchAfterTimes) { + currentStatusSavedObject.attributes.searchAfterTimeDurations = searchAfterTimes; + } // current status is failing await services.savedObjectsClient.update(ruleStatusSavedObjectType, currentStatusSavedObject.id, { ...currentStatusSavedObject.attributes, From e42956151b554d73ce6fb4b15b3a2de2e2646c45 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Wed, 25 Mar 2020 06:24:39 +0100 Subject: [PATCH 51/56] Bump mapbox-gl from 1.3.1 to 1.9.0 (#61135) This commit also consolidates our versions of pbf to all use the latest 3.x (v3.2.1) and ieee754 to all use the latest 1.x (v1.1.13). --- x-pack/package.json | 8 ++--- yarn.lock | 75 +++++++++++++++++++++------------------------ 2 files changed, 39 insertions(+), 44 deletions(-) diff --git a/x-pack/package.json b/x-pack/package.json index 5e8bffb27a3bf..fcc7e70d3e417 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -76,7 +76,7 @@ "@types/json-stable-stringify": "^1.0.32", "@types/jsonwebtoken": "^7.2.8", "@types/lodash": "^3.10.1", - "@types/mapbox-gl": "^0.54.1", + "@types/mapbox-gl": "^1.8.1", "@types/memoize-one": "^4.1.0", "@types/mime": "^2.0.1", "@types/mocha": "^7.0.2", @@ -193,8 +193,8 @@ "@kbn/interpreter": "1.0.0", "@kbn/storybook": "1.0.0", "@kbn/ui-framework": "1.0.0", - "@mapbox/mapbox-gl-draw": "^1.1.1", - "@mapbox/mapbox-gl-rtl-text": "0.2.3", + "@mapbox/mapbox-gl-draw": "^1.1.2", + "@mapbox/mapbox-gl-rtl-text": "^0.2.3", "@scant/router": "^0.1.0", "@slack/webhook": "^5.0.0", "@turf/boolean-contains": "6.0.1", @@ -273,7 +273,7 @@ "lodash.topath": "^4.5.2", "lodash.uniqby": "^4.7.0", "lz-string": "^1.4.4", - "mapbox-gl": "1.3.1", + "mapbox-gl": "^1.9.0", "mapbox-gl-draw-rectangle-mode": "^1.0.4", "markdown-it": "^10.0.0", "memoize-one": "^5.0.0", diff --git a/yarn.lock b/yarn.lock index 27ba50b07280d..3997cfbaeca6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2761,10 +2761,10 @@ resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234" integrity sha1-zlblOfg1UrWNENZy6k1vya3HsjQ= -"@mapbox/mapbox-gl-draw@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-draw/-/mapbox-gl-draw-1.1.1.tgz#b88a7919c8de04eb7946885e747e22049c3a3138" - integrity sha512-Xg+R0VUXKdXC7MaSSMiWfz96eLssJZa28/D6MxK/Xc19G5HvU6w/wytm8EeI28T7Sa2C7FII/0/XOwuAfJgDJw== +"@mapbox/mapbox-gl-draw@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-draw/-/mapbox-gl-draw-1.1.2.tgz#247b3f0727db34c2641ab718df5eebeee69a2585" + integrity sha512-DWtATUAnJaGZYoH/y2O+QTRybxrp5y3w3eV5FXHFNVcKsCAojKEMB8ALKUG2IsiCKqV/JCAguK9AlPWR7Bjafw== dependencies: "@mapbox/geojson-area" "^0.2.1" "@mapbox/geojson-extent" "^0.3.2" @@ -2775,7 +2775,7 @@ lodash.isequal "^4.2.0" xtend "^4.0.1" -"@mapbox/mapbox-gl-rtl-text@0.2.3": +"@mapbox/mapbox-gl-rtl-text@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.2.3.tgz#a26ecfb3f0061456d93ee8570dd9587d226ea8bd" integrity sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw== @@ -4859,10 +4859,10 @@ resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03" integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w== -"@types/mapbox-gl@^0.54.1": - version "0.54.3" - resolved "https://registry.yarnpkg.com/@types/mapbox-gl/-/mapbox-gl-0.54.3.tgz#6215fbf4dbb555d2ca6ce3be0b1de045eec0f967" - integrity sha512-/G06vUcV5ucNB7G9ka6J+VbGtffyUYvfe6A3oae/+csTlHIEHcvyJop3Ic4yeMDxycsQCmBvuwz+owseMuiQ3w== +"@types/mapbox-gl@^1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@types/mapbox-gl/-/mapbox-gl-1.8.1.tgz#dbc12da1324d5bdb3dbf71b90b77cac17994a1a3" + integrity sha512-DdT/YzpGiYITkj2cUwyqPilPbtZURr1E0vZX0KTyyeNP0t0bxNyKoXo0seAcvUd2MsMgFYwFQh1WRC3x2V0kKQ== dependencies: "@types/geojson" "*" @@ -12085,10 +12085,10 @@ earcut@^2.0.0: resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.1.3.tgz#ca579545f351941af7c3d0df49c9f7d34af99b0c" integrity sha512-AxdCdWUk1zzK/NuZ7e1ljj6IGC+VAdC3Qb7QQDsXpfNrc5IM8tL9nNXUmEGE6jRHTfZ10zhzRhtDmWVsR5pd3A== -earcut@^2.1.5: - version "2.1.5" - resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.1.5.tgz#829280a9a3a0f5fee0529f0a47c3e4eff09b21e4" - integrity sha512-QFWC7ywTVLtvRAJTVp8ugsuuGQ5mVqNmJ1cRYeLrSHgP3nycr2RHTJob9OtM0v8ujuoKN0NY1a93J/omeTL1PA== +earcut@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.2.2.tgz#41b0bc35f63e0fe80da7cddff28511e7e2e80d11" + integrity sha512-eZoZPPJcUHnfRZ0PjLvx2qBordSiO8ofC3vt+qACLM95u+4DovnbYNpQtJh0DNsWj8RnxrQytD4WA8gj5cRIaQ== ecc-jsbn@~0.1.1: version "0.1.2" @@ -16654,15 +16654,10 @@ iedriver@^3.14.1: request "^2.88.0" rimraf "~2.0.2" -ieee754@^1.1.4: - version "1.1.8" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" - integrity sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q= - -ieee754@^1.1.6: - version "1.1.12" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.12.tgz#50bf24e5b9c8bb98af4964c941cdb0918da7b60b" - integrity sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA== +ieee754@^1.1.12, ieee754@^1.1.4: + version "1.1.13" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" + integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== iferr@^0.1.5: version "0.1.5" @@ -20213,10 +20208,10 @@ mapbox-gl-draw-rectangle-mode@^1.0.4: resolved "https://registry.yarnpkg.com/mapbox-gl-draw-rectangle-mode/-/mapbox-gl-draw-rectangle-mode-1.0.4.tgz#42987d68872a5fb5cc5d76d3375ee20cd8bab8f7" integrity sha512-BdF6nwEK2p8n9LQoMPzBO8LhddW1fe+d5vK8HQIei+4VcRnUbKNsEj7Z15FsJxCHzsc2BQKXbESx5GaE8x0imQ== -mapbox-gl@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/mapbox-gl/-/mapbox-gl-1.3.1.tgz#6be39a207afec3cc6ea4bc241d596140a664e46b" - integrity sha512-IF7b0LZd/caTiknPhm8DAcv7bhvOCXO6rsW18rmFxi8Vw0syJXKK8DLLabI5oiJXtUIgLe57XRgduQzAYrb4og== +mapbox-gl@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/mapbox-gl/-/mapbox-gl-1.9.0.tgz#53e3e13c99483f362b07a8a763f2d61d580255a5" + integrity sha512-PKpoiB2pPUMrqFfBJpt/oA8On3zcp0adEoDS2YIC2RA6o4EZ9Sq2NPZocb64y7ra3mLUvEb7ps1pLVlPMh6y7w== dependencies: "@mapbox/geojson-rewind" "^0.4.0" "@mapbox/geojson-types" "^1.0.2" @@ -20228,17 +20223,17 @@ mapbox-gl@1.3.1: "@mapbox/vector-tile" "^1.3.1" "@mapbox/whoots-js" "^3.1.0" csscolorparser "~1.0.2" - earcut "^2.1.5" + earcut "^2.2.2" geojson-vt "^3.2.1" gl-matrix "^3.0.0" grid-index "^1.1.0" minimist "0.0.8" murmurhash-js "^1.0.0" - pbf "^3.0.5" + pbf "^3.2.1" potpack "^1.0.1" quickselect "^2.0.0" rw "^1.3.3" - supercluster "^6.0.1" + supercluster "^7.0.0" tinyqueue "^2.0.0" vt-pbf "^3.1.1" @@ -22986,13 +22981,13 @@ path2d-polyfill@^0.4.2: resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-0.4.2.tgz#594d3103838ef6b9dd4a7fd498fe9a88f1f28531" integrity sha512-JSeAnUfkFjl+Ml/EZL898ivMSbGHrOH63Mirx5EQ1ycJiryHDmj1Q7Are+uEPvenVGCUN9YbolfGfyUewJfJEg== -pbf@^3.0.5: - version "3.1.0" - resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.1.0.tgz#f70004badcb281761eabb1e76c92f179f08189e9" - integrity sha512-/hYJmIsTmh7fMkHAWWXJ5b8IKLWdjdlAFb3IHkRBn1XUhIYBChVGfVwmHEAV3UfXTxsP/AKfYTXTS/dCPxJd5w== +pbf@^3.0.5, pbf@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.2.1.tgz#b4c1b9e72af966cd82c6531691115cc0409ffe2a" + integrity sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ== dependencies: - ieee754 "^1.1.6" - resolve-protobuf-schema "^2.0.0" + ieee754 "^1.1.12" + resolve-protobuf-schema "^2.1.0" pbkdf2@^3.0.3: version "3.0.14" @@ -26082,7 +26077,7 @@ resolve-pkg@^2.0.0: dependencies: resolve-from "^5.0.0" -resolve-protobuf-schema@^2.0.0: +resolve-protobuf-schema@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz#9ca9a9e69cf192bbdaf1006ec1973948aa4a3758" integrity sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ== @@ -28336,10 +28331,10 @@ superagent@3.8.2: qs "^6.5.1" readable-stream "^2.0.5" -supercluster@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/supercluster/-/supercluster-6.0.1.tgz#4c0177d96daa195d58a5bad9f55dbf12fb727a4c" - integrity sha512-NTth/FBFUt9mwW03+Z6Byscex+UHu0utroIe6uXjGu9PrTuWtW70LYv9I1vPSYYIHQL74S5zAkrXrHEk0L7dGA== +supercluster@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/supercluster/-/supercluster-7.0.0.tgz#75d474fafb0a055db552ed7bd7bbda583f6ab321" + integrity sha512-8VuHI8ynylYQj7Qf6PBMWy1PdgsnBiIxujOgc9Z83QvJ8ualIYWNx2iMKyKeC4DZI5ntD9tz/CIwwZvIelixsA== dependencies: kdbush "^3.0.0" From 1e3c5b1f871bad0313a9b21394b6e7c0dd7055b2 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Tue, 24 Mar 2020 23:39:46 -0600 Subject: [PATCH 52/56] [SIEM] [Cases] Final case features for 7.7 (#61161) --- .../__snapshots__/index.test.tsx.snap | 2 + .../siem/public/components/markdown/index.tsx | 13 +++++- .../case/components/add_comment/index.tsx | 17 +++++-- .../case/components/all_cases/columns.tsx | 46 +++++++++++++++++-- .../pages/case/components/all_cases/index.tsx | 18 +++++--- .../case/components/all_cases/translations.ts | 14 ++++++ .../case/components/case_view/translations.ts | 4 ++ .../components/property_actions/index.tsx | 2 +- .../components/user_action_tree/index.tsx | 20 +++++++- .../user_action_tree/user_action_item.tsx | 6 +++ .../user_action_tree/user_action_title.tsx | 36 ++++++++++----- 11 files changed, 152 insertions(+), 26 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/markdown/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/markdown/__snapshots__/index.test.tsx.snap index 4b02d23568d26..ce0c797c2b2b4 100644 --- a/x-pack/legacy/plugins/siem/public/components/markdown/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/markdown/__snapshots__/index.test.tsx.snap @@ -10,6 +10,7 @@ exports[`Markdown markdown links it renders the expected content containing a li rawSourcePos={false} renderers={ Object { + "blockquote": [Function], "link": [Function], "root": [Function], "table": [Function], @@ -35,6 +36,7 @@ exports[`Markdown markdown tables it renders the expected table content 1`] = ` rawSourcePos={false} renderers={ Object { + "blockquote": [Function], "link": [Function], "root": [Function], "table": [Function], diff --git a/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx b/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx index 1368c13619d6b..8e051685af56d 100644 --- a/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx @@ -9,12 +9,20 @@ import { EuiLink, EuiTableRow, EuiTableRowCell, EuiText, EuiToolTip } from '@elastic/eui'; import React from 'react'; import ReactMarkdown from 'react-markdown'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; const TableHeader = styled.thead` font-weight: bold; `; +const MyBlockquote = styled.div` + ${({ theme }) => css` + padding: 0 ${theme.eui.euiSize}; + color: ${theme.eui.euiColorMediumShade}; + border-left: ${theme.eui.euiSizeXS} solid ${theme.eui.euiColorLightShade}; + `} +`; + TableHeader.displayName = 'TableHeader'; /** prevents links to the new pages from accessing `window.opener` */ @@ -63,6 +71,9 @@ export const Markdown = React.memo<{ ), + blockquote: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), }; return ( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx index 836595c7c45d9..21e4724797c5d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx @@ -30,13 +30,14 @@ const initialCommentValue: CommentRequest = { interface AddCommentProps { caseId: string; + insertQuote: string | null; onCommentSaving?: () => void; onCommentPosted: (commentResponse: Comment) => void; showLoading?: boolean; } export const AddComment = React.memo( - ({ caseId, showLoading = true, onCommentPosted, onCommentSaving }) => { + ({ caseId, insertQuote, showLoading = true, onCommentPosted, onCommentSaving }) => { const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId); const { form } = useForm({ defaultValue: initialCommentValue, @@ -48,6 +49,16 @@ export const AddComment = React.memo( 'comment' ); + useEffect(() => { + if (insertQuote !== null) { + const { comment } = form.getFormData(); + form.setFieldValue( + 'comment', + `${comment}${comment.length > 0 ? '\n\n' : ''}${insertQuote}` + ); + } + }, [insertQuote]); + useEffect(() => { if (commentData !== null) { onCommentPosted(commentData); @@ -67,7 +78,7 @@ export const AddComment = React.memo( }, [form]); return ( - <> + {isLoading && showLoading && }
( }} /> - +
); } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 32a29483e9c75..5ca54c7f429d2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -3,13 +3,15 @@ * 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 React, { useCallback } from 'react'; import { EuiBadge, EuiTableFieldDataColumnType, EuiTableComputedColumnType, EuiTableActionsColumnType, EuiAvatar, + EuiLink, + EuiLoadingSpinner, } from '@elastic/eui'; import styled from 'styled-components'; import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; @@ -19,6 +21,7 @@ import { FormattedRelativePreferenceDate } from '../../../../components/formatte import { CaseDetailsLink } from '../../../../components/links'; import { TruncatableText } from '../../../../components/truncatable_text'; import * as i18n from './translations'; +import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; export type CasesColumns = | EuiTableFieldDataColumnType @@ -60,7 +63,6 @@ export const getCasesColumns = ( } return getEmptyTagValue(); }, - width: '25%', }, { field: 'createdBy', @@ -105,7 +107,6 @@ export const getCasesColumns = ( return getEmptyTagValue(); }, truncateText: true, - width: '20%', }, { align: 'right', @@ -148,8 +149,47 @@ export const getCasesColumns = ( return getEmptyTagValue(); }, }, + { + name: 'ServiceNow Incident', + render: (theCase: Case) => { + if (theCase.id != null) { + return ; + } + return getEmptyTagValue(); + }, + }, { name: 'Actions', actions, }, ]; + +interface Props { + theCase: Case; +} + +const ServiceNowColumn: React.FC = ({ theCase }) => { + const { hasDataToPush, isLoading } = useGetCaseUserActions(theCase.id); + const handleRenderDataToPush = useCallback( + () => + isLoading ? ( + + ) : ( +

+ + {theCase.externalService?.externalTitle} + + {hasDataToPush ? i18n.REQUIRES_UPDATE : i18n.UP_TO_DATE} +

+ ), + [hasDataToPush, isLoading, theCase.externalService] + ); + if (theCase.externalService !== null) { + return handleRenderDataToPush(); + } + return renderStringField(i18n.NOT_PUSHED, `case-table-column-external-notPushed`); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index cbb9ddae22d04..27316ab8427cb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -109,19 +109,21 @@ export const AllCases = React.memo(() => { const { dispatchResetIsUpdated, isUpdated, updateBulkStatus } = useUpdateCases(); + const refreshCases = useCallback(() => { + refetchCases(filterOptions, queryParams); + fetchCasesStatus(); + }, [filterOptions, queryParams]); + useEffect(() => { if (isDeleted) { - refetchCases(filterOptions, queryParams); - fetchCasesStatus(); + refreshCases(); dispatchResetIsDeleted(); } if (isUpdated) { - refetchCases(filterOptions, queryParams); - fetchCasesStatus(); + refreshCases(); dispatchResetIsUpdated(); } - }, [isDeleted, isUpdated, filterOptions, queryParams]); - + }, [isDeleted, isUpdated]); const [deleteThisCase, setDeleteThisCase] = useState({ title: '', id: '', @@ -327,6 +329,10 @@ export const AllCases = React.memo(() => { > {i18n.BULK_ACTIONS} + + + {i18n.REFRESH} + diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts index b18134f6d093e..e8459454576e3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts @@ -62,3 +62,17 @@ export const CLOSED = i18n.translate('xpack.siem.case.caseTable.closed', { export const DELETE = i18n.translate('xpack.siem.case.caseTable.delete', { defaultMessage: 'Delete', }); +export const REQUIRES_UPDATE = i18n.translate('xpack.siem.case.caseTable.requiresUpdate', { + defaultMessage: ' requires update', +}); + +export const UP_TO_DATE = i18n.translate('xpack.siem.case.caseTable.upToDate', { + defaultMessage: ' is up to date', +}); +export const NOT_PUSHED = i18n.translate('xpack.siem.case.caseTable.notPushed', { + defaultMessage: 'Not pushed', +}); + +export const REFRESH = i18n.translate('xpack.siem.case.caseTable.refreshTitle', { + defaultMessage: 'Refresh', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index beba80ccd934c..c081567e3be72 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -59,6 +59,10 @@ export const EDIT_DESCRIPTION = i18n.translate('xpack.siem.case.caseView.edit.de defaultMessage: 'Edit description', }); +export const QUOTE = i18n.translate('xpack.siem.case.caseView.edit.quote', { + defaultMessage: 'Quote', +}); + export const EDIT_COMMENT = i18n.translate('xpack.siem.case.caseView.edit.comment', { defaultMessage: 'Edit comment', }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx index 01ccf3c510b60..25332982dca1a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx @@ -45,7 +45,7 @@ export const PropertyActions = React.memo(({ propertyActio const onClosePopover = useCallback((cb?: () => void) => { setShowActions(false); - if (cb) { + if (cb != null) { cb(); } }, []); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 8b77186f76f77..d8b9ac115426a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -57,6 +57,7 @@ export const UserActionTree = React.memo( ); const currentUser = useCurrentUser(); const [manageMarkdownEditIds, setManangeMardownEditIds] = useState([]); + const [insertQuote, setInsertQuote] = useState(null); const handleManageMarkdownEditId = useCallback( (id: string) => { @@ -92,6 +93,9 @@ export const UserActionTree = React.memo( top: y, behavior: 'smooth', }); + if (id === 'add-comment') { + moveToTarget.getElementsByTagName('textarea')[0].focus(); + } } window.clearTimeout(handlerTimeoutId.current); setSelectedOutlineCommentId(id); @@ -103,6 +107,15 @@ export const UserActionTree = React.memo( [handlerTimeoutId.current] ); + const handleManageQuote = useCallback( + (quote: string) => { + const addCarrots = quote.replace(new RegExp('\r?\n', 'g'), ' \n> '); + setInsertQuote(`> ${addCarrots} \n`); + handleOutlineComment('add-comment'); + }, + [handleOutlineComment] + ); + const handleUpdate = useCallback( (comment: Comment) => { addPostedComment(comment); @@ -131,12 +144,13 @@ export const UserActionTree = React.memo( () => ( ), - [caseData.id, handleUpdate] + [caseData.id, handleUpdate, insertQuote] ); useEffect(() => { @@ -156,10 +170,12 @@ export const UserActionTree = React.memo( isEditable={manageMarkdownEditIds.includes(DESCRIPTION_ID)} isLoading={isLoadingDescription} labelEditAction={i18n.EDIT_DESCRIPTION} + labelQuoteAction={i18n.QUOTE} labelTitle={<>{i18n.ADDED_DESCRIPTION}} fullName={caseData.createdBy.fullName ?? caseData.createdBy.username} markdown={MarkdownDescription} onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} + onQuote={handleManageQuote.bind(null, caseData.description)} userName={caseData.createdBy.username} /> @@ -176,6 +192,7 @@ export const UserActionTree = React.memo( isEditable={manageMarkdownEditIds.includes(comment.id)} isLoading={isLoadingIds.includes(comment.id)} labelEditAction={i18n.EDIT_COMMENT} + labelQuoteAction={i18n.QUOTE} labelTitle={<>{i18n.ADDED_COMMENT}} fullName={comment.createdBy.fullName ?? comment.createdBy.username} markdown={ @@ -188,6 +205,7 @@ export const UserActionTree = React.memo( /> } onEdit={handleManageMarkdownEditId.bind(null, comment.id)} + onQuote={handleManageQuote.bind(null, comment.comment)} outlineComment={handleOutlineComment} userName={comment.createdBy.username} updatedAt={comment.updatedAt} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index 10a7c56e2eb2d..c1dbe3b5fdbfa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -25,11 +25,13 @@ interface UserActionItemProps { isEditable: boolean; isLoading: boolean; labelEditAction?: string; + labelQuoteAction?: string; labelTitle?: JSX.Element; linkId?: string | null; fullName: string; markdown?: React.ReactNode; onEdit?: (id: string) => void; + onQuote?: (id: string) => void; userName: string; updatedAt?: string | null; outlineComment?: (id: string) => void; @@ -113,11 +115,13 @@ export const UserActionItem = ({ isEditable, isLoading, labelEditAction, + labelQuoteAction, labelTitle, linkId, fullName, markdown, onEdit, + onQuote, outlineComment, showBottomFooter, showTopFooter, @@ -147,11 +151,13 @@ export const UserActionItem = ({ id={id} isLoading={isLoading} labelEditAction={labelEditAction} + labelQuoteAction={labelQuoteAction} labelTitle={labelTitle ?? <>} linkId={linkId} userName={userName} updatedAt={updatedAt} onEdit={onEdit} + onQuote={onQuote} outlineComment={outlineComment} /> {markdown} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx index 6ca81667d9712..391f54da7e972 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -30,11 +30,13 @@ interface UserActionTitleProps { id: string; isLoading: boolean; labelEditAction?: string; + labelQuoteAction?: string; labelTitle: JSX.Element; linkId?: string | null; updatedAt?: string | null; userName: string; onEdit?: (id: string) => void; + onQuote?: (id: string) => void; outlineComment?: (id: string) => void; } @@ -43,27 +45,39 @@ export const UserActionTitle = ({ id, isLoading, labelEditAction, + labelQuoteAction, labelTitle, linkId, userName, updatedAt, onEdit, + onQuote, outlineComment, }: UserActionTitleProps) => { const { detailName: caseId } = useParams(); const urlSearch = useGetUrlSearch(navTabs.case); const propertyActions = useMemo(() => { - if (labelEditAction != null && onEdit != null) { - return [ - { - iconType: 'pencil', - label: labelEditAction, - onClick: () => onEdit(id), - }, - ]; - } - return []; - }, [id, labelEditAction, onEdit]); + return [ + ...(labelEditAction != null && onEdit != null + ? [ + { + iconType: 'pencil', + label: labelEditAction, + onClick: () => onEdit(id), + }, + ] + : []), + ...(labelQuoteAction != null && onQuote != null + ? [ + { + iconType: 'quote', + label: labelQuoteAction, + onClick: () => onQuote(id), + }, + ] + : []), + ]; + }, [id, labelEditAction, onEdit, labelQuoteAction, onQuote]); const handleAnchorLink = useCallback(() => { copy( From 411959fc47da4c620bc98f8beec4f21cf69c72f6 Mon Sep 17 00:00:00 2001 From: "Devin W. Hurley" Date: Wed, 25 Mar 2020 01:52:06 -0400 Subject: [PATCH 53/56] fix type check failure on master (#61204) --- .../rules/use_rule_status.test.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx index 834fc1b4196da..7269bf1baa5e5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx @@ -12,11 +12,21 @@ import { ReturnRulesStatuses, } from './use_rule_status'; import * as api from './api'; -import { RuleType } from '../rules/types'; +import { RuleType, Rule } from '../rules/types'; jest.mock('./api'); -const testRule = { +const testRule: Rule = { + actions: [ + { + group: 'fake group', + id: 'fake id', + action_type_id: 'fake action_type_id', + params: { + someKey: 'someVal', + }, + }, + ], created_at: 'mm/dd/yyyyTHH:MM:sssz', created_by: 'mockUser', description: 'some desc', @@ -45,6 +55,7 @@ const testRule = { severity: 'high', tags: ['APM'], threat: [], + throttle: null, to: 'now', type: 'query' as RuleType, updated_at: 'mm/dd/yyyyTHH:MM:sssz', From 488b9e2648517c1c04a5c99191cb14bfa52f0252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Wed, 25 Mar 2020 07:09:28 +0100 Subject: [PATCH 54/56] [APM] Improve e2e runner (#61163) --- src/cli/cluster/cluster_manager.ts | 2 +- .../plugins/apm/e2e/cypress/webpack.config.js | 1 + .../plugins/apm/e2e/ingest-data/replay.js | 44 +++++--- x-pack/legacy/plugins/apm/e2e/package.json | 1 + x-pack/legacy/plugins/apm/e2e/run-e2e.sh | 101 ++++++++++++------ x-pack/legacy/plugins/apm/e2e/yarn.lock | 18 ++++ x-pack/legacy/plugins/apm/readme.md | 54 ++++++---- 7 files changed, 151 insertions(+), 70 deletions(-) diff --git a/src/cli/cluster/cluster_manager.ts b/src/cli/cluster/cluster_manager.ts index 8862b96e74401..44b6c39556afd 100644 --- a/src/cli/cluster/cluster_manager.ts +++ b/src/cli/cluster/cluster_manager.ts @@ -264,7 +264,7 @@ export class ClusterManager { fromRoot('src/legacy/server/sass/__tmp__'), fromRoot('x-pack/legacy/plugins/reporting/.chromium'), fromRoot('x-pack/legacy/plugins/siem/cypress'), - fromRoot('x-pack/legacy/plugins/apm/e2e/cypress'), + fromRoot('x-pack/legacy/plugins/apm/e2e'), fromRoot('x-pack/legacy/plugins/apm/scripts'), fromRoot('x-pack/legacy/plugins/canvas/canvas_plugin_src'), // prevents server from restarting twice for Canvas plugin changes, 'plugins/java_languageserver', diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/webpack.config.js b/x-pack/legacy/plugins/apm/e2e/cypress/webpack.config.js index 823b23cfdffec..8db6a1ef83520 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/webpack.config.js +++ b/x-pack/legacy/plugins/apm/e2e/cypress/webpack.config.js @@ -14,6 +14,7 @@ module.exports = { { test: /\.ts$/, exclude: [/node_modules/], + include: [/e2e\/cypress/], use: [ { loader: 'ts-loader' diff --git a/x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js b/x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js index 59cd34704d624..5301eafece06d 100644 --- a/x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js +++ b/x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js @@ -32,6 +32,7 @@ const path = require('path'); const axios = require('axios'); const readFile = promisify(fs.readFile); const pLimit = require('p-limit'); +const pRetry = require('p-retry'); const { argv } = require('yargs'); const ora = require('ora'); @@ -49,8 +50,6 @@ if (!EVENTS_PATH) { process.exit(1); } -const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); - const requestProgress = { succeeded: 0, failed: 0, @@ -59,7 +58,7 @@ const requestProgress = { const spinner = ora({ text: 'Warming up...', stream: process.stdout }); -function updateSpinnerText({ success }) { +function incrementSpinnerCount({ success }) { success ? requestProgress.succeeded++ : requestProgress.failed++; const remaining = requestProgress.total - @@ -71,7 +70,6 @@ function updateSpinnerText({ success }) { async function insertItem(item) { try { const url = `${APM_SERVER_URL}${item.url}`; - const headers = { 'content-type': 'application/x-ndjson' }; @@ -86,16 +84,11 @@ async function insertItem(item) { headers, data: item.body }); - - updateSpinnerText({ success: true }); - - // add delay to avoid flooding the queue - return delay(500); } catch (e) { console.error( `${e.response ? JSON.stringify(e.response.data) : e.message}` ); - updateSpinnerText({ success: false }); + throw e; } } @@ -112,17 +105,34 @@ async function init() { requestProgress.total = items.length; const limit = pLimit(20); // number of concurrent requests - await Promise.all(items.map(item => limit(() => insertItem(item)))); + await Promise.all( + items.map(async item => { + try { + // retry 5 times with exponential backoff + await pRetry(() => limit(() => insertItem(item)), { retries: 5 }); + incrementSpinnerCount({ success: true }); + } catch (e) { + incrementSpinnerCount({ success: false }); + } + }) + ); } init() + .then(() => { + if (requestProgress.succeeded === requestProgress.total) { + spinner.succeed( + `Successfully ingested ${requestProgress.succeeded} of ${requestProgress.total} events` + ); + process.exit(0); + } else { + spinner.fail( + `Ingested ${requestProgress.succeeded} of ${requestProgress.total} events` + ); + process.exit(1); + } + }) .catch(e => { console.log('An error occurred:', e); process.exit(1); - }) - .then(() => { - spinner.succeed( - `Successfully ingested ${requestProgress.succeeded} of ${requestProgress.total} events` - ); - process.exit(0); }); diff --git a/x-pack/legacy/plugins/apm/e2e/package.json b/x-pack/legacy/plugins/apm/e2e/package.json index e298be7db514c..57500dfe3fdc8 100644 --- a/x-pack/legacy/plugins/apm/e2e/package.json +++ b/x-pack/legacy/plugins/apm/e2e/package.json @@ -18,6 +18,7 @@ "js-yaml": "^3.13.1", "ora": "^4.0.3", "p-limit": "^2.2.1", + "p-retry": "^4.2.0", "ts-loader": "^6.2.2", "typescript": "3.8.3", "wait-on": "^4.0.1", diff --git a/x-pack/legacy/plugins/apm/e2e/run-e2e.sh b/x-pack/legacy/plugins/apm/e2e/run-e2e.sh index 6c9ac83678682..5e55dc1eb834d 100755 --- a/x-pack/legacy/plugins/apm/e2e/run-e2e.sh +++ b/x-pack/legacy/plugins/apm/e2e/run-e2e.sh @@ -1,3 +1,5 @@ +#!/bin/sh + # variables KIBANA_PORT=5701 ELASTICSEARCH_PORT=9201 @@ -14,52 +16,74 @@ fi bold=$(tput bold) normal=$(tput sgr0) -# Create tmp folder -mkdir -p tmp +# paths +E2E_DIR="${0%/*}" +TMP_DIR="./tmp" +APM_IT_DIR="./tmp/apm-integration-testing" -# Ask user to start Kibana -echo " -${bold}To start Kibana please run the following command:${normal} +cd ${E2E_DIR} -node ./scripts/kibana --no-base-path --dev --no-dev-config --config x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml -" +# +# Ask user to start Kibana +################################################## +echo "\n${bold}To start Kibana please run the following command:${normal} +node ./scripts/kibana --no-base-path --dev --no-dev-config --config x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml" -# Clone or pull apm-integration-testing -printf "\n${bold}=== apm-integration-testing ===\n${normal}" +# +# Create tmp folder +################################################## +echo "\n${bold}Temporary folder${normal}" +echo "Temporary files will be stored in: ${TMP_DIR}" +mkdir -p ${TMP_DIR} + +# +# apm-integration-testing +################################################## +printf "\n${bold}apm-integration-testing (logs: ${TMP_DIR}/apm-it.log)\n${normal}" + +# pull if folder already exists +if [ -d ${APM_IT_DIR} ]; then + echo "Pulling from master..." + git -C ${APM_IT_DIR} pull &> ${TMP_DIR}/apm-it.log -git clone "https://github.com/elastic/apm-integration-testing.git" "./tmp/apm-integration-testing" &> /dev/null -if [ $? -eq 0 ]; then - echo "Cloning repository" +# clone if folder does not exists else - echo "Pulling from master..." - git -C "./tmp/apm-integration-testing" pull &> /dev/null + echo "Cloning repository" + git clone "https://github.com/elastic/apm-integration-testing.git" ${APM_IT_DIR} &> ${TMP_DIR}/apm-it.log +fi + +# Stop if clone/pull failed +if [ $? -ne 0 ]; then + printf "\n⚠️ Initializing apm-integration-testing failed. \n" + exit 1 fi # Start apm-integration-testing -echo "Starting (logs: ./tmp/apm-it.log)" -./tmp/apm-integration-testing/scripts/compose.py start master \ +echo "Starting docker-compose" +${APM_IT_DIR}/scripts/compose.py start master \ --no-kibana \ --elasticsearch-port $ELASTICSEARCH_PORT \ --apm-server-port=$APM_SERVER_PORT \ --elasticsearch-heap 4g \ - &> ./tmp/apm-it.log + &> ${TMP_DIR}/apm-it.log # Stop if apm-integration-testing failed to start correctly if [ $? -ne 0 ]; then printf "⚠️ apm-integration-testing could not be started.\n" - printf "Please see the logs in ./tmp/apm-it.log\n\n" - printf "As a last resort, reset docker with:\n\n./tmp/apm-integration-testing/scripts/compose.py stop && system prune --all --force --volumes\n" + printf "Please see the logs in ${TMP_DIR}/apm-it.log\n\n" + printf "As a last resort, reset docker with:\n\n cd ${APM_IT_DIR} && scripts/compose.py stop && docker system prune --all --force --volumes\n" exit 1 fi -printf "\n${bold}=== Static mock data ===\n${normal}" +# +# Static mock data +################################################## +printf "\n${bold}Static mock data (logs: ${TMP_DIR}/ingest-data.log)\n${normal}" # Download static data if not already done -if [ -e "./tmp/events.json" ]; then - echo 'Skip: events.json already exists. Not downloading' -else +if [ ! -e "${TMP_DIR}/events.json" ]; then echo 'Downloading events.json...' - curl --silent https://storage.googleapis.com/apm-ui-e2e-static-data/events.json --output ./tmp/events.json + curl --silent https://storage.googleapis.com/apm-ui-e2e-static-data/events.json --output ${TMP_DIR}/events.json fi # echo "Deleting existing indices (apm* and .apm*)" @@ -67,23 +91,38 @@ curl --silent --user admin:changeme -XDELETE "localhost:${ELASTICSEARCH_PORT}/.a curl --silent --user admin:changeme -XDELETE "localhost:${ELASTICSEARCH_PORT}/apm*" > /dev/null # Ingest data into APM Server -echo "Ingesting data (logs: tmp/ingest-data.log)" -node ingest-data/replay.js --server-url http://localhost:$APM_SERVER_PORT --events ./tmp/events.json 2> ./tmp/ingest-data.log +node ingest-data/replay.js --server-url http://localhost:$APM_SERVER_PORT --events ${TMP_DIR}/events.json 2> ${TMP_DIR}/ingest-data.log + +# Stop if not all events were ingested correctly +if [ $? -ne 0 ]; then + printf "\n⚠️ Not all events were ingested correctly. This might affect test tests. \n" + exit 1 +fi -# Install local dependencies -printf "\n" -echo "Installing local dependencies (logs: tmp/e2e-yarn.log)" -yarn &> ./tmp/e2e-yarn.log +# +# Cypress +################################################## +echo "\n${bold}Cypress (logs: ${TMP_DIR}/e2e-yarn.log)${normal}" +echo "Installing cypress dependencies " +yarn &> ${TMP_DIR}/e2e-yarn.log +# # Wait for Kibana to start -echo "Waiting for Kibana to start..." +################################################## +echo "\n${bold}Waiting for Kibana to start...${normal}" +echo "Note: you need to start Kibana manually. Find the instructions at the top." yarn wait-on -i 500 -w 500 http://localhost:$KIBANA_PORT > /dev/null echo "\n✅ Setup completed successfully. Running tests...\n" +# # run cypress tests +################################################## yarn cypress run --config pageLoadTimeout=100000,watchForFileChanges=true +# +# Run interactively +################################################## echo " ${bold}If you want to run the test interactively, run:${normal} diff --git a/x-pack/legacy/plugins/apm/e2e/yarn.lock b/x-pack/legacy/plugins/apm/e2e/yarn.lock index 474337931d665..b7b531a9c73c0 100644 --- a/x-pack/legacy/plugins/apm/e2e/yarn.lock +++ b/x-pack/legacy/plugins/apm/e2e/yarn.lock @@ -1019,6 +1019,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.14.tgz#b6c60ebf2fb5e4229fdd751ff9ddfae0f5f31541" integrity sha512-G0UmX5uKEmW+ZAhmZ6PLTQ5eu/VPaT+d/tdLd5IFsKRPcbe6lPxocBtcYBFSaLaCW8O60AX90e91Nsp8lVHCNw== +"@types/retry@^0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/sizzle@2.3.2": version "2.3.2" resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47" @@ -4342,6 +4347,14 @@ p-map@^2.0.0: resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== +p-retry@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.2.0.tgz#ea9066c6b44f23cab4cd42f6147cdbbc6604da5d" + integrity sha512-jPH38/MRh263KKcq0wBNOGFJbm+U6784RilTmHjB/HM9kH9V8WlCpVUcdOmip9cjXOh6MxZ5yk1z2SjDUJfWmA== + dependencies: + "@types/retry" "^0.12.0" + retry "^0.12.0" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -4874,6 +4887,11 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + rimraf@^2.5.4, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" diff --git a/x-pack/legacy/plugins/apm/readme.md b/x-pack/legacy/plugins/apm/readme.md index addf73064716c..e8e2514c83fcb 100644 --- a/x-pack/legacy/plugins/apm/readme.md +++ b/x-pack/legacy/plugins/apm/readme.md @@ -29,31 +29,13 @@ cd apm-integration-testing/ _Docker Compose is required_ -### Setup default APM users - -APM behaves differently depending on which the role and permissions a logged in user has. -For testing purposes APM uses 3 custom users: - -**apm_read_user**: Apps: read. Indices: read (`apm-*`) - -**apm_write_user**: Apps: read/write. Indices: read (`apm-*`) - -**kibana_write_user** Apps: read/write. Indices: None - -To create the users with the correct roles run the following script: +### E2E (Cypress) tests ```sh -node x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js --role-suffix +x-pack/legacy/plugins/apm/e2e/run-e2e.sh ``` -The users will be created with the password specified in kibana.dev.yml for `elasticsearch.password` - -### Debugging Elasticsearch queries - -All APM api endpoints accept `_debug=true` as a query param that will result in the underlying ES query being outputted in the Kibana backend process. - -Example: -`/api/apm/services/my_service?_debug=true` +_Starts Kibana (:5701), APM Server (:8201) and Elasticsearch (:9201). Ingests sample data into Elasticsearch via APM Server and runs the Cypress tests_ ### Unit testing @@ -74,11 +56,13 @@ node scripts/jest.js plugins/apm --updateSnapshot ### Functional tests **Start server** + ``` node scripts/functional_tests_server --config x-pack/test/functional/config.js ``` **Run tests** + ``` node scripts/functional_test_runner --config x-pack/test/functional/config.js --grep='APM specs' ``` @@ -89,11 +73,13 @@ For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme) ### API integration tests **Start server** + ``` node scripts/functional_tests_server --config x-pack/test/api_integration/config.js ``` **Run tests** + ``` node scripts/functional_test_runner --config x-pack/test/api_integration/config.js --grep='APM specs' ``` @@ -117,6 +103,32 @@ yarn prettier "./x-pack/legacy/plugins/apm/**/*.{tsx,ts,js}" --write yarn eslint ./x-pack/legacy/plugins/apm --fix ``` +### Setup default APM users + +APM behaves differently depending on which the role and permissions a logged in user has. +For testing purposes APM uses 3 custom users: + +**apm_read_user**: Apps: read. Indices: read (`apm-*`) + +**apm_write_user**: Apps: read/write. Indices: read (`apm-*`) + +**kibana_write_user** Apps: read/write. Indices: None + +To create the users with the correct roles run the following script: + +```sh +node x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js --role-suffix +``` + +The users will be created with the password specified in kibana.dev.yml for `elasticsearch.password` + +### Debugging Elasticsearch queries + +All APM api endpoints accept `_debug=true` as a query param that will result in the underlying ES query being outputted in the Kibana backend process. + +Example: +`/api/apm/services/my_service?_debug=true` + #### Storybook Start the [Storybook](https://storybook.js.org/) development environment with From caaeb3758a86709806f5c7509c93f343b8cfcd52 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Wed, 25 Mar 2020 07:29:35 +0100 Subject: [PATCH 55/56] [ML] Data Frame Analytics: Use EuiDataGrid for outlier result page (#58235) - Replaces EuiInMemoryTable with EuiDataGrid - Replaces the memory table's search with QueryInputFilter --- .../data_frame_analytics/_index.scss | 1 - .../data_frame_analytics/common/analytics.ts | 2 + .../data_frame_analytics/common/data_grid.ts | 23 + .../data_frame_analytics/common/fields.ts | 20 +- .../data_frame_analytics/common/index.ts | 2 + .../components/exploration/_exploration.scss | 19 - .../components/exploration/_index.scss | 1 - .../components/exploration/exploration.tsx | 572 ------------------ .../exploration/use_explore_data.ts | 153 ----- .../exploration_data_grid.tsx | 145 +++++ .../components/exploration_data_grid/index.ts | 7 + .../exploration_query_bar.tsx | 106 ++++ .../components/exploration_query_bar/index.ts | 7 + .../index.ts | 2 +- .../outlier_exploration.test.tsx} | 4 +- .../outlier_exploration.tsx | 220 +++++++ .../use_explore_data}/common.test.ts | 0 .../use_explore_data}/common.ts | 0 .../hooks/use_explore_data/index.ts | 7 + .../use_explore_data/use_explore_data.ts | 232 +++++++ .../pages/analytics_exploration/page.tsx | 8 +- .../translations/translations/ja-JP.json | 7 - .../translations/translations/zh-CN.json | 7 - 23 files changed, 767 insertions(+), 778 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/common/data_grid.ts delete mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_exploration.scss delete mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_index.scss delete mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx delete mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/use_explore_data.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/exploration_data_grid.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/index.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/index.ts rename x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/{exploration => outlier_exploration}/index.ts (80%) rename x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/{exploration/exploration.test.tsx => outlier_exploration/outlier_exploration.test.tsx} (88%) create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx rename x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/{components/exploration => hooks/use_explore_data}/common.test.ts (100%) rename x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/{components/exploration => hooks/use_explore_data}/common.ts (100%) create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/index.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/use_explore_data.ts diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss index 962d3f4c7bd54..83314a74331fd 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss @@ -1,4 +1,3 @@ -@import 'pages/analytics_exploration/components/exploration/index'; @import 'pages/analytics_exploration/components/regression_exploration/index'; @import 'pages/analytics_exploration/components/classification_exploration/index'; @import 'pages/analytics_management/components/analytics_list/index'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 9c239df357163..95a8dfbb308f8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -56,6 +56,8 @@ export interface LoadExploreDataArg { direction: SortDirection; searchQuery: SavedSearchQuery; requiresKeyword?: boolean; + pageIndex?: number; + pageSize?: number; } export const SEARCH_SIZE = 1000; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/data_grid.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/data_grid.ts new file mode 100644 index 0000000000000..2b6d733837562 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/data_grid.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiDataGridStyle } from '@elastic/eui'; + +export const euiDataGridStyle: EuiDataGridStyle = { + border: 'all', + fontSize: 's', + cellPadding: 's', + stripes: false, + rowHover: 'none', + header: 'shade', +}; + +export const euiDataGridToolbarSettings = { + showColumnSelector: true, + showStyleSelector: false, + showSortSelector: true, + showFullScreenSelector: false, +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts index e8ebf2b1cfd56..fb1d4edb37af8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts @@ -362,18 +362,16 @@ export const getDefaultSelectableFields = (docs: EsDoc[], resultsField: string): } const newDocFields = getFlattenedFields(docs[0]._source, resultsField); - return newDocFields - .filter(k => { - if (k === `${resultsField}.outlier_score`) { - return true; - } - if (k.split('.')[0] === resultsField) { - return false; - } + return newDocFields.filter(k => { + if (k === `${resultsField}.outlier_score`) { + return true; + } + if (k.split('.')[0] === resultsField) { + return false; + } - return docs.some(row => row._source[k] !== null); - }) - .slice(0, MAX_COLUMNS); + return docs.some(row => row._source[k] !== null); + }); }; export const toggleSelectedFieldSimple = ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts index 62ef73670d8f5..7b76faf613ce8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -46,3 +46,5 @@ export { EsFieldName, MAX_COLUMNS, } from './fields'; + +export { euiDataGridStyle, euiDataGridToolbarSettings } from './data_grid'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_exploration.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_exploration.scss deleted file mode 100644 index b5b90347cf0b8..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_exploration.scss +++ /dev/null @@ -1,19 +0,0 @@ -.mlDataFrameAnalyticsExploration { - /* Overwrite to give table cells a more grid-like appearance */ - .euiTableHeaderCell { - padding: 0 4px; - } - .euiTableCellContent { - padding: 0; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - line-height: 2; - } -} - -.mlColoredTableCell { - width: 100%; - height: 100%; - padding: 0 4px; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_index.scss deleted file mode 100644 index ca27eec1d5a4d..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'exploration'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx deleted file mode 100644 index 70c29051c8215..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx +++ /dev/null @@ -1,572 +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, { FC, useEffect, useState } from 'react'; -import moment from 'moment-timezone'; - -import { i18n } from '@kbn/i18n'; - -import { - EuiBadge, - EuiButtonIcon, - EuiCallOut, - EuiCheckbox, - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiPopover, - EuiPopoverTitle, - EuiProgress, - EuiSpacer, - EuiText, - EuiTitle, - EuiToolTip, - Query, -} from '@elastic/eui'; - -import { - useColorRange, - ColorRangeLegend, - COLOR_RANGE, - COLOR_RANGE_SCALE, -} from '../../../../../components/color_range_legend'; -import { - ColumnType, - mlInMemoryTableBasicFactory, - OnTableChangeArg, - SortingPropType, - SORT_DIRECTION, -} from '../../../../../components/ml_in_memory_table'; - -import { formatHumanReadableDateTimeSeconds } from '../../../../../util/date_utils'; -import { ml } from '../../../../../services/ml_api_service'; - -import { - sortColumns, - toggleSelectedFieldSimple, - DataFrameAnalyticsConfig, - EsFieldName, - EsDoc, - MAX_COLUMNS, - INDEX_STATUS, - SEARCH_SIZE, - defaultSearchQuery, -} from '../../../../common'; -import { isKeywordAndTextType } from '../../../../common/fields'; - -import { getOutlierScoreFieldName } from './common'; -import { useExploreData, TableItem } from './use_explore_data'; -import { - DATA_FRAME_TASK_STATE, - Query as QueryType, -} from '../../../analytics_management/components/analytics_list/common'; -import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/columns'; -import { SavedSearchQuery } from '../../../../../contexts/ml'; -import { getIndexPatternIdFromName } from '../../../../../util/index_utils'; -import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/common/index_patterns'; -import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; -import { useMlContext } from '../../../../../contexts/ml'; - -const FEATURE_INFLUENCE = 'feature_influence'; - -const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; - -const ExplorationTitle: React.FC<{ jobId: string }> = ({ jobId }) => ( - - - {i18n.translate('xpack.ml.dataframe.analytics.exploration.jobIdTitle', { - defaultMessage: 'Outlier detection job ID {jobId}', - values: { jobId }, - })} - - -); - -interface Props { - jobId: string; - jobStatus: DATA_FRAME_TASK_STATE; -} - -const getFeatureCount = (jobConfig?: DataFrameAnalyticsConfig, tableItems: TableItem[] = []) => { - if (jobConfig === undefined || tableItems.length === 0) { - return 0; - } - - return Object.keys(tableItems[0]).filter(key => - key.includes(`${jobConfig.dest.results_field}.${FEATURE_INFLUENCE}.`) - ).length; -}; - -export const Exploration: FC = React.memo(({ jobId, jobStatus }) => { - const [jobConfig, setJobConfig] = useState(undefined); - - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(25); - - const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); - const [searchError, setSearchError] = useState(undefined); - const [searchString, setSearchString] = useState(undefined); - - const mlContext = useMlContext(); - - const initializeJobCapsService = async () => { - if (jobConfig !== undefined) { - const sourceIndex = jobConfig.source.index[0]; - const indexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; - const indexPattern: IIndexPattern = await mlContext.indexPatterns.get(indexPatternId); - if (indexPattern !== undefined) { - await newJobCapsService.initializeFromIndexPattern(indexPattern, false, false); - } - } - }; - - useEffect(() => { - (async function() { - const analyticsConfigs = await ml.dataFrameAnalytics.getDataFrameAnalytics(jobId); - if ( - Array.isArray(analyticsConfigs.data_frame_analytics) && - analyticsConfigs.data_frame_analytics.length > 0 - ) { - setJobConfig(analyticsConfigs.data_frame_analytics[0]); - } - })(); - }, []); - - useEffect(() => { - initializeJobCapsService(); - }, [jobConfig && jobConfig.id]); - - const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]); - const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false); - - function toggleColumnsPopover() { - setColumnsPopoverVisible(!isColumnsPopoverVisible); - } - - function closeColumnsPopover() { - setColumnsPopoverVisible(false); - } - - function toggleColumn(column: EsFieldName) { - if (tableItems.length > 0 && jobConfig !== undefined) { - // spread to a new array otherwise the component wouldn't re-render - setSelectedFields([...toggleSelectedFieldSimple(selectedFields, column)]); - } - } - - const { - errorMessage, - loadExploreData, - sortField, - sortDirection, - status, - tableItems, - } = useExploreData(jobConfig, selectedFields, setSelectedFields); - - let docFields: EsFieldName[] = []; - let docFieldsCount = 0; - if (tableItems.length > 0) { - docFields = Object.keys(tableItems[0]); - docFields.sort(); - docFieldsCount = docFields.length; - } - - const columns: Array> = []; - - const cellBgColor = useColorRange( - COLOR_RANGE.BLUE, - COLOR_RANGE_SCALE.INFLUENCER, - getFeatureCount(jobConfig, tableItems) - ); - - if (jobConfig !== undefined && selectedFields.length > 0 && tableItems.length > 0) { - columns.push( - ...selectedFields.sort(sortColumns(tableItems[0], jobConfig.dest.results_field)).map(k => { - const column: ColumnType = { - field: k, - name: k, - sortable: true, - truncateText: true, - }; - - const render = (d: any, fullItem: EsDoc) => { - if (Array.isArray(d) && d.every(item => typeof item === 'string')) { - // If the cells data is an array of strings, return as a comma separated list. - // The list will get limited to 5 items with `…` at the end if there's more in the original array. - return `${d.slice(0, 5).join(', ')}${d.length > 5 ? ', …' : ''}`; - } else if (Array.isArray(d)) { - // If the cells data is an array of e.g. objects, display a 'array' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.exploration.indexArrayBadgeContent', - { - defaultMessage: 'array', - } - )} - - - ); - } else if (typeof d === 'object' && d !== null) { - // If the cells data is an object, display a 'object' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.exploration.indexObjectBadgeContent', - { - defaultMessage: 'object', - } - )} - - - ); - } - - const split = k.split('.'); - let backgroundColor; - const color = undefined; - const resultsField = jobConfig.dest.results_field; - - if (fullItem[`${resultsField}.${FEATURE_INFLUENCE}.${k}`] !== undefined) { - backgroundColor = cellBgColor(fullItem[`${resultsField}.${FEATURE_INFLUENCE}.${k}`]); - } - - if (split.length > 2 && split[0] === resultsField && split[1] === FEATURE_INFLUENCE) { - backgroundColor = cellBgColor(d); - } - - return ( -
- {d} -
- ); - }; - - let columnType; - - if (tableItems.length > 0) { - columnType = typeof tableItems[0][k]; - } - - if (typeof columnType !== 'undefined') { - switch (columnType) { - case 'boolean': - column.dataType = 'boolean'; - break; - case 'Date': - column.align = 'right'; - column.render = (d: any) => - formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); - break; - case 'number': - column.dataType = 'number'; - column.render = render; - break; - default: - column.render = render; - break; - } - } else { - column.render = render; - } - - return column; - }) - ); - } - - useEffect(() => { - if (jobConfig !== undefined) { - const outlierScoreFieldName = getOutlierScoreFieldName(jobConfig); - const outlierScoreFieldSelected = selectedFields.includes(outlierScoreFieldName); - let requiresKeyword = false; - - const field = outlierScoreFieldSelected ? outlierScoreFieldName : selectedFields[0]; - const direction = outlierScoreFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - - if (outlierScoreFieldSelected === false) { - requiresKeyword = isKeywordAndTextType(field); - } - - loadExploreData({ field, direction, searchQuery, requiresKeyword }); - } - }, [JSON.stringify(searchQuery)]); - - useEffect(() => { - // by default set the sorting to descending on the `outlier_score` field. - // if that's not available sort ascending on the first column. - // also check if the current sorting field is still available. - if (jobConfig !== undefined && columns.length > 0 && !selectedFields.includes(sortField)) { - const outlierScoreFieldName = getOutlierScoreFieldName(jobConfig); - const outlierScoreFieldSelected = selectedFields.includes(outlierScoreFieldName); - let requiresKeyword = false; - - const field = outlierScoreFieldSelected ? outlierScoreFieldName : selectedFields[0]; - const direction = outlierScoreFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - - if (outlierScoreFieldSelected === false) { - requiresKeyword = isKeywordAndTextType(field); - } - - loadExploreData({ field, direction, searchQuery, requiresKeyword }); - return; - } - }, [jobConfig, columns.length, sortField, sortDirection, tableItems.length]); - - let sorting: SortingPropType = false; - let onTableChange; - - if (columns.length > 0 && sortField !== '') { - sorting = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; - - onTableChange = ({ - page = { index: 0, size: 10 }, - sort = { field: sortField, direction: sortDirection }, - }: OnTableChangeArg) => { - const { index, size } = page; - setPageIndex(index); - setPageSize(size); - - if ( - (sort.field !== sortField || sort.direction !== sortDirection) && - jobConfig !== undefined - ) { - const outlierScoreFieldName = getOutlierScoreFieldName(jobConfig); - let requiresKeyword = false; - - if (outlierScoreFieldName !== sort.field) { - requiresKeyword = isKeywordAndTextType(sort.field); - } - loadExploreData({ ...sort, searchQuery, requiresKeyword }); - } - }; - } - - const pagination = { - initialPageIndex: pageIndex, - initialPageSize: pageSize, - totalItemCount: tableItems.length, - pageSizeOptions: PAGE_SIZE_OPTIONS, - hidePerPageOptions: false, - }; - - const onQueryChange = ({ query, error }: { query: QueryType; error: any }) => { - if (error) { - setSearchError(error.message); - } else { - try { - const esQueryDsl = Query.toESQuery(query); - setSearchQuery(esQueryDsl); - setSearchString(query.text); - setSearchError(undefined); - } catch (e) { - setSearchError(e.toString()); - } - } - }; - - const search = { - onChange: onQueryChange, - defaultQuery: searchString, - box: { - incremental: false, - placeholder: i18n.translate('xpack.ml.dataframe.analytics.exploration.searchBoxPlaceholder', { - defaultMessage: 'E.g. avg>0.5', - }), - }, - }; - - if (jobConfig === undefined) { - return null; - } - // if it's a searchBar syntax error leave the table visible so they can try again - if (status === INDEX_STATUS.ERROR && !errorMessage.includes('parsing_exception')) { - return ( - - - -

{errorMessage}

-
-
- ); - } - - let tableError = - status === INDEX_STATUS.ERROR && errorMessage.includes('parsing_exception') - ? errorMessage - : searchError; - - if (status === INDEX_STATUS.LOADED && tableItems.length === 0 && tableError === undefined) { - tableError = i18n.translate('xpack.ml.dataframe.analytics.exploration.noDataCalloutBody', { - defaultMessage: - 'The query for the index returned no results. Please make sure the index contains documents and your query is not too restrictive.', - }); - } - - const MlInMemoryTableBasic = mlInMemoryTableBasicFactory(); - - return ( - - - - - - - - - {getTaskStateBadge(jobStatus)} - - - - - - - {docFieldsCount > MAX_COLUMNS && ( - - {i18n.translate('xpack.ml.dataframe.analytics.exploration.fieldSelection', { - defaultMessage: - '{selectedFieldsLength, number} of {docFieldsCount, number} {docFieldsCount, plural, one {field} other {fields}} selected', - values: { selectedFieldsLength: selectedFields.length, docFieldsCount }, - })} - - )} - - - - - } - isOpen={isColumnsPopoverVisible} - closePopover={closeColumnsPopover} - ownFocus - > - - {i18n.translate( - 'xpack.ml.dataframe.analytics.exploration.selectFieldsPopoverTitle', - { - defaultMessage: 'Select fields', - } - )} - -
- {docFields.map(d => ( - toggleColumn(d)} - disabled={selectedFields.includes(d) && selectedFields.length === 1} - /> - ))} -
-
-
-
-
-
-
- {status === INDEX_STATUS.LOADING && } - {status !== INDEX_STATUS.LOADING && ( - - )} - {(columns.length > 0 || searchQuery !== defaultSearchQuery) && sortField !== '' && ( - <> - - - - {tableItems.length === SEARCH_SIZE && ( - - {i18n.translate( - 'xpack.ml.dataframe.analytics.exploration.documentsShownHelpText', - { - defaultMessage: 'Showing first {searchSize} documents', - values: { searchSize: SEARCH_SIZE }, - } - )} - - )} - - - - - - - - )} -
- ); -}); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/use_explore_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/use_explore_data.ts deleted file mode 100644 index 24cc8d000de7e..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/use_explore_data.ts +++ /dev/null @@ -1,153 +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, { useEffect, useState } from 'react'; - -import { SearchResponse } from 'elasticsearch'; - -import { SortDirection, SORT_DIRECTION } from '../../../../../components/ml_in_memory_table'; - -import { ml } from '../../../../../services/ml_api_service'; -import { getNestedProperty } from '../../../../../util/object_utils'; - -import { - getDefaultSelectableFields, - getFlattenedFields, - DataFrameAnalyticsConfig, - EsFieldName, - INDEX_STATUS, - SEARCH_SIZE, - defaultSearchQuery, - SearchQuery, -} from '../../../../common'; -import { LoadExploreDataArg } from '../../../../common/analytics'; - -import { getOutlierScoreFieldName } from './common'; - -export type TableItem = Record; - -export interface UseExploreDataReturnType { - errorMessage: string; - loadExploreData: (arg: LoadExploreDataArg) => void; - sortField: EsFieldName; - sortDirection: SortDirection; - status: INDEX_STATUS; - tableItems: TableItem[]; -} - -export const useExploreData = ( - jobConfig: DataFrameAnalyticsConfig | undefined, - selectedFields: EsFieldName[], - setSelectedFields: React.Dispatch> -): UseExploreDataReturnType => { - const [errorMessage, setErrorMessage] = useState(''); - const [status, setStatus] = useState(INDEX_STATUS.UNUSED); - const [tableItems, setTableItems] = useState([]); - const [sortField, setSortField] = useState(''); - const [sortDirection, setSortDirection] = useState(SORT_DIRECTION.ASC); - - const loadExploreData = async ({ - field, - direction, - searchQuery, - requiresKeyword, - }: LoadExploreDataArg) => { - if (jobConfig !== undefined) { - setErrorMessage(''); - setStatus(INDEX_STATUS.LOADING); - - try { - const resultsField = jobConfig.dest.results_field; - - const body: SearchQuery = { - query: searchQuery, - }; - - if (field !== undefined) { - body.sort = [ - { - [`${field}${requiresKeyword ? '.keyword' : ''}`]: { - order: direction, - }, - }, - ]; - } - - const resp: SearchResponse = await ml.esSearch({ - index: jobConfig.dest.index, - size: SEARCH_SIZE, - body, - }); - - setSortField(field); - setSortDirection(direction); - - const docs = resp.hits.hits; - - if (docs.length === 0) { - setTableItems([]); - setStatus(INDEX_STATUS.LOADED); - return; - } - - if (selectedFields.length === 0) { - const newSelectedFields = getDefaultSelectableFields(docs, resultsField); - setSelectedFields(newSelectedFields); - } - - // Create a version of the doc's source with flattened field names. - // This avoids confusion later on if a field name has dots in its name - // or is a nested fields when displaying it via EuiInMemoryTable. - const flattenedFields = getFlattenedFields(docs[0]._source, resultsField); - const transformedTableItems = docs.map(doc => { - const item: TableItem = {}; - flattenedFields.forEach(ff => { - item[ff] = getNestedProperty(doc._source, ff); - if (item[ff] === undefined) { - // If the attribute is undefined, it means it was not a nested property - // but had dots in its actual name. This selects the property by its - // full name and assigns it to `item[ff]`. - item[ff] = doc._source[`"${ff}"`]; - } - if (item[ff] === undefined) { - const parts = ff.split('.'); - if (parts[0] === resultsField && parts.length >= 2) { - parts.shift(); - if (doc._source[resultsField] !== undefined) { - item[ff] = doc._source[resultsField][parts.join('.')]; - } - } - } - }); - return item; - }); - - setTableItems(transformedTableItems); - setStatus(INDEX_STATUS.LOADED); - } catch (e) { - if (e.message !== undefined) { - setErrorMessage(e.message); - } else { - setErrorMessage(JSON.stringify(e)); - } - setTableItems([]); - setStatus(INDEX_STATUS.ERROR); - } - } - }; - - useEffect(() => { - if (jobConfig !== undefined) { - loadExploreData({ - field: getOutlierScoreFieldName(jobConfig), - direction: SORT_DIRECTION.DESC, - searchQuery: defaultSearchQuery, - }); - } - }, [jobConfig && jobConfig.id]); - - return { errorMessage, loadExploreData, sortField, sortDirection, status, tableItems }; -}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/exploration_data_grid.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/exploration_data_grid.tsx new file mode 100644 index 0000000000000..2df0f70a56722 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/exploration_data_grid.tsx @@ -0,0 +1,145 @@ +/* + * 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, { Dispatch, FC, SetStateAction, useCallback, useMemo } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiDataGrid, EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui'; + +import { euiDataGridStyle, euiDataGridToolbarSettings } from '../../../../common'; + +const FEATURE_INFLUENCE = 'feature_influence'; +const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; + +type Pagination = Pick; +type TableItem = Record; + +interface ExplorationDataGridProps { + colorRange: (d: number) => string; + columns: any[]; + pagination: Pagination; + resultsField: string; + rowCount: number; + selectedFields: string[]; + setPagination: Dispatch>; + setSelectedFields: Dispatch>; + setSortingColumns: Dispatch>; + sortingColumns: EuiDataGridSorting['columns']; + tableItems: TableItem[]; +} + +export const ExplorationDataGrid: FC = ({ + colorRange, + columns, + pagination, + resultsField, + rowCount, + selectedFields, + setPagination, + setSelectedFields, + setSortingColumns, + sortingColumns, + tableItems, +}) => { + const renderCellValue = useMemo(() => { + return ({ + rowIndex, + columnId, + setCellProps, + }: { + rowIndex: number; + columnId: string; + setCellProps: any; + }) => { + const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize; + + const fullItem = tableItems[adjustedRowIndex]; + + if (fullItem === undefined) { + return null; + } + + const cellValue = + fullItem.hasOwnProperty(columnId) && fullItem[columnId] !== undefined + ? fullItem[columnId] + : null; + + const split = columnId.split('.'); + let backgroundColor; + + // column with feature values get color coded by its corresponding influencer value + if (fullItem[`${resultsField}.${FEATURE_INFLUENCE}.${columnId}`] !== undefined) { + backgroundColor = colorRange(fullItem[`${resultsField}.${FEATURE_INFLUENCE}.${columnId}`]); + } + + // column with influencer values get color coded by its own value + if (split.length > 2 && split[0] === resultsField && split[1] === FEATURE_INFLUENCE) { + backgroundColor = colorRange(cellValue); + } + + if (backgroundColor !== undefined) { + setCellProps({ + style: { backgroundColor }, + }); + } + + if (typeof cellValue === 'string' || cellValue === null) { + return cellValue; + } + + if (typeof cellValue === 'boolean') { + return cellValue ? 'true' : 'false'; + } + + if (typeof cellValue === 'object' && cellValue !== null) { + return JSON.stringify(cellValue); + } + + return cellValue; + }; + }, [resultsField, rowCount, tableItems, pagination.pageIndex, pagination.pageSize]); + + const onChangeItemsPerPage = useCallback( + pageSize => { + setPagination(p => { + const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize); + return { pageIndex, pageSize }; + }); + }, + [setPagination] + ); + + const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [ + setPagination, + ]); + + const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]); + + return ( + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/index.ts new file mode 100644 index 0000000000000..ea89e91de5046 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/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 { ExplorationDataGrid } from './exploration_data_grid'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx new file mode 100644 index 0000000000000..f95e6a93058ba --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx @@ -0,0 +1,106 @@ +/* + * 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, { Dispatch, FC, SetStateAction, useState } from 'react'; + +import { EuiCode, EuiInputPopover } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/common/index_patterns'; +import { + esKuery, + esQuery, + Query, + QueryStringInput, +} from '../../../../../../../../../../src/plugins/data/public'; + +import { SEARCH_QUERY_LANGUAGE } from '../../../../../../../common/constants/search'; + +import { SavedSearchQuery } from '../../../../../contexts/ml'; + +interface ErrorMessage { + query: string; + message: string; +} + +interface ExplorationQueryBarProps { + indexPattern: IIndexPattern; + setSearchQuery: Dispatch>; +} + +export const ExplorationQueryBar: FC = ({ + indexPattern, + setSearchQuery, +}) => { + // The internal state of the input query bar updated on every key stroke. + const [searchInput, setSearchInput] = useState({ + query: '', + language: SEARCH_QUERY_LANGUAGE.KUERY, + }); + + const [errorMessage, setErrorMessage] = useState(undefined); + + const searchChangeHandler = (query: Query) => setSearchInput(query); + const searchSubmitHandler = (query: Query) => { + try { + switch (query.language) { + case SEARCH_QUERY_LANGUAGE.KUERY: + setSearchQuery( + esKuery.toElasticsearchQuery( + esKuery.fromKueryExpression(query.query as string), + indexPattern + ) + ); + return; + case SEARCH_QUERY_LANGUAGE.LUCENE: + setSearchQuery(esQuery.luceneStringToDsl(query.query as string)); + return; + } + } catch (e) { + setErrorMessage({ query: query.query as string, message: e.message }); + } + }; + + return ( + setErrorMessage(undefined)} + input={ + + } + isOpen={errorMessage?.query === searchInput.query && errorMessage?.message !== ''} + > + + {i18n.translate('xpack.ml.stepDefineForm.invalidQuery', { + defaultMessage: 'Invalid Query', + })} + {': '} + {errorMessage?.message.split('\n')[0]} + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/index.ts new file mode 100644 index 0000000000000..bebf4f65db04e --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/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 { ExplorationQueryBar } from './exploration_query_bar'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/index.ts similarity index 80% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/index.ts rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/index.ts index 6f15c278158dc..de49556f9cc98 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { Exploration } from './exploration'; +export { OutlierExploration } from './outlier_exploration'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.test.tsx similarity index 88% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.test.tsx rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.test.tsx index ca8fd68079f7e..030447873f6a5 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.test.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.test.tsx @@ -10,7 +10,7 @@ import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/ import { MlContext } from '../../../../../contexts/ml'; import { kibanaContextValueMock } from '../../../../../contexts/ml/__mocks__/kibana_context_value'; -import { Exploration } from './exploration'; +import { OutlierExploration } from './outlier_exploration'; // workaround to make React.memo() work with enzyme jest.mock('react', () => { @@ -22,7 +22,7 @@ describe('Data Frame Analytics: ', () => { test('Minimal initialization', () => { const wrapper = shallow( - + ); // Without the jobConfig being loaded, the component will just return empty. diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx new file mode 100644 index 0000000000000..214bc01c6a2ef --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx @@ -0,0 +1,220 @@ +/* + * 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, { FC } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiPanel, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; + +import { + useColorRange, + ColorRangeLegend, + COLOR_RANGE, + COLOR_RANGE_SCALE, +} from '../../../../../components/color_range_legend'; + +import { sortColumns, INDEX_STATUS, defaultSearchQuery } from '../../../../common'; + +import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; +import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/columns'; + +import { useExploreData, TableItem } from '../../hooks/use_explore_data'; + +import { ExplorationDataGrid } from '../exploration_data_grid'; +import { ExplorationQueryBar } from '../exploration_query_bar'; + +const FEATURE_INFLUENCE = 'feature_influence'; + +const ExplorationTitle: FC<{ jobId: string }> = ({ jobId }) => ( + + + {i18n.translate('xpack.ml.dataframe.analytics.exploration.jobIdTitle', { + defaultMessage: 'Outlier detection job ID {jobId}', + values: { jobId }, + })} + + +); + +interface ExplorationProps { + jobId: string; + jobStatus: DATA_FRAME_TASK_STATE; +} + +const getFeatureCount = (resultsField: string, tableItems: TableItem[] = []) => { + if (tableItems.length === 0) { + return 0; + } + + return Object.keys(tableItems[0]).filter(key => + key.includes(`${resultsField}.${FEATURE_INFLUENCE}.`) + ).length; +}; + +export const OutlierExploration: FC = React.memo(({ jobId, jobStatus }) => { + const { + errorMessage, + indexPattern, + jobConfig, + pagination, + searchQuery, + selectedFields, + setPagination, + setSearchQuery, + setSelectedFields, + setSortingColumns, + sortingColumns, + rowCount, + status, + tableFields, + tableItems, + } = useExploreData(jobId); + + const columns = []; + + if ( + jobConfig !== undefined && + indexPattern !== undefined && + selectedFields.length > 0 && + tableItems.length > 0 + ) { + const resultsField = jobConfig.dest.results_field; + const removePrefix = new RegExp(`^${resultsField}\.${FEATURE_INFLUENCE}\.`, 'g'); + columns.push( + ...tableFields.sort(sortColumns(tableItems[0], resultsField)).map(id => { + const idWithoutPrefix = id.replace(removePrefix, ''); + const field = indexPattern.fields.getByName(idWithoutPrefix); + + // Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json'] + // To fall back to the default string schema it needs to be undefined. + let schema; + + switch (field?.type) { + case 'date': + schema = 'datetime'; + break; + case 'geo_point': + schema = 'json'; + break; + case 'number': + schema = 'numeric'; + break; + } + + if (id === `${resultsField}.outlier_score`) { + schema = 'numeric'; + } + + return { id, schema }; + }) + ); + } + + const colorRange = useColorRange( + COLOR_RANGE.BLUE, + COLOR_RANGE_SCALE.INFLUENCER, + jobConfig !== undefined ? getFeatureCount(jobConfig.dest.results_field, tableItems) : 1 + ); + + if (jobConfig === undefined || indexPattern === undefined) { + return null; + } + + // if it's a searchBar syntax error leave the table visible so they can try again + if (status === INDEX_STATUS.ERROR && !errorMessage.includes('parsing_exception')) { + return ( + + + +

{errorMessage}

+
+
+ ); + } + + let tableError = + status === INDEX_STATUS.ERROR && errorMessage.includes('parsing_exception') + ? errorMessage + : undefined; + + if (status === INDEX_STATUS.LOADED && tableItems.length === 0 && tableError === undefined) { + tableError = i18n.translate('xpack.ml.dataframe.analytics.exploration.noDataCalloutBody', { + defaultMessage: + 'The query for the index returned no results. Please make sure the index contains documents and your query is not too restrictive.', + }); + } + + return ( + + + + + + + {getTaskStateBadge(jobStatus)} + + + + {(columns.length > 0 || searchQuery !== defaultSearchQuery) && ( + <> + + + + + + + + + + + {columns.length > 0 && tableItems.length > 0 && ( + + )} + + )} + + ); +}); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/common.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/common.test.ts similarity index 100% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/common.test.ts rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/common.test.ts diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/common.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/common.ts similarity index 100% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/common.ts rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/common.ts diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/index.ts new file mode 100644 index 0000000000000..dd896ca02f7f7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/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 { useExploreData, TableItem } from './use_explore_data'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/use_explore_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/use_explore_data.ts new file mode 100644 index 0000000000000..6ad0a1822e490 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/use_explore_data.ts @@ -0,0 +1,232 @@ +/* + * 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 { useEffect, useState, Dispatch, SetStateAction } from 'react'; +import { SearchResponse } from 'elasticsearch'; + +import { EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui'; + +import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; + +import { Dictionary } from '../../../../../../../common/types/common'; + +import { SavedSearchQuery } from '../../../../../contexts/ml'; +import { ml } from '../../../../../services/ml_api_service'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { getIndexPatternIdFromName } from '../../../../../util/index_utils'; +import { getNestedProperty } from '../../../../../util/object_utils'; +import { useMlContext } from '../../../../../contexts/ml'; + +import { + getDefaultSelectableFields, + getFlattenedFields, + DataFrameAnalyticsConfig, + EsFieldName, + INDEX_STATUS, + defaultSearchQuery, +} from '../../../../common'; +import { isKeywordAndTextType } from '../../../../common/fields'; + +import { getOutlierScoreFieldName } from './common'; + +export type TableItem = Record; + +type Pagination = Pick; + +interface UseExploreDataReturnType { + errorMessage: string; + indexPattern: IndexPattern | undefined; + jobConfig: DataFrameAnalyticsConfig | undefined; + pagination: Pagination; + searchQuery: SavedSearchQuery; + selectedFields: EsFieldName[]; + setJobConfig: Dispatch>; + setPagination: Dispatch>; + setSearchQuery: Dispatch>; + setSelectedFields: Dispatch>; + setSortingColumns: Dispatch>; + rowCount: number; + sortingColumns: EuiDataGridSorting['columns']; + status: INDEX_STATUS; + tableFields: string[]; + tableItems: TableItem[]; +} + +type EsSorting = Dictionary<{ + order: 'asc' | 'desc'; +}>; + +// The types specified in `@types/elasticsearch` are out of date and still have `total: number`. +interface SearchResponse7 extends SearchResponse { + hits: SearchResponse['hits'] & { + total: { + value: number; + relation: string; + }; + }; +} + +export const useExploreData = (jobId: string): UseExploreDataReturnType => { + const mlContext = useMlContext(); + + const [indexPattern, setIndexPattern] = useState(undefined); + const [jobConfig, setJobConfig] = useState(undefined); + const [errorMessage, setErrorMessage] = useState(''); + const [status, setStatus] = useState(INDEX_STATUS.UNUSED); + + const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]); + const [tableFields, setTableFields] = useState([]); + const [tableItems, setTableItems] = useState([]); + const [rowCount, setRowCount] = useState(0); + + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); + const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); + const [sortingColumns, setSortingColumns] = useState([]); + + // get analytics configuration + useEffect(() => { + (async function() { + const analyticsConfigs = await ml.dataFrameAnalytics.getDataFrameAnalytics(jobId); + if ( + Array.isArray(analyticsConfigs.data_frame_analytics) && + analyticsConfigs.data_frame_analytics.length > 0 + ) { + setJobConfig(analyticsConfigs.data_frame_analytics[0]); + } + })(); + }, []); + + // get index pattern and field caps + useEffect(() => { + (async () => { + if (jobConfig !== undefined) { + const sourceIndex = jobConfig.source.index[0]; + const indexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; + const jobCapsIndexPattern: IndexPattern = await mlContext.indexPatterns.get(indexPatternId); + if (jobCapsIndexPattern !== undefined) { + setIndexPattern(jobCapsIndexPattern); + await newJobCapsService.initializeFromIndexPattern(jobCapsIndexPattern, false, false); + } + } + })(); + }, [jobConfig && jobConfig.id]); + + // initialize sorting: reverse sort on outlier score column + useEffect(() => { + if (jobConfig !== undefined) { + setSortingColumns([{ id: getOutlierScoreFieldName(jobConfig), direction: 'desc' }]); + } + }, [jobConfig && jobConfig.id]); + + // update data grid data + useEffect(() => { + (async () => { + if (jobConfig !== undefined) { + setErrorMessage(''); + setStatus(INDEX_STATUS.LOADING); + + try { + const resultsField = jobConfig.dest.results_field; + + const sort: EsSorting = sortingColumns + .map(column => { + const { id } = column; + column.id = isKeywordAndTextType(id) ? `${id}.keyword` : id; + return column; + }) + .reduce((s, column) => { + s[column.id] = { order: column.direction }; + return s; + }, {} as EsSorting); + + const { pageIndex, pageSize } = pagination; + const resp: SearchResponse7 = await ml.esSearch({ + index: jobConfig.dest.index, + body: { + query: searchQuery, + from: pageIndex * pageSize, + size: pageSize, + ...(Object.keys(sort).length > 0 ? { sort } : {}), + }, + }); + + setRowCount(resp.hits.total.value); + + const docs = resp.hits.hits; + + if (docs.length === 0) { + setTableItems([]); + setStatus(INDEX_STATUS.LOADED); + return; + } + + if (selectedFields.length === 0) { + const newSelectedFields = getDefaultSelectableFields(docs, resultsField); + setSelectedFields(newSelectedFields); + } + + // Create a version of the doc's source with flattened field names. + // This avoids confusion later on if a field name has dots in its name + // or is a nested fields when displaying it via EuiInMemoryTable. + const flattenedFields = getFlattenedFields(docs[0]._source, resultsField); + const transformedTableItems = docs.map(doc => { + const item: TableItem = {}; + flattenedFields.forEach(ff => { + item[ff] = getNestedProperty(doc._source, ff); + if (item[ff] === undefined) { + // If the attribute is undefined, it means it was not a nested property + // but had dots in its actual name. This selects the property by its + // full name and assigns it to `item[ff]`. + item[ff] = doc._source[`"${ff}"`]; + } + if (item[ff] === undefined) { + const parts = ff.split('.'); + if (parts[0] === resultsField && parts.length >= 2) { + parts.shift(); + if (doc._source[resultsField] !== undefined) { + item[ff] = doc._source[resultsField][parts.join('.')]; + } + } + } + }); + return item; + }); + + setTableFields(flattenedFields); + setTableItems(transformedTableItems); + setStatus(INDEX_STATUS.LOADED); + } catch (e) { + if (e.message !== undefined) { + setErrorMessage(e.message); + } else { + setErrorMessage(JSON.stringify(e)); + } + setTableItems([]); + setStatus(INDEX_STATUS.ERROR); + } + } + })(); + }, [jobConfig && jobConfig.id, pagination, searchQuery, selectedFields, sortingColumns]); + + return { + errorMessage, + indexPattern, + jobConfig, + pagination, + rowCount, + searchQuery, + selectedFields, + setJobConfig, + setPagination, + setSearchQuery, + setSelectedFields, + setSortingColumns, + sortingColumns, + status, + tableFields, + tableItems, + }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx index b00a38e2b5f65..efbebc1564bf9 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx @@ -22,7 +22,7 @@ import { import { NavigationMenu } from '../../../components/navigation_menu'; -import { Exploration } from './components/exploration'; +import { OutlierExploration } from './components/outlier_exploration'; import { RegressionExploration } from './components/regression_exploration'; import { ClassificationExploration } from './components/classification_exploration'; @@ -37,7 +37,7 @@ export const Page: FC<{ - + @@ -65,10 +65,10 @@ export const Page: FC<{ - + {analysisType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && ( - + )} {analysisType === ANALYSIS_CONFIG_TYPE.REGRESSION && ( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 129aa25c27e84..15d9bec1189c6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7527,16 +7527,9 @@ "xpack.ml.dataframe.analytics.create.trainingPercentLabel": "トレーニングパーセンテージ", "xpack.ml.dataframe.analytics.exploration.experimentalBadgeLabel": "実験的", "xpack.ml.dataframe.analytics.exploration.experimentalBadgeTooltipContent": "データフレーム分析は実験段階の機能です。フィードバックをお待ちしています。", - "xpack.ml.dataframe.analytics.exploration.fieldSelection": "{docFieldsCount, number} 件中 showing {selectedFieldsLength, number} 件の{docFieldsCount, plural, one {フィールド} other {フィールド}}", - "xpack.ml.dataframe.analytics.exploration.indexArrayBadgeContent": "配列", - "xpack.ml.dataframe.analytics.exploration.indexArrayToolTipContent": "この配列ベースの列の完全なコンテンツは表示できません。", "xpack.ml.dataframe.analytics.exploration.indexError": "インデックスデータの読み込み中にエラーが発生しました。", - "xpack.ml.dataframe.analytics.exploration.indexObjectBadgeContent": "オブジェクト", - "xpack.ml.dataframe.analytics.exploration.indexObjectToolTipContent": "このオブジェクトベースの列の完全なコンテンツは表示できません。", "xpack.ml.dataframe.analytics.exploration.jobIdTitle": "ジョブID {jobId}", "xpack.ml.dataframe.analytics.exploration.noDataCalloutBody": "インデックスのクエリが結果を返しませんでした。インデックスにドキュメントが含まれていて、クエリ要件が妥当であることを確認してください。", - "xpack.ml.dataframe.analytics.exploration.selectColumnsAriaLabel": "列を選択", - "xpack.ml.dataframe.analytics.exploration.selectFieldsPopoverTitle": "フィールドを選択", "xpack.ml.dataframe.analytics.exploration.title": "分析の探索", "xpack.ml.dataframe.analytics.regressionExploration.evaluateError": "データの読み込み中にエラーが発生しました。", "xpack.ml.dataframe.analytics.regressionExploration.generalError": "データの読み込み中にエラーが発生しました。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d0a354a2108d4..5037c883037b9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7527,16 +7527,9 @@ "xpack.ml.dataframe.analytics.create.trainingPercentLabel": "训练百分比", "xpack.ml.dataframe.analytics.exploration.experimentalBadgeLabel": "实验性", "xpack.ml.dataframe.analytics.exploration.experimentalBadgeTooltipContent": "数据帧分析为实验功能。我们很乐意听取您的反馈意见。", - "xpack.ml.dataframe.analytics.exploration.fieldSelection": "已选择 {selectedFieldsLength, number} 个{docFieldsCount, plural, one {字段} other {字段}},共 {docFieldsCount, number} 个", - "xpack.ml.dataframe.analytics.exploration.indexArrayBadgeContent": "数组", - "xpack.ml.dataframe.analytics.exploration.indexArrayToolTipContent": "无法显示此基于数组的列的完整内容。", "xpack.ml.dataframe.analytics.exploration.indexError": "加载索引数据时出错。", - "xpack.ml.dataframe.analytics.exploration.indexObjectBadgeContent": "对象", - "xpack.ml.dataframe.analytics.exploration.indexObjectToolTipContent": "无法显示此基于对象的列的完整内容。", "xpack.ml.dataframe.analytics.exploration.jobIdTitle": "作业 ID {jobId}", "xpack.ml.dataframe.analytics.exploration.noDataCalloutBody": "该索引的查询未返回结果。请确保索引包含文档且您的查询限制不过于严格。", - "xpack.ml.dataframe.analytics.exploration.selectColumnsAriaLabel": "选择列", - "xpack.ml.dataframe.analytics.exploration.selectFieldsPopoverTitle": "选择字段", "xpack.ml.dataframe.analytics.exploration.title": "分析浏览", "xpack.ml.dataframe.analytics.regressionExploration.evaluateError": "加载数据时出错。", "xpack.ml.dataframe.analytics.regressionExploration.generalError": "加载数据时出错。", From 6c3fa6bd43915f987737939b8c37450c3318cbe3 Mon Sep 17 00:00:00 2001 From: Katrin Freihofner Date: Wed, 25 Mar 2020 08:24:46 +0100 Subject: [PATCH 56/56] fix/uptime-alert-icon (#60750) Co-authored-by: Elastic Machine --- .../components/functional/alerts/toggle_alert_flyout_button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx index 99853a9f775ec..8093dd30604e4 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx @@ -48,7 +48,7 @@ export const ToggleAlertFlyoutButtonComponent = ({ setAlertFlyoutVisible }: Prop })} data-test-subj="xpack.uptime.toggleAlertFlyout" key="create-alert" - icon="alert" + icon="bell" onClick={() => setAlertFlyoutVisible(true)} >