From 1d451625b7f692cc14717c01e70bfa10230ae083 Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Tue, 25 Aug 2020 17:41:07 +0200 Subject: [PATCH 01/56] Improve wording on add agent flyout. --- .../agent_enrollment_flyout/managed_instructions.tsx | 6 +++--- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx index b02893057c9c3..61e17aa670492 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx @@ -72,13 +72,13 @@ export const ManagedInstructions: React.FunctionComponent = ({ agentPolic <> ), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 118362f494b47..2b38f200fa53e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9190,7 +9190,6 @@ "xpack.ingestManager.agentEnrollment.enrollStandaloneTabLabel": "スタンドアロンモード", "xpack.ingestManager.agentEnrollment.fleetNotInitializedText": "エージェントを登録する前に、フリートを設定する必要があります。{link}", "xpack.ingestManager.agentEnrollment.flyoutTitle": "エージェントの追加", - "xpack.ingestManager.agentEnrollment.goToFleetButton": "フリートに移動します。", "xpack.ingestManager.agentEnrollment.managedDescription": "必要なエージェントの数に関係なく、Fleetでは、簡単に一元的に更新を管理し、エージェントにデプロイすることができます。次の手順に従い、Elasticエージェントをダウンロードし、Fleetに登録してください。", "xpack.ingestManager.agentEnrollment.standaloneDescription": "スタンドアロンモードで実行中のエージェントは、構成を変更したい場合には、手動で更新する必要があります。次の手順に従い、スタンドアロンモードでElasticエージェントをダウンロードし、セットアップしてください。", "xpack.ingestManager.agentEnrollment.stepCheckForDataDescription": "エージェントを起動した後、エージェントはデータの送信を開始します。Ingest Managerのデータセットページでは、このデータを確認できます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index de1f206118447..ff53fcee68cb9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9193,7 +9193,6 @@ "xpack.ingestManager.agentEnrollment.enrollStandaloneTabLabel": "独立模式", "xpack.ingestManager.agentEnrollment.fleetNotInitializedText": "注册代理前需要设置 Fleet。{link}", "xpack.ingestManager.agentEnrollment.flyoutTitle": "添加代理", - "xpack.ingestManager.agentEnrollment.goToFleetButton": "前往 Fleet。", "xpack.ingestManager.agentEnrollment.managedDescription": "无论是需要一个代理还是需要数以千计的代理,Fleet 允许您轻松地集中管理并部署代理的更新。按照下面的说明下载 Elastic 代理并将代理注册到 Fleet。", "xpack.ingestManager.agentEnrollment.standaloneDescription": "如果希望对以独立模式运行的代理进行配置更改,则需要手动更新。按照下面的说明下载并设置独立模式的 Elastic 代理。", "xpack.ingestManager.agentEnrollment.stepCheckForDataDescription": "启动代理后,代理应开始发送数据。您可以在采集管理器的数据集页面查看此数据。", From c634208e4f2426b9762b46d88073121529df23d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Tue, 25 Aug 2020 18:59:47 +0200 Subject: [PATCH 02/56] [ILM] TS conversion of Edit policy page (#75148) * [ILM] TS conversion of Edit policy page * [ILM] Deleted some constants * [ILM] Fixed imports * [ILM] Fixed imports * [ILM] Clean up * [ILM] Clean up * [ILM] Fixed ui_metric jest test * [ILM] Fixed ui_metric jest test * [ILM] Fix review suggestions Co-authored-by: Elastic Machine --- .../edit_policy/constants.ts | 10 +- .../edit_policy/edit_policy.helpers.tsx | 2 - .../edit_policy/edit_policy.test.ts | 8 +- .../__jest__/components/edit_policy.test.js | 28 +- .../public/application/app.tsx | 2 +- .../public/application/constants/index.ts | 99 +---- .../public/application/constants/policy.ts | 60 +++ .../edit_policy/components/form_errors.tsx | 12 +- .../edit_policy/components/min_age_input.tsx | 58 ++- .../components/node_allocation.tsx | 41 +- .../components/policy_json_flyout.tsx | 15 +- .../components/set_priority_input.tsx | 31 +- .../edit_policy/edit_policy.container.js | 58 --- .../edit_policy/edit_policy.container.tsx | 83 ++++ .../sections/edit_policy/edit_policy.js | 390 ------------------ .../sections/edit_policy/edit_policy.tsx | 383 +++++++++++++++++ .../sections/edit_policy/index.d.ts | 7 - .../edit_policy/{index.js => index.ts} | 0 .../edit_policy/phases/cold_phase.tsx | 67 ++- .../edit_policy/phases/delete_phase.tsx | 34 +- .../sections/edit_policy/phases/hot_phase.tsx | 262 ++++++------ .../edit_policy/phases/warm_phase.tsx | 138 +++---- .../components/policy_table/policy_table.js | 2 +- .../public/application/services/api.ts | 13 +- .../application/services/find_errors.js | 24 -- .../services/policies/cold_phase.ts | 159 +++++++ .../services/policies/delete_phase.ts | 88 ++++ .../services/policies/hot_phase.ts | 155 +++++++ .../policies/policy_save.ts} | 30 +- .../services/policies/policy_serialization.ts | 104 +++++ .../services/policies/policy_validation.ts | 191 +++++++++ .../application/services/policies/types.ts | 140 +++++++ .../services/policies/warm_phase.ts | 219 ++++++++++ .../{ui_metric.test.js => ui_metric.test.ts} | 19 +- .../public/application/services/ui_metric.ts | 64 +-- .../application/store/actions/general.js | 11 - .../public/application/store/actions/index.js | 3 - .../public/application/store/actions/nodes.js | 8 - .../application/store/actions/policies.js | 7 - .../application/store/defaults/cold_phase.js | 30 -- .../store/defaults/delete_phase.js | 23 -- .../application/store/defaults/hot_phase.js | 35 -- .../application/store/defaults/index.d.ts | 10 - .../application/store/defaults/index.js | 10 - .../application/store/defaults/warm_phase.js | 39 -- .../application/store/reducers/general.js | 38 -- .../application/store/reducers/index.js | 4 - .../application/store/reducers/nodes.js | 50 --- .../application/store/reducers/policies.js | 97 ----- .../application/store/selectors/general.js | 9 - .../application/store/selectors/index.js | 3 - .../application/store/selectors/lifecycle.js | 287 ------------- .../application/store/selectors/nodes.js | 12 - .../application/store/selectors/policies.js | 291 +------------ 54 files changed, 1996 insertions(+), 1967 deletions(-) create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.js create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.js create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.d.ts rename x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/{index.js => index.ts} (100%) delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/find_errors.js create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/policies/hot_phase.ts rename x-pack/plugins/index_lifecycle_management/public/application/{store/actions/lifecycle.js => services/policies/policy_save.ts} (58%) create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts rename x-pack/plugins/index_lifecycle_management/public/application/services/{ui_metric.test.js => ui_metric.test.ts} (75%) delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/actions/general.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/actions/nodes.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/defaults/cold_phase.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/defaults/delete_phase.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/defaults/hot_phase.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.d.ts delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/defaults/warm_phase.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/reducers/general.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/reducers/nodes.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/selectors/general.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js delete mode 100644 x-pack/plugins/index_lifecycle_management/public/application/store/selectors/nodes.js diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts index e5037a6477aca..acf642f250a7b 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts @@ -4,21 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PolicyFromES } from '../../../public/application/services/policies/types'; + export const POLICY_NAME = 'my_policy'; export const SNAPSHOT_POLICY_NAME = 'my_snapshot_policy'; export const NEW_SNAPSHOT_POLICY_NAME = 'my_new_snapshot_policy'; -export const DELETE_PHASE_POLICY = { +export const DELETE_PHASE_POLICY: PolicyFromES = { version: 1, - modified_date: Date.now(), + modified_date: Date.now().toString(), policy: { phases: { hot: { min_age: '0ms', actions: { - set_priority: { - priority: null, - }, rollover: { max_size: '50gb', }, @@ -36,6 +35,7 @@ export const DELETE_PHASE_POLICY = { }, }, }, + name: POLICY_NAME, }, name: POLICY_NAME, }; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index ebe1c12e2a079..6365bb8caa963 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -13,7 +13,6 @@ import { POLICY_NAME } from './constants'; import { TestSubjects } from '../helpers'; import { EditPolicy } from '../../../public/application/sections/edit_policy'; -import { indexLifecycleManagementStore } from '../../../public/application/store'; jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -35,7 +34,6 @@ jest.mock('@elastic/eui', () => { }); const testBedConfig: TestBedConfig = { - store: () => indexLifecycleManagementStore(), memoryRouter: { initialEntries: [`/policies/edit/${POLICY_NAME}`], componentRoutePath: `/policies/edit/:policyName`, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 06829e6ef6f1e..36feb3f6203c8 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -40,8 +40,8 @@ describe('', () => { test('wait for snapshot policy field should correctly display snapshot policy name', () => { expect(testBed.find('snapshotPolicyCombobox').prop('data-currentvalue')).toEqual([ { - label: DELETE_PHASE_POLICY.policy.phases.delete.actions.wait_for_snapshot.policy, - value: DELETE_PHASE_POLICY.policy.phases.delete.actions.wait_for_snapshot.policy, + label: DELETE_PHASE_POLICY.policy.phases.delete?.actions.wait_for_snapshot?.policy, + value: DELETE_PHASE_POLICY.policy.phases.delete?.actions.wait_for_snapshot?.policy, }, ]); }); @@ -59,7 +59,7 @@ describe('', () => { delete: { ...DELETE_PHASE_POLICY.policy.phases.delete, actions: { - ...DELETE_PHASE_POLICY.policy.phases.delete.actions, + ...DELETE_PHASE_POLICY.policy.phases.delete?.actions, wait_for_snapshot: { policy: NEW_SNAPSHOT_POLICY_NAME, }, @@ -96,7 +96,7 @@ describe('', () => { delete: { ...DELETE_PHASE_POLICY.policy.phases.delete, actions: { - ...DELETE_PHASE_POLICY.policy.phases.delete.actions, + ...DELETE_PHASE_POLICY.policy.phases.delete?.actions, }, }, }, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js index 4fe3d5c66696e..81c30579cd4dd 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js @@ -7,7 +7,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import moment from 'moment-timezone'; -import { Provider } from 'react-redux'; // axios has a $http like interface so using it to simulate $http import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; @@ -21,9 +20,7 @@ import { import { usageCollectionPluginMock } from '../../../../../src/plugins/usage_collection/public/mocks'; import { mountWithIntl } from '../../../../test_utils/enzyme_helpers'; -import { fetchedPolicies } from '../../public/application/store/actions'; -import { indexLifecycleManagementStore } from '../../public/application/store'; -import { EditPolicy } from '../../public/application/sections/edit_policy'; +import { EditPolicy } from '../../public/application/sections/edit_policy/edit_policy'; import { init as initHttp } from '../../public/application/services/http'; import { init as initUiMetric } from '../../public/application/services/ui_metric'; import { init as initNotification } from '../../public/application/services/notification'; @@ -40,7 +37,7 @@ import { policyNameMustBeDifferentErrorMessage, policyNameAlreadyUsedErrorMessage, maximumDocumentsRequiredMessage, -} from '../../public/application/store/selectors'; +} from '../../public/application/services/policies/policy_validation'; initHttp(axios.create({ adapter: axiosXhrAdapter })); initUiMetric(usageCollectionPluginMock.createSetupContract()); @@ -51,7 +48,6 @@ initNotification( let server; let httpRequestsMockHelpers; -let store; const policy = { phases: { hot: { @@ -128,13 +124,14 @@ const save = (rendered) => { }; describe('edit policy', () => { beforeEach(() => { - store = indexLifecycleManagementStore(); component = ( - - {} }} getUrlForApp={() => {}} /> - + {} }} + getUrlForApp={() => {}} + policies={policies} + policyName={''} + /> ); - store.dispatch(fetchedPolicies(policies)); ({ server, httpRequestsMockHelpers } = initHttpRequests()); httpRequestsMockHelpers.setPoliciesResponse(policies); @@ -162,9 +159,12 @@ describe('edit policy', () => { }); test('should show error when trying to save as new policy but using the same name', () => { component = ( - - {}} /> - + {}} + history={{ push: () => {} }} + /> ); const rendered = mountWithIntl(component); findTestSubject(rendered, 'saveAsNewSwitch').simulate('click'); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/app.tsx b/x-pack/plugins/index_lifecycle_management/public/application/app.tsx index 14b0e72317c66..f7f8b30324bca 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/app.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/app.tsx @@ -9,7 +9,7 @@ import { Router, Switch, Route, Redirect } from 'react-router-dom'; import { ScopedHistory, ApplicationStart } from 'kibana/public'; import { METRIC_TYPE } from '@kbn/analytics'; -import { UIM_APP_LOAD } from './constants'; +import { UIM_APP_LOAD } from './constants/ui_metric'; import { EditPolicy } from './sections/edit_policy'; import { PolicyTable } from './sections/policy_table'; import { trackUiMetric } from './services/ui_metric'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/constants/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/constants/index.ts index 6319fc0d68543..61c197f2ba149 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/constants/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/constants/index.ts @@ -4,102 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './policy'; export * from './ui_metric'; - -export const SET_PHASE_DATA: string = 'SET_PHASE_DATA'; -export const SET_SELECTED_NODE_ATTRS: string = 'SET_SELECTED_NODE_ATTRS'; -export const PHASE_HOT: string = 'hot'; -export const PHASE_WARM: string = 'warm'; -export const PHASE_COLD: string = 'cold'; -export const PHASE_DELETE: string = 'delete'; - -export const PHASE_ENABLED: string = 'phaseEnabled'; - -export const PHASE_ROLLOVER_ENABLED: string = 'rolloverEnabled'; -export const WARM_PHASE_ON_ROLLOVER: string = 'warmPhaseOnRollover'; -export const PHASE_ROLLOVER_ALIAS: string = 'selectedAlias'; -export const PHASE_ROLLOVER_MAX_AGE: string = 'selectedMaxAge'; -export const PHASE_ROLLOVER_MAX_AGE_UNITS: string = 'selectedMaxAgeUnits'; -export const PHASE_ROLLOVER_MAX_SIZE_STORED: string = 'selectedMaxSizeStored'; -export const PHASE_ROLLOVER_MAX_DOCUMENTS: string = 'selectedMaxDocuments'; -export const PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS: string = 'selectedMaxSizeStoredUnits'; -export const PHASE_ROLLOVER_MINIMUM_AGE: string = 'selectedMinimumAge'; -export const PHASE_ROLLOVER_MINIMUM_AGE_UNITS: string = 'selectedMinimumAgeUnits'; - -export const PHASE_FORCE_MERGE_SEGMENTS: string = 'selectedForceMergeSegments'; -export const PHASE_FORCE_MERGE_ENABLED: string = 'forceMergeEnabled'; -export const PHASE_FREEZE_ENABLED: string = 'freezeEnabled'; - -export const PHASE_SHRINK_ENABLED: string = 'shrinkEnabled'; - -export const PHASE_NODE_ATTRS: string = 'selectedNodeAttrs'; -export const PHASE_PRIMARY_SHARD_COUNT: string = 'selectedPrimaryShardCount'; -export const PHASE_REPLICA_COUNT: string = 'selectedReplicaCount'; -export const PHASE_INDEX_PRIORITY: string = 'phaseIndexPriority'; - -export const PHASE_WAIT_FOR_SNAPSHOT_POLICY = 'waitForSnapshotPolicy'; - -export const PHASE_ATTRIBUTES_THAT_ARE_NUMBERS_VALIDATE: string[] = [ - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_FORCE_MERGE_SEGMENTS, - PHASE_PRIMARY_SHARD_COUNT, - PHASE_REPLICA_COUNT, - PHASE_INDEX_PRIORITY, -]; -export const PHASE_ATTRIBUTES_THAT_ARE_NUMBERS: string[] = [ - ...PHASE_ATTRIBUTES_THAT_ARE_NUMBERS_VALIDATE, - PHASE_ROLLOVER_MAX_AGE, - PHASE_ROLLOVER_MAX_SIZE_STORED, - PHASE_ROLLOVER_MAX_DOCUMENTS, -]; - -export const STRUCTURE_INDEX_TEMPLATE: string = 'indexTemplate'; -export const STRUCTURE_TEMPLATE_SELECTION: string = 'templateSelection'; -export const STRUCTURE_TEMPLATE_NAME: string = 'templateName'; -export const STRUCTURE_CONFIGURATION: string = 'configuration'; -export const STRUCTURE_NODE_ATTRS: string = 'node_attrs'; -export const STRUCTURE_PRIMARY_NODES: string = 'primary_nodes'; -export const STRUCTURE_REPLICAS: string = 'replicas'; - -export const STRUCTURE_POLICY_CONFIGURATION: string = 'policyConfiguration'; - -export const STRUCTURE_REVIEW: string = 'review'; -export const STRUCTURE_POLICY_NAME: string = 'policyName'; -export const STRUCTURE_INDEX_NAME: string = 'indexName'; -export const STRUCTURE_ALIAS_NAME: string = 'aliasName'; - -export const ERROR_STRUCTURE: any = { - [PHASE_HOT]: { - [PHASE_ROLLOVER_ALIAS]: [], - [PHASE_ROLLOVER_MAX_AGE]: [], - [PHASE_ROLLOVER_MAX_AGE_UNITS]: [], - [PHASE_ROLLOVER_MAX_SIZE_STORED]: [], - [PHASE_ROLLOVER_MAX_DOCUMENTS]: [], - [PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS]: [], - [PHASE_INDEX_PRIORITY]: [], - }, - [PHASE_WARM]: { - [PHASE_ROLLOVER_ALIAS]: [], - [PHASE_ROLLOVER_MINIMUM_AGE]: [], - [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: [], - [PHASE_NODE_ATTRS]: [], - [PHASE_PRIMARY_SHARD_COUNT]: [], - [PHASE_REPLICA_COUNT]: [], - [PHASE_FORCE_MERGE_SEGMENTS]: [], - [PHASE_INDEX_PRIORITY]: [], - }, - [PHASE_COLD]: { - [PHASE_ROLLOVER_ALIAS]: [], - [PHASE_ROLLOVER_MINIMUM_AGE]: [], - [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: [], - [PHASE_NODE_ATTRS]: [], - [PHASE_REPLICA_COUNT]: [], - [PHASE_INDEX_PRIORITY]: [], - }, - [PHASE_DELETE]: { - [PHASE_ROLLOVER_ALIAS]: [], - [PHASE_ROLLOVER_MINIMUM_AGE]: [], - [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: [], - }, - [STRUCTURE_POLICY_NAME]: [], -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts new file mode 100644 index 0000000000000..3a19f03547b5b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts @@ -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 { + SerializedPhase, + ColdPhase, + DeletePhase, + HotPhase, + WarmPhase, +} from '../services/policies/types'; + +export const defaultNewHotPhase: HotPhase = { + phaseEnabled: true, + rolloverEnabled: true, + selectedMaxAge: '30', + selectedMaxAgeUnits: 'd', + selectedMaxSizeStored: '50', + selectedMaxSizeStoredUnits: 'gb', + phaseIndexPriority: '100', + selectedMaxDocuments: '', +}; + +export const defaultNewWarmPhase: WarmPhase = { + phaseEnabled: false, + forceMergeEnabled: false, + selectedForceMergeSegments: '', + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + selectedNodeAttrs: '', + shrinkEnabled: false, + selectedPrimaryShardCount: '', + selectedReplicaCount: '', + warmPhaseOnRollover: true, + phaseIndexPriority: '50', +}; + +export const defaultNewColdPhase: ColdPhase = { + phaseEnabled: false, + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + selectedNodeAttrs: '', + selectedReplicaCount: '', + freezeEnabled: false, + phaseIndexPriority: '0', +}; + +export const defaultNewDeletePhase: DeletePhase = { + phaseEnabled: false, + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + waitForSnapshotPolicy: '', +}; + +export const serializedPhaseInitialization: SerializedPhase = { + min_age: '0ms', + actions: {}, +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors.tsx index a3278b6c231b9..9db40ebf5521f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/form_errors.tsx @@ -8,28 +8,22 @@ import React, { cloneElement, Children, Fragment, ReactElement } from 'react'; import { EuiFormRow, EuiFormRowProps } from '@elastic/eui'; type Props = EuiFormRowProps & { - errorKey: string; isShowingErrors: boolean; - errors: Record; + errors?: string[]; }; export const ErrableFormRow: React.FunctionComponent = ({ - errorKey, isShowingErrors, errors, children, ...rest }) => { return ( - 0} - error={errors[errorKey]} - {...rest} - > + 0} error={errors} {...rest}> {Children.map(children, (child) => cloneElement(child as ReactElement, { - isInvalid: isShowingErrors && errors[errorKey].length > 0, + isInvalid: errors && isShowingErrors && errors.length > 0, }) )} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx index c9732f2311758..11b743ecc4bb6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx @@ -9,40 +9,35 @@ import { i18n } from '@kbn/i18n'; import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; -import { - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_ROLLOVER_MINIMUM_AGE_UNITS, - PHASE_WARM, - PHASE_COLD, - PHASE_DELETE, -} from '../../../constants'; import { LearnMoreLink } from './learn_more_link'; import { ErrableFormRow } from './form_errors'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; +import { ColdPhase, DeletePhase, Phase, Phases, WarmPhase } from '../../../services/policies/types'; -function getTimingLabelForPhase(phase: string) { +function getTimingLabelForPhase(phase: keyof Phases) { // NOTE: Hot phase isn't necessary, because indices begin in the hot phase. switch (phase) { - case PHASE_WARM: + case 'warm': return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeLabel', { defaultMessage: 'Timing for warm phase', }); - case PHASE_COLD: + case 'cold': return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeLabel', { defaultMessage: 'Timing for cold phase', }); - case PHASE_DELETE: + case 'delete': return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeLabel', { defaultMessage: 'Timing for delete phase', }); } } -function getUnitsAriaLabelForPhase(phase: string) { +function getUnitsAriaLabelForPhase(phase: keyof Phases) { // NOTE: Hot phase isn't necessary, because indices begin in the hot phase. switch (phase) { - case PHASE_WARM: + case 'warm': return i18n.translate( 'xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeUnitsAriaLabel', { @@ -50,7 +45,7 @@ function getUnitsAriaLabelForPhase(phase: string) { } ); - case PHASE_COLD: + case 'cold': return i18n.translate( 'xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeUnitsAriaLabel', { @@ -58,7 +53,7 @@ function getUnitsAriaLabelForPhase(phase: string) { } ); - case PHASE_DELETE: + case 'delete': return i18n.translate( 'xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeUnitsAriaLabel', { @@ -68,24 +63,23 @@ function getUnitsAriaLabelForPhase(phase: string) { } } -interface Props { +interface Props { rolloverEnabled: boolean; - errors: Record; - phase: string; - // TODO add types for phaseData and setPhaseData after policy is typed - phaseData: any; - setPhaseData: (dataKey: string, value: any) => void; + errors?: PhaseValidationErrors; + phase: keyof Phases & string; + phaseData: T; + setPhaseData: (dataKey: keyof T & string, value: string) => void; isShowingErrors: boolean; } -export const MinAgeInput: React.FunctionComponent = ({ +export const MinAgeInput = ({ rolloverEnabled, errors, phaseData, phase, setPhaseData, isShowingErrors, -}) => { +}: React.PropsWithChildren>): React.ReactElement => { let daysOptionLabel; let hoursOptionLabel; let minutesOptionLabel; @@ -192,15 +186,17 @@ export const MinAgeInput: React.FunctionComponent = ({ ); } + // check that these strings are valid properties + const selectedMinimumAgeProperty = propertyof('selectedMinimumAge'); + const selectedMinimumAgeUnitsProperty = propertyof('selectedMinimumAgeUnits'); return ( = ({ } > { - setPhaseData(PHASE_ROLLOVER_MINIMUM_AGE, e.target.value); + setPhaseData(selectedMinimumAgeProperty, e.target.value); }} min={0} /> @@ -227,8 +223,8 @@ export const MinAgeInput: React.FunctionComponent = ({ setPhaseData(PHASE_ROLLOVER_MINIMUM_AGE_UNITS, e.target.value)} + value={phaseData.selectedMinimumAgeUnits} + onChange={(e) => setPhaseData(selectedMinimumAgeUnitsProperty, e.target.value)} options={[ { value: 'd', diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx index 576483a5ab9c2..0ce2c0d7ea566 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx @@ -16,20 +16,12 @@ import { EuiButton, } from '@elastic/eui'; -import { PHASE_NODE_ATTRS } from '../../../constants'; import { LearnMoreLink } from './learn_more_link'; import { ErrableFormRow } from './form_errors'; import { useLoadNodes } from '../../../services/api'; import { NodeAttrsDetails } from './node_attrs_details'; - -interface Props { - phase: string; - errors: Record; - // TODO add types for phaseData and setPhaseData after policy is typed - phaseData: any; - setPhaseData: (dataKey: string, value: any) => void; - isShowingErrors: boolean; -} +import { ColdPhase, Phase, Phases, WarmPhase } from '../../../services/policies/types'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; const learnMoreLink = ( @@ -46,13 +38,20 @@ const learnMoreLink = ( ); -export const NodeAllocation: React.FunctionComponent = ({ +interface Props { + phase: keyof Phases & string; + errors?: PhaseValidationErrors; + phaseData: T; + setPhaseData: (dataKey: keyof T & string, value: string) => void; + isShowingErrors: boolean; +} +export const NodeAllocation = ({ phase, setPhaseData, errors, phaseData, isShowingErrors, -}) => { +}: React.PropsWithChildren>) => { const { isLoading, data: nodes, error, sendRequest } = useLoadNodes(); const [selectedNodeAttrsForDetails, setSelectedNodeAttrsForDetails] = useState( @@ -140,33 +139,35 @@ export const NodeAllocation: React.FunctionComponent = ({ ); } + // check that this string is a valid property + const nodeAttrsProperty = propertyof('selectedNodeAttrs'); + return ( { - setPhaseData(PHASE_NODE_ATTRS, e.target.value); + setPhaseData(nodeAttrsProperty, e.target.value); }} /> - {!!phaseData[PHASE_NODE_ATTRS] ? ( + {!!phaseData.selectedNodeAttrs ? ( setSelectedNodeAttrsForDetails(phaseData[PHASE_NODE_ATTRS])} + onClick={() => setSelectedNodeAttrsForDetails(phaseData.selectedNodeAttrs)} > void; - // TODO add types for lifecycle after policy is typed - lifecycle: any; + policy: Policy; policyName: string; } -export const PolicyJsonFlyout: React.FunctionComponent = ({ - close, - lifecycle, - policyName, -}) => { - // @ts-ignore until store is typed - const getEsJson = ({ phases }) => { +export const PolicyJsonFlyout: React.FunctionComponent = ({ close, policy, policyName }) => { + const getEsJson = ({ phases }: Policy) => { return JSON.stringify( { policy: { @@ -45,7 +40,7 @@ export const PolicyJsonFlyout: React.FunctionComponent = ({ }; const endpoint = `PUT _ilm/policy/${policyName || ''}`; - const request = `${endpoint}\n${getEsJson(lifecycle)}`; + const request = `${endpoint}\n${getEsJson(policy)}`; return ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.tsx index 0034de85fce17..1da7508049f24 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/set_priority_input.tsx @@ -7,27 +7,27 @@ import React, { Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFieldNumber, EuiTextColor, EuiDescribedFormGroup } from '@elastic/eui'; -import { PHASE_INDEX_PRIORITY } from '../../../constants'; - import { LearnMoreLink } from './'; import { OptionalLabel } from './'; import { ErrableFormRow } from './'; +import { ColdPhase, HotPhase, Phase, Phases, WarmPhase } from '../../../services/policies/types'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; -interface Props { - errors: Record; - // TODO add types for phaseData and setPhaseData after policy is typed - phase: string; - phaseData: any; - setPhaseData: (dataKey: string, value: any) => void; +interface Props { + errors?: PhaseValidationErrors; + phase: keyof Phases & string; + phaseData: T; + setPhaseData: (dataKey: keyof T & string, value: any) => void; isShowingErrors: boolean; } -export const SetPriorityInput: React.FunctionComponent = ({ +export const SetPriorityInput = ({ errors, phaseData, phase, setPhaseData, isShowingErrors, -}) => { +}: React.PropsWithChildren>) => { + const phaseIndexPriorityProperty = propertyof('phaseIndexPriority'); return ( = ({ fullWidth > = ({ } - errorKey={PHASE_INDEX_PRIORITY} isShowingErrors={isShowingErrors} - errors={errors} + errors={errors?.phaseIndexPriority} > { - setPhaseData(PHASE_INDEX_PRIORITY, e.target.value); + setPhaseData(phaseIndexPriorityProperty, e.target.value); }} min={0} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.js deleted file mode 100644 index e7f20a66d09f0..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.js +++ /dev/null @@ -1,58 +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 { connect } from 'react-redux'; - -import { - getSaveAsNewPolicy, - getSelectedPolicy, - validateLifecycle, - getLifecycle, - getPolicies, - isPolicyListLoaded, - getIsNewPolicy, - getSelectedOriginalPolicyName, - getPhases, -} from '../../store/selectors'; - -import { - setSelectedPolicy, - setSelectedPolicyName, - setSaveAsNewPolicy, - saveLifecyclePolicy, - fetchPolicies, - setPhaseData, -} from '../../store/actions'; - -import { findFirstError } from '../../services/find_errors'; -import { EditPolicy as PresentationComponent } from './edit_policy'; - -export const EditPolicy = connect( - (state) => { - const errors = validateLifecycle(state); - const firstError = findFirstError(errors); - return { - firstError, - errors, - selectedPolicy: getSelectedPolicy(state), - saveAsNewPolicy: getSaveAsNewPolicy(state), - lifecycle: getLifecycle(state), - policies: getPolicies(state), - isPolicyListLoaded: isPolicyListLoaded(state), - isNewPolicy: getIsNewPolicy(state), - originalPolicyName: getSelectedOriginalPolicyName(state), - phases: getPhases(state), - }; - }, - { - setSelectedPolicy, - setSelectedPolicyName, - setSaveAsNewPolicy, - saveLifecyclePolicy, - fetchPolicies, - setPhaseData, - } -)(PresentationComponent); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx new file mode 100644 index 0000000000000..359134e015f7f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx @@ -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 React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { EuiButton, EuiCallOut, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useLoadPoliciesList } from '../../services/api'; + +import { EditPolicy as PresentationComponent } from './edit_policy'; + +interface RouterProps { + policyName: string; +} + +interface Props { + getUrlForApp: ( + appId: string, + options?: { + path?: string; + absolute?: boolean; + } + ) => string; +} + +export const EditPolicy: React.FunctionComponent> = ({ + match: { + params: { policyName }, + }, + getUrlForApp, + history, +}) => { + const { error, isLoading, data: policies, sendRequest } = useLoadPoliciesList(false); + if (isLoading) { + return ( + } + body={ + + } + /> + ); + } + if (error || !policies) { + const { statusCode, message } = error ? error : { statusCode: '', message: '' }; + return ( + + } + color="danger" + > +

+ {message} ({statusCode}) +

+ + + +
+ ); + } + + return ( + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.js deleted file mode 100644 index a29ecd07c5e45..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.js +++ /dev/null @@ -1,390 +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, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - -import { - EuiPage, - EuiPageBody, - EuiFieldText, - EuiPageContent, - EuiFormRow, - EuiTitle, - EuiText, - EuiSpacer, - EuiSwitch, - EuiHorizontalRule, - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiDescribedFormGroup, -} from '@elastic/eui'; - -import { - PHASE_HOT, - PHASE_COLD, - PHASE_DELETE, - PHASE_WARM, - STRUCTURE_POLICY_NAME, - WARM_PHASE_ON_ROLLOVER, - PHASE_ROLLOVER_ENABLED, -} from '../../constants'; - -import { toasts } from '../../services/notification'; -import { findFirstError } from '../../services/find_errors'; -import { LearnMoreLink, PolicyJsonFlyout, ErrableFormRow } from './components'; - -import { HotPhase, WarmPhase, ColdPhase, DeletePhase } from './phases'; - -export class EditPolicy extends Component { - static propTypes = { - selectedPolicy: PropTypes.object.isRequired, - errors: PropTypes.object.isRequired, - }; - - constructor(props) { - super(props); - this.state = { - isShowingErrors: false, - isShowingPolicyJsonFlyout: false, - }; - } - - selectPolicy = (policyName) => { - const { setSelectedPolicy, policies } = this.props; - - const selectedPolicy = policies.find((policy) => { - return policy.name === policyName; - }); - - if (selectedPolicy) { - setSelectedPolicy(selectedPolicy); - } - }; - - componentDidMount() { - window.scrollTo(0, 0); - - const { - isPolicyListLoaded, - fetchPolicies, - match: { params: { policyName } } = { params: {} }, - } = this.props; - - if (policyName) { - const decodedPolicyName = decodeURIComponent(policyName); - if (isPolicyListLoaded) { - this.selectPolicy(decodedPolicyName); - } else { - fetchPolicies(true, () => { - this.selectPolicy(decodedPolicyName); - }); - } - } else { - this.props.setSelectedPolicy(null); - } - } - - backToPolicyList = () => { - this.props.setSelectedPolicy(null); - this.props.history.push('/policies'); - }; - - submit = async () => { - this.setState({ isShowingErrors: true }); - const { saveLifecyclePolicy, lifecycle, saveAsNewPolicy, firstError } = this.props; - if (firstError) { - toasts.addDanger( - i18n.translate('xpack.indexLifecycleMgmt.editPolicy.formErrorsMessage', { - defaultMessage: 'Please fix the errors on this page.', - }) - ); - const errorRowId = `${firstError.replace('.', '-')}-row`; - const element = document.getElementById(errorRowId); - if (element) { - element.scrollIntoView({ block: 'center', inline: 'nearest' }); - } - } else { - const success = await saveLifecyclePolicy(lifecycle, saveAsNewPolicy); - if (success) { - this.backToPolicyList(); - } - } - }; - - togglePolicyJsonFlyout = () => { - this.setState(({ isShowingPolicyJsonFlyout }) => ({ - isShowingPolicyJsonFlyout: !isShowingPolicyJsonFlyout, - })); - }; - - render() { - const { - selectedPolicy, - errors, - setSaveAsNewPolicy, - saveAsNewPolicy, - setSelectedPolicyName, - isNewPolicy, - lifecycle, - originalPolicyName, - phases, - setPhaseData, - } = this.props; - const selectedPolicyName = selectedPolicy.name; - const { isShowingErrors, isShowingPolicyJsonFlyout } = this.state; - - return ( - - - - -

- {isNewPolicy - ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { - defaultMessage: 'Create an index lifecycle policy', - }) - : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { - defaultMessage: 'Edit index lifecycle policy {originalPolicyName}', - values: { originalPolicyName }, - })} -

-
- -
- - -

- {' '} - - } - /> -

-
- - - - - {isNewPolicy ? null : ( - - - -

- - - - .{' '} - -

-
- -
- - - { - await setSaveAsNewPolicy(e.target.checked); - }} - label={ - - - - } - /> - -
- )} - - {saveAsNewPolicy || isNewPolicy ? ( - - - - -
- } - titleSize="s" - fullWidth - > - - } - > - { - await setSelectedPolicyName(e.target.value); - }} - /> - - - ) : null} - - - - - setPhaseData(PHASE_HOT, key, value)} - phaseData={phases[PHASE_HOT]} - setWarmPhaseOnRollover={(value) => - setPhaseData(PHASE_WARM, WARM_PHASE_ON_ROLLOVER, value) - } - /> - - - - setPhaseData(PHASE_WARM, key, value)} - phaseData={phases[PHASE_WARM]} - hotPhaseRolloverEnabled={phases[PHASE_HOT][PHASE_ROLLOVER_ENABLED]} - /> - - - - setPhaseData(PHASE_COLD, key, value)} - phaseData={phases[PHASE_COLD]} - hotPhaseRolloverEnabled={phases[PHASE_HOT][PHASE_ROLLOVER_ENABLED]} - /> - - - - setPhaseData(PHASE_DELETE, key, value)} - phaseData={phases[PHASE_DELETE]} - hotPhaseRolloverEnabled={phases[PHASE_HOT][PHASE_ROLLOVER_ENABLED]} - /> - - - - - - - - - {saveAsNewPolicy ? ( - - ) : ( - - )} - - - - - - - - - - - - - - {isShowingPolicyJsonFlyout ? ( - - ) : ( - - )} - - - - - {this.state.isShowingPolicyJsonFlyout ? ( - this.setState({ isShowingPolicyJsonFlyout: false })} - /> - ) : null} - -
-
-
- ); - } -} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx new file mode 100644 index 0000000000000..6cffde577b35e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -0,0 +1,383 @@ +/* + * 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, useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { + EuiButton, + EuiButtonEmpty, + EuiDescribedFormGroup, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiHorizontalRule, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { toasts } from '../../services/notification'; + +import { Policy, PolicyFromES } from '../../services/policies/types'; +import { + validatePolicy, + ValidationErrors, + findFirstError, +} from '../../services/policies/policy_validation'; +import { savePolicy } from '../../services/policies/policy_save'; +import { + deserializePolicy, + getPolicyByName, + initializeNewPolicy, +} from '../../services/policies/policy_serialization'; + +import { ErrableFormRow, LearnMoreLink, PolicyJsonFlyout } from './components'; +import { ColdPhase, DeletePhase, HotPhase, WarmPhase } from './phases'; + +interface Props { + policies: PolicyFromES[]; + policyName: string; + getUrlForApp: ( + appId: string, + options?: { + path?: string; + absolute?: boolean; + } + ) => string; + history: any; +} +export const EditPolicy: React.FunctionComponent = ({ + policies, + policyName, + history, + getUrlForApp, +}) => { + useEffect(() => { + window.scrollTo(0, 0); + }, []); + + const [isShowingErrors, setIsShowingErrors] = useState(false); + const [errors, setErrors] = useState(); + const [isShowingPolicyJsonFlyout, setIsShowingPolicyJsonFlyout] = useState(false); + + const existingPolicy = getPolicyByName(policies, policyName); + + const [policy, setPolicy] = useState( + existingPolicy ? deserializePolicy(existingPolicy) : initializeNewPolicy(policyName) + ); + + const isNewPolicy: boolean = !Boolean(existingPolicy); + const [saveAsNew, setSaveAsNew] = useState(isNewPolicy); + const originalPolicyName: string = existingPolicy ? existingPolicy.name : ''; + + const backToPolicyList = () => { + history.push('/policies'); + }; + + const submit = async () => { + setIsShowingErrors(true); + const [isValid, validationErrors] = validatePolicy( + saveAsNew, + policy, + policies, + originalPolicyName + ); + setErrors(validationErrors); + + if (!isValid) { + toasts.addDanger( + i18n.translate('xpack.indexLifecycleMgmt.editPolicy.formErrorsMessage', { + defaultMessage: 'Please fix the errors on this page.', + }) + ); + const firstError = findFirstError(validationErrors); + const errorRowId = `${firstError ? firstError.replace('.', '-') : ''}-row`; + const element = document.getElementById(errorRowId); + if (element) { + element.scrollIntoView({ block: 'center', inline: 'nearest' }); + } + } else { + const success = await savePolicy(policy, isNewPolicy || saveAsNew, existingPolicy); + if (success) { + backToPolicyList(); + } + } + }; + + const togglePolicyJsonFlyout = () => { + setIsShowingPolicyJsonFlyout(!isShowingPolicyJsonFlyout); + }; + + const setPhaseData = (phase: 'hot' | 'warm' | 'cold' | 'delete', key: string, value: any) => { + setPolicy({ + ...policy, + phases: { + ...policy.phases, + [phase]: { ...policy.phases[phase], [key]: value }, + }, + }); + }; + + const setWarmPhaseOnRollover = (value: boolean) => { + setPolicy({ + ...policy, + phases: { + ...policy.phases, + hot: { + ...policy.phases.hot, + rolloverEnabled: value, + }, + warm: { + ...policy.phases.warm, + warmPhaseOnRollover: value, + }, + }, + }); + }; + + return ( + + + + +

+ {isNewPolicy + ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { + defaultMessage: 'Create an index lifecycle policy', + }) + : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { + defaultMessage: 'Edit index lifecycle policy {originalPolicyName}', + values: { originalPolicyName }, + })} +

+
+ +
+ + +

+ {' '} + + } + /> +

+
+ + + + {isNewPolicy ? null : ( + + +

+ + + + .{' '} + +

+
+ + + + { + setSaveAsNew(e.target.checked); + }} + label={ + + + + } + /> + +
+ )} + + {saveAsNew || isNewPolicy ? ( + + + + +
+ } + titleSize="s" + fullWidth + > + + } + > + { + setPolicy({ ...policy, name: e.target.value }); + }} + /> + + + ) : null} + + + + 0} + setPhaseData={(key, value) => setPhaseData('hot', key, value)} + phaseData={policy.phases.hot} + setWarmPhaseOnRollover={setWarmPhaseOnRollover} + /> + + + + 0} + setPhaseData={(key, value) => setPhaseData('warm', key, value)} + phaseData={policy.phases.warm} + hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} + /> + + + + 0} + setPhaseData={(key, value) => setPhaseData('cold', key, value)} + phaseData={policy.phases.cold} + hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} + /> + + + + 0} + getUrlForApp={getUrlForApp} + setPhaseData={(key, value) => setPhaseData('delete', key, value)} + phaseData={policy.phases.delete} + hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} + /> + + + + + + + + + {saveAsNew ? ( + + ) : ( + + )} + + + + + + + + + + + + + + {isShowingPolicyJsonFlyout ? ( + + ) : ( + + )} + + + + + {isShowingPolicyJsonFlyout ? ( + setIsShowingPolicyJsonFlyout(false)} + /> + ) : null} + +
+
+
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.d.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.d.ts deleted file mode 100644 index 5f15d929a4916..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.d.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 declare const EditPolicy: any; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.js rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/index.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx index babbbf7638ebe..fb32752fe24ea 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx @@ -18,12 +18,9 @@ import { EuiTextColor, } from '@elastic/eui'; -import { - PHASE_COLD, - PHASE_ENABLED, - PHASE_REPLICA_COUNT, - PHASE_FREEZE_ENABLED, -} from '../../../constants'; +import { ColdPhase as ColdPhaseInterface, Phases } from '../../../services/policies/types'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; + import { LearnMoreLink, ActiveBadge, @@ -35,14 +32,21 @@ import { SetPriorityInput, } from '../components'; +const freezeLabel = i18n.translate('xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel', { + defaultMessage: 'Freeze index', +}); + +const coldProperty = propertyof('cold'); +const phaseProperty = (propertyName: keyof ColdPhaseInterface) => + propertyof(propertyName); + interface Props { - setPhaseData: (key: string, value: any) => void; - phaseData: any; + setPhaseData: (key: keyof ColdPhaseInterface & string, value: string | boolean) => void; + phaseData: ColdPhaseInterface; isShowingErrors: boolean; - errors: Record; + errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; } - export class ColdPhase extends PureComponent { render() { const { @@ -53,10 +57,6 @@ export class ColdPhase extends PureComponent { hotPhaseRolloverEnabled, } = this.props; - const freezeLabel = i18n.translate('xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel', { - defaultMessage: 'Freeze index', - }); - return (
{ defaultMessage="Cold phase" /> {' '} - {phaseData[PHASE_ENABLED] && !isShowingErrors ? : null} + {phaseData.phaseEnabled && !isShowingErrors ? : null}
} @@ -91,10 +91,10 @@ export class ColdPhase extends PureComponent { defaultMessage="Activate cold phase" /> } - id={`${PHASE_COLD}-${PHASE_ENABLED}`} - checked={phaseData[PHASE_ENABLED]} + id={`${coldProperty}-${phaseProperty('phaseEnabled')}`} + checked={phaseData.phaseEnabled} onChange={(e) => { - setPhaseData(PHASE_ENABLED, e.target.checked); + setPhaseData(phaseProperty('phaseEnabled'), e.target.checked); }} aria-controls="coldPhaseContent" /> @@ -103,20 +103,20 @@ export class ColdPhase extends PureComponent { fullWidth > - {phaseData[PHASE_ENABLED] ? ( + {phaseData.phaseEnabled ? ( - errors={errors} phaseData={phaseData} - phase={PHASE_COLD} + phase={coldProperty} isShowingErrors={isShowingErrors} setPhaseData={setPhaseData} rolloverEnabled={hotPhaseRolloverEnabled} /> - + phase={coldProperty} setPhaseData={setPhaseData} errors={errors} phaseData={phaseData} @@ -126,7 +126,7 @@ export class ColdPhase extends PureComponent { { } - errorKey={PHASE_REPLICA_COUNT} isShowingErrors={isShowingErrors} - errors={errors} + errors={errors?.freezeEnabled} helpText={i18n.translate( 'xpack.indexLifecycleMgmt.coldPhase.replicaCountHelpText', { @@ -147,10 +146,10 @@ export class ColdPhase extends PureComponent { )} > { - setPhaseData(PHASE_REPLICA_COUNT, e.target.value); + setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); }} min={0} /> @@ -163,7 +162,7 @@ export class ColdPhase extends PureComponent { )} - {phaseData[PHASE_ENABLED] ? ( + {phaseData.phaseEnabled ? ( { > { - setPhaseData(PHASE_FREEZE_ENABLED, e.target.checked); + setPhaseData(phaseProperty('freezeEnabled'), e.target.checked); }} label={freezeLabel} aria-label={freezeLabel} /> - errors={errors} phaseData={phaseData} - phase={PHASE_COLD} + phase={coldProperty} isShowingErrors={isShowingErrors} setPhaseData={setPhaseData} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/delete_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/delete_phase.tsx index 0143cc4af24e3..d3c73090f25f2 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/delete_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/delete_phase.tsx @@ -8,7 +8,9 @@ import React, { PureComponent, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiDescribedFormGroup, EuiSwitch, EuiTextColor, EuiFormRow } from '@elastic/eui'; -import { PHASE_DELETE, PHASE_ENABLED, PHASE_WAIT_FOR_SNAPSHOT_POLICY } from '../../../constants'; +import { DeletePhase as DeletePhaseInterface, Phases } from '../../../services/policies/types'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; + import { ActiveBadge, LearnMoreLink, @@ -18,11 +20,15 @@ import { SnapshotPolicies, } from '../components'; +const deleteProperty = propertyof('delete'); +const phaseProperty = (propertyName: keyof DeletePhaseInterface) => + propertyof(propertyName); + interface Props { - setPhaseData: (key: string, value: any) => void; - phaseData: any; + setPhaseData: (key: keyof DeletePhaseInterface & string, value: string | boolean) => void; + phaseData: DeletePhaseInterface; isShowingErrors: boolean; - errors: Record; + errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; getUrlForApp: ( appId: string, @@ -55,7 +61,7 @@ export class DeletePhase extends PureComponent { defaultMessage="Delete phase" /> {' '} - {phaseData[PHASE_ENABLED] && !isShowingErrors ? : null} + {phaseData.phaseEnabled && !isShowingErrors ? : null} } @@ -76,10 +82,10 @@ export class DeletePhase extends PureComponent { defaultMessage="Activate delete phase" /> } - id={`${PHASE_DELETE}-${PHASE_ENABLED}`} - checked={phaseData[PHASE_ENABLED]} + id={`${deleteProperty}-${phaseProperty('phaseEnabled')}`} + checked={phaseData.phaseEnabled} onChange={(e) => { - setPhaseData(PHASE_ENABLED, e.target.checked); + setPhaseData(phaseProperty('phaseEnabled'), e.target.checked); }} aria-controls="deletePhaseContent" /> @@ -87,11 +93,11 @@ export class DeletePhase extends PureComponent { } fullWidth > - {phaseData[PHASE_ENABLED] ? ( - errors={errors} phaseData={phaseData} - phase={PHASE_DELETE} + phase={deleteProperty} isShowingErrors={isShowingErrors} setPhaseData={setPhaseData} rolloverEnabled={hotPhaseRolloverEnabled} @@ -100,7 +106,7 @@ export class DeletePhase extends PureComponent {
)} - {phaseData[PHASE_ENABLED] ? ( + {phaseData.phaseEnabled ? ( @@ -135,8 +141,8 @@ export class DeletePhase extends PureComponent { } > setPhaseData(PHASE_WAIT_FOR_SNAPSHOT_POLICY, value)} + value={phaseData.waitForSnapshotPolicy} + onChange={(value) => setPhaseData(phaseProperty('waitForSnapshotPolicy'), value)} getUrlForApp={getUrlForApp} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx index dbd48f3a85634..22f0114d16afe 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/hot_phase.tsx @@ -7,7 +7,6 @@ import React, { Fragment, PureComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; - import { EuiFlexGroup, EuiFlexItem, @@ -19,15 +18,9 @@ import { EuiDescribedFormGroup, } from '@elastic/eui'; -import { - PHASE_HOT, - PHASE_ROLLOVER_MAX_AGE, - PHASE_ROLLOVER_MAX_AGE_UNITS, - PHASE_ROLLOVER_MAX_DOCUMENTS, - PHASE_ROLLOVER_MAX_SIZE_STORED, - PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS, - PHASE_ROLLOVER_ENABLED, -} from '../../../constants'; +import { HotPhase as HotPhaseInterface, Phases } from '../../../services/policies/types'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; + import { LearnMoreLink, ActiveBadge, @@ -36,11 +29,98 @@ import { SetPriorityInput, } from '../components'; +const maxSizeStoredUnits = [ + { + value: 'gb', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.gigabytesLabel', { + defaultMessage: 'gigabytes', + }), + }, + { + value: 'mb', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.megabytesLabel', { + defaultMessage: 'megabytes', + }), + }, + { + value: 'b', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.bytesLabel', { + defaultMessage: 'bytes', + }), + }, + { + value: 'kb', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.kilobytesLabel', { + defaultMessage: 'kilobytes', + }), + }, + { + value: 'tb', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.terabytesLabel', { + defaultMessage: 'terabytes', + }), + }, + { + value: 'pb', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.petabytesLabel', { + defaultMessage: 'petabytes', + }), + }, +]; + +const maxAgeUnits = [ + { + value: 'd', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.daysLabel', { + defaultMessage: 'days', + }), + }, + { + value: 'h', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.hoursLabel', { + defaultMessage: 'hours', + }), + }, + { + value: 'm', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.minutesLabel', { + defaultMessage: 'minutes', + }), + }, + { + value: 's', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.secondsLabel', { + defaultMessage: 'seconds', + }), + }, + { + value: 'ms', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.millisecondsLabel', { + defaultMessage: 'milliseconds', + }), + }, + { + value: 'micros', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.microsecondsLabel', { + defaultMessage: 'microseconds', + }), + }, + { + value: 'nanos', + text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.nanosecondsLabel', { + defaultMessage: 'nanoseconds', + }), + }, +]; +const hotProperty = propertyof('hot'); +const phaseProperty = (propertyName: keyof HotPhaseInterface) => + propertyof(propertyName); + interface Props { - errors: Record; + errors?: PhaseValidationErrors; isShowingErrors: boolean; - phaseData: any; - setPhaseData: (key: string, value: any) => void; + phaseData: HotPhaseInterface; + setPhaseData: (key: keyof HotPhaseInterface & string, value: string | boolean) => void; setWarmPhaseOnRollover: (value: boolean) => void; } @@ -104,39 +184,36 @@ export class HotPhase extends PureComponent { > { - const { checked } = e.target; - setPhaseData(PHASE_ROLLOVER_ENABLED, checked); - setWarmPhaseOnRollover(checked); + setWarmPhaseOnRollover(e.target.checked); }} label={i18n.translate('xpack.indexLifecycleMgmt.hotPhase.enableRolloverLabel', { defaultMessage: 'Enable rollover', })} /> - {phaseData[PHASE_ROLLOVER_ENABLED] ? ( + {phaseData.rolloverEnabled ? ( { - setPhaseData(PHASE_ROLLOVER_MAX_SIZE_STORED, e.target.value); + setPhaseData(phaseProperty('selectedMaxSizeStored'), e.target.value); }} min={1} /> @@ -144,11 +221,10 @@ export class HotPhase extends PureComponent { { defaultMessage: 'Maximum index size units', } )} - value={phaseData[PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS]} + value={phaseData.selectedMaxSizeStoredUnits} onChange={(e) => { - setPhaseData(PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS, e.target.value); + setPhaseData(phaseProperty('selectedMaxSizeStoredUnits'), e.target.value); }} - options={[ - { - value: 'gb', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.gigabytesLabel', { - defaultMessage: 'gigabytes', - }), - }, - { - value: 'mb', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.megabytesLabel', { - defaultMessage: 'megabytes', - }), - }, - { - value: 'b', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.bytesLabel', { - defaultMessage: 'bytes', - }), - }, - { - value: 'kb', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.kilobytesLabel', { - defaultMessage: 'kilobytes', - }), - }, - { - value: 'tb', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.terabytesLabel', { - defaultMessage: 'terabytes', - }), - }, - { - value: 'pb', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.petabytesLabel', { - defaultMessage: 'petabytes', - }), - }, - ]} + options={maxSizeStoredUnits} /> @@ -207,22 +246,21 @@ export class HotPhase extends PureComponent { { - setPhaseData(PHASE_ROLLOVER_MAX_DOCUMENTS, e.target.value); + setPhaseData(phaseProperty('selectedMaxDocuments'), e.target.value); }} min={1} /> @@ -233,19 +271,18 @@ export class HotPhase extends PureComponent { { - setPhaseData(PHASE_ROLLOVER_MAX_AGE, e.target.value); + setPhaseData(phaseProperty('selectedMaxAge'), e.target.value); }} min={1} /> @@ -253,11 +290,10 @@ export class HotPhase extends PureComponent { { defaultMessage: 'Maximum age units', } )} - value={phaseData[PHASE_ROLLOVER_MAX_AGE_UNITS]} + value={phaseData.selectedMaxAgeUnits} onChange={(e) => { - setPhaseData(PHASE_ROLLOVER_MAX_AGE_UNITS, e.target.value); + setPhaseData(phaseProperty('selectedMaxAgeUnits'), e.target.value); }} - options={[ - { - value: 'd', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.daysLabel', { - defaultMessage: 'days', - }), - }, - { - value: 'h', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.hoursLabel', { - defaultMessage: 'hours', - }), - }, - { - value: 'm', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.minutesLabel', { - defaultMessage: 'minutes', - }), - }, - { - value: 's', - text: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.secondsLabel', { - defaultMessage: 'seconds', - }), - }, - { - value: 'ms', - text: i18n.translate( - 'xpack.indexLifecycleMgmt.hotPhase.millisecondsLabel', - { - defaultMessage: 'milliseconds', - } - ), - }, - { - value: 'micros', - text: i18n.translate( - 'xpack.indexLifecycleMgmt.hotPhase.microsecondsLabel', - { - defaultMessage: 'microseconds', - } - ), - }, - { - value: 'nanos', - text: i18n.translate( - 'xpack.indexLifecycleMgmt.hotPhase.nanosecondsLabel', - { - defaultMessage: 'nanoseconds', - } - ), - }, - ]} + options={maxAgeUnits} /> @@ -330,10 +314,10 @@ export class HotPhase extends PureComponent { ) : null} - errors={errors} phaseData={phaseData} - phase={PHASE_HOT} + phase={hotProperty} isShowingErrors={isShowingErrors} setPhaseData={setPhaseData} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx index 6ed81bf8f45d5..f7b8c60a5c71f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx @@ -18,16 +18,6 @@ import { EuiDescribedFormGroup, } from '@elastic/eui'; -import { - PHASE_WARM, - PHASE_ENABLED, - WARM_PHASE_ON_ROLLOVER, - PHASE_FORCE_MERGE_ENABLED, - PHASE_FORCE_MERGE_SEGMENTS, - PHASE_PRIMARY_SHARD_COUNT, - PHASE_REPLICA_COUNT, - PHASE_SHRINK_ENABLED, -} from '../../../constants'; import { LearnMoreLink, ActiveBadge, @@ -39,11 +29,33 @@ import { MinAgeInput, } from '../components'; +import { Phases, WarmPhase as WarmPhaseInterface } from '../../../services/policies/types'; +import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; + +const shrinkLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { + defaultMessage: 'Shrink index', +}); + +const moveToWarmPhaseOnRolloverLabel = i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel', + { + defaultMessage: 'Move to warm phase on rollover', + } +); + +const forcemergeLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.forceMergeDataLabel', { + defaultMessage: 'Force merge data', +}); + +const warmProperty = propertyof('warm'); +const phaseProperty = (propertyName: keyof WarmPhaseInterface) => + propertyof(propertyName); + interface Props { - setPhaseData: (key: string, value: any) => void; - phaseData: any; + setPhaseData: (key: keyof WarmPhaseInterface & string, value: boolean | string) => void; + phaseData: WarmPhaseInterface; isShowingErrors: boolean; - errors: Record; + errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; } export class WarmPhase extends PureComponent { @@ -56,24 +68,6 @@ export class WarmPhase extends PureComponent { hotPhaseRolloverEnabled, } = this.props; - const shrinkLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { - defaultMessage: 'Shrink index', - }); - - const moveToWarmPhaseOnRolloverLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel', - { - defaultMessage: 'Move to warm phase on rollover', - } - ); - - const forcemergeLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.forceMergeDataLabel', - { - defaultMessage: 'Force merge data', - } - ); - return (
{ defaultMessage="Warm phase" /> {' '} - {phaseData[PHASE_ENABLED] && !isShowingErrors ? : null} + {phaseData.phaseEnabled && !isShowingErrors ? : null}
} @@ -108,10 +102,10 @@ export class WarmPhase extends PureComponent { defaultMessage="Activate warm phase" /> } - id={`${PHASE_WARM}-${PHASE_ENABLED}`} - checked={phaseData[PHASE_ENABLED]} + id={`${warmProperty}-${phaseProperty('phaseEnabled')}`} + checked={phaseData.phaseEnabled} onChange={(e) => { - setPhaseData(PHASE_ENABLED, e.target.checked); + setPhaseData(phaseProperty('phaseEnabled'), e.target.checked); }} aria-controls="warmPhaseContent" /> @@ -120,28 +114,28 @@ export class WarmPhase extends PureComponent { fullWidth > - {phaseData[PHASE_ENABLED] ? ( + {phaseData.phaseEnabled ? ( {hotPhaseRolloverEnabled ? ( - + { - setPhaseData(WARM_PHASE_ON_ROLLOVER, e.target.checked); + setPhaseData(phaseProperty('warmPhaseOnRollover'), e.target.checked); }} /> ) : null} - {!phaseData[WARM_PHASE_ON_ROLLOVER] ? ( + {!phaseData.warmPhaseOnRollover ? ( - errors={errors} phaseData={phaseData} - phase={PHASE_WARM} + phase={warmProperty} isShowingErrors={isShowingErrors} setPhaseData={setPhaseData} rolloverEnabled={hotPhaseRolloverEnabled} @@ -151,8 +145,8 @@ export class WarmPhase extends PureComponent { - + phase={warmProperty} setPhaseData={setPhaseData} errors={errors} phaseData={phaseData} @@ -162,7 +156,7 @@ export class WarmPhase extends PureComponent { { } - errorKey={PHASE_REPLICA_COUNT} isShowingErrors={isShowingErrors} - errors={errors} + errors={errors?.selectedReplicaCount} helpText={i18n.translate( 'xpack.indexLifecycleMgmt.warmPhase.replicaCountHelpText', { @@ -183,10 +176,10 @@ export class WarmPhase extends PureComponent { )} > { - setPhaseData(PHASE_REPLICA_COUNT, e.target.value); + setPhaseData('selectedReplicaCount', e.target.value); }} min={0} /> @@ -199,7 +192,7 @@ export class WarmPhase extends PureComponent { ) : null} - {phaseData[PHASE_ENABLED] ? ( + {phaseData.phaseEnabled ? ( { { - setPhaseData(PHASE_SHRINK_ENABLED, e.target.checked); + setPhaseData(phaseProperty('shrinkEnabled'), e.target.checked); }} label={shrinkLabel} aria-label={shrinkLabel} @@ -235,28 +228,30 @@ export class WarmPhase extends PureComponent { />
- {phaseData[PHASE_SHRINK_ENABLED] ? ( + {phaseData.shrinkEnabled ? ( { - setPhaseData(PHASE_PRIMARY_SHARD_COUNT, e.target.value); + setPhaseData( + phaseProperty('selectedPrimaryShardCount'), + e.target.value + ); }} min={1} /> @@ -294,33 +289,32 @@ export class WarmPhase extends PureComponent { data-test-subj="forceMergeSwitch" label={forcemergeLabel} aria-label={forcemergeLabel} - checked={phaseData[PHASE_FORCE_MERGE_ENABLED]} + checked={phaseData.forceMergeEnabled} onChange={(e) => { - setPhaseData(PHASE_FORCE_MERGE_ENABLED, e.target.checked); + setPhaseData(phaseProperty('forceMergeEnabled'), e.target.checked); }} aria-controls="forcemergeContent" />
- {phaseData[PHASE_FORCE_MERGE_ENABLED] ? ( + {phaseData.forceMergeEnabled ? ( { - setPhaseData(PHASE_FORCE_MERGE_SEGMENTS, e.target.value); + setPhaseData(phaseProperty('selectedForceMergeSegments'), e.target.value); }} min={1} /> @@ -328,10 +322,10 @@ export class WarmPhase extends PureComponent { ) : null}
- errors={errors} phaseData={phaseData} - phase={PHASE_WARM} + phase={warmProperty} isShowingErrors={isShowingErrors} setPhaseData={setPhaseData} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js index 500ab44d96694..ec1cdb987f4b3 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/policy_table/policy_table.js @@ -38,7 +38,7 @@ import { import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import { reactRouterNavigate } from '../../../../../../../../../src/plugins/kibana_react/public'; import { getIndexListUri } from '../../../../../../../index_management/public'; -import { UIM_EDIT_CLICK } from '../../../../constants'; +import { UIM_EDIT_CLICK } from '../../../../constants/ui_metric'; import { getPolicyPath } from '../../../../services/navigation'; import { flattenPanelTree } from '../../../../services/flatten_panel_tree'; import { trackUiMetric } from '../../../../services/ui_metric'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts index 61de37bbfad11..b80e9e70c54fa 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts @@ -12,10 +12,11 @@ import { UIM_POLICY_ATTACH_INDEX_TEMPLATE, UIM_POLICY_DETACH_INDEX, UIM_INDEX_RETRY_STEP, -} from '../constants'; +} from '../constants/ui_metric'; import { trackUiMetric } from './ui_metric'; import { sendGet, sendPost, sendDelete, useRequest } from './http'; +import { PolicyFromES, SerializedPolicy } from './policies/types'; interface GenericObject { [key: string]: any; @@ -44,7 +45,15 @@ export async function loadPolicies(withIndices: boolean) { return await sendGet('policies', { withIndices }); } -export async function savePolicy(policy: GenericObject) { +export const useLoadPoliciesList = (withIndices: boolean) => { + return useRequest({ + path: `policies`, + method: 'get', + query: { withIndices }, + }); +}; + +export async function savePolicy(policy: SerializedPolicy) { return await sendPost(`policies`, policy); } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/find_errors.js b/x-pack/plugins/index_lifecycle_management/public/application/services/find_errors.js deleted file mode 100644 index 12b53ad1eaf52..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/find_errors.js +++ /dev/null @@ -1,24 +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 findFirstError = (object, topLevel = true) => { - let firstError; - const keys = topLevel ? ['policyName', 'hot', 'warm', 'cold', 'delete'] : Object.keys(object); - for (const key of keys) { - const value = object[key]; - if (Array.isArray(value) && value.length > 0) { - firstError = key; - break; - } else if (value) { - firstError = findFirstError(value, false); - if (firstError) { - firstError = `${key}.${firstError}`; - break; - } - } - } - return firstError; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts new file mode 100644 index 0000000000000..d9ed7a0bf51eb --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts @@ -0,0 +1,159 @@ +/* + * 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 { isEmpty } from 'lodash'; +import { serializedPhaseInitialization } from '../../constants'; +import { AllocateAction, ColdPhase, SerializedColdPhase } from './types'; +import { isNumber, splitSizeAndUnits } from './policy_serialization'; +import { + numberRequiredMessage, + PhaseValidationErrors, + positiveNumberRequiredMessage, +} from './policy_validation'; + +const coldPhaseInitialization: ColdPhase = { + phaseEnabled: false, + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + selectedNodeAttrs: '', + selectedReplicaCount: '', + freezeEnabled: false, + phaseIndexPriority: '', +}; + +export const coldPhaseFromES = (phaseSerialized?: SerializedColdPhase): ColdPhase => { + const phase = { ...coldPhaseInitialization }; + if (phaseSerialized === undefined || phaseSerialized === null) { + return phase; + } + + phase.phaseEnabled = true; + + if (phaseSerialized.min_age) { + const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); + phase.selectedMinimumAge = minAge; + phase.selectedMinimumAgeUnits = minAgeUnits; + } + + if (phaseSerialized.actions) { + const actions = phaseSerialized.actions; + if (actions.allocate) { + const allocate = actions.allocate; + if (allocate.require) { + Object.entries(allocate.require).forEach((entry) => { + phase.selectedNodeAttrs = entry.join(':'); + }); + if (allocate.number_of_replicas) { + phase.selectedReplicaCount = allocate.number_of_replicas.toString(); + } + } + } + + if (actions.freeze) { + phase.freezeEnabled = true; + } + + if (actions.set_priority) { + phase.phaseIndexPriority = actions.set_priority.priority + ? actions.set_priority.priority.toString() + : ''; + } + } + + return phase; +}; + +export const coldPhaseToES = ( + phase: ColdPhase, + originalPhase: SerializedColdPhase | undefined +): SerializedColdPhase => { + if (!originalPhase) { + originalPhase = { ...serializedPhaseInitialization }; + } + + const esPhase = { ...originalPhase }; + + if (isNumber(phase.selectedMinimumAge)) { + esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; + } + + esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; + + if (phase.selectedNodeAttrs) { + const [name, value] = phase.selectedNodeAttrs.split(':'); + esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); + esPhase.actions.allocate.require = { + [name]: value, + }; + } else { + if (esPhase.actions.allocate) { + delete esPhase.actions.allocate.require; + } + } + + if (isNumber(phase.selectedReplicaCount)) { + esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); + esPhase.actions.allocate.number_of_replicas = parseInt(phase.selectedReplicaCount, 10); + } else { + if (esPhase.actions.allocate) { + delete esPhase.actions.allocate.number_of_replicas; + } + } + + if ( + esPhase.actions.allocate && + !esPhase.actions.allocate.require && + !isNumber(esPhase.actions.allocate.number_of_replicas) && + isEmpty(esPhase.actions.allocate.include) && + isEmpty(esPhase.actions.allocate.exclude) + ) { + // remove allocate action if it does not define require or number of nodes + // and both include and exclude are empty objects (ES will fail to parse if we don't) + delete esPhase.actions.allocate; + } + + if (phase.freezeEnabled) { + esPhase.actions.freeze = {}; + } else { + delete esPhase.actions.freeze; + } + + if (isNumber(phase.phaseIndexPriority)) { + esPhase.actions.set_priority = { + priority: parseInt(phase.phaseIndexPriority, 10), + }; + } else { + delete esPhase.actions.set_priority; + } + + return esPhase; +}; + +export const validateColdPhase = (phase: ColdPhase): PhaseValidationErrors => { + if (!phase.phaseEnabled) { + return {}; + } + + const phaseErrors = {} as PhaseValidationErrors; + + // index priority is optional, but if it's set, it needs to be a positive number + if (phase.phaseIndexPriority) { + if (!isNumber(phase.phaseIndexPriority)) { + phaseErrors.phaseIndexPriority = [numberRequiredMessage]; + } else if (parseInt(phase.phaseIndexPriority, 10) < 0) { + phaseErrors.phaseIndexPriority = [positiveNumberRequiredMessage]; + } + } + + // min age needs to be a positive number + if (!isNumber(phase.selectedMinimumAge)) { + phaseErrors.phaseIndexPriority = [numberRequiredMessage]; + } else if (parseInt(phase.selectedMinimumAge, 10) < 0) { + phaseErrors.phaseIndexPriority = [positiveNumberRequiredMessage]; + } + + return { ...phaseErrors }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts new file mode 100644 index 0000000000000..70e7c21da8cb6 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { serializedPhaseInitialization } from '../../constants'; +import { DeletePhase, SerializedDeletePhase } from './types'; +import { isNumber, splitSizeAndUnits } from './policy_serialization'; +import { + numberRequiredMessage, + PhaseValidationErrors, + positiveNumberRequiredMessage, +} from './policy_validation'; + +const deletePhaseInitialization: DeletePhase = { + phaseEnabled: false, + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + waitForSnapshotPolicy: '', +}; + +export const deletePhaseFromES = (phaseSerialized?: SerializedDeletePhase): DeletePhase => { + const phase = { ...deletePhaseInitialization }; + if (phaseSerialized === undefined || phaseSerialized === null) { + return phase; + } + + phase.phaseEnabled = true; + if (phaseSerialized.min_age) { + const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); + phase.selectedMinimumAge = minAge; + phase.selectedMinimumAgeUnits = minAgeUnits; + } + + if (phaseSerialized.actions) { + const actions = phaseSerialized.actions; + + if (actions.wait_for_snapshot) { + phase.waitForSnapshotPolicy = actions.wait_for_snapshot.policy; + } + } + + return phase; +}; + +export const deletePhaseToES = ( + phase: DeletePhase, + originalEsPhase?: SerializedDeletePhase +): SerializedDeletePhase => { + if (!originalEsPhase) { + originalEsPhase = { ...serializedPhaseInitialization }; + } + const esPhase = { ...originalEsPhase }; + + if (isNumber(phase.selectedMinimumAge)) { + esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; + } + + esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; + + if (phase.waitForSnapshotPolicy) { + esPhase.actions.wait_for_snapshot = { + policy: phase.waitForSnapshotPolicy, + }; + } else { + delete esPhase.actions.wait_for_snapshot; + } + + return esPhase; +}; + +export const validateDeletePhase = (phase: DeletePhase): PhaseValidationErrors => { + if (!phase.phaseEnabled) { + return {}; + } + + const phaseErrors = {} as PhaseValidationErrors; + + // min age needs to be a positive number + if (!isNumber(phase.selectedMinimumAge)) { + phaseErrors.selectedMinimumAge = [numberRequiredMessage]; + } else if (parseInt(phase.selectedMinimumAge, 10) < 0) { + phaseErrors.selectedMinimumAge = [positiveNumberRequiredMessage]; + } + + return { ...phaseErrors }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/hot_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/hot_phase.ts new file mode 100644 index 0000000000000..34ac8f3e270e6 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/hot_phase.ts @@ -0,0 +1,155 @@ +/* + * 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 { serializedPhaseInitialization } from '../../constants'; +import { isNumber, splitSizeAndUnits } from './policy_serialization'; +import { HotPhase, SerializedHotPhase } from './types'; +import { + maximumAgeRequiredMessage, + maximumDocumentsRequiredMessage, + maximumSizeRequiredMessage, + numberRequiredMessage, + PhaseValidationErrors, + positiveNumberRequiredMessage, + positiveNumbersAboveZeroErrorMessage, +} from './policy_validation'; + +const hotPhaseInitialization: HotPhase = { + phaseEnabled: false, + rolloverEnabled: false, + selectedMaxAge: '', + selectedMaxAgeUnits: 'd', + selectedMaxSizeStored: '', + selectedMaxSizeStoredUnits: 'gb', + phaseIndexPriority: '', + selectedMaxDocuments: '', +}; + +export const hotPhaseFromES = (phaseSerialized?: SerializedHotPhase): HotPhase => { + const phase: HotPhase = { ...hotPhaseInitialization }; + + if (phaseSerialized === undefined || phaseSerialized === null) { + return phase; + } + + phase.phaseEnabled = true; + + if (phaseSerialized.actions) { + const actions = phaseSerialized.actions; + + if (actions.rollover) { + const rollover = actions.rollover; + phase.rolloverEnabled = true; + if (rollover.max_age) { + const { size: maxAge, units: maxAgeUnits } = splitSizeAndUnits(rollover.max_age); + phase.selectedMaxAge = maxAge; + phase.selectedMaxAgeUnits = maxAgeUnits; + } + if (rollover.max_size) { + const { size: maxSize, units: maxSizeUnits } = splitSizeAndUnits(rollover.max_size); + phase.selectedMaxSizeStored = maxSize; + phase.selectedMaxSizeStoredUnits = maxSizeUnits; + } + if (rollover.max_docs) { + phase.selectedMaxDocuments = rollover.max_docs.toString(); + } + } + + if (actions.set_priority) { + phase.phaseIndexPriority = actions.set_priority.priority + ? actions.set_priority.priority.toString() + : ''; + } + } + + return phase; +}; + +export const hotPhaseToES = ( + phase: HotPhase, + originalPhase?: SerializedHotPhase +): SerializedHotPhase => { + if (!originalPhase) { + originalPhase = { ...serializedPhaseInitialization }; + } + + const esPhase = { ...originalPhase }; + + esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; + + if (phase.rolloverEnabled) { + if (!esPhase.actions.rollover) { + esPhase.actions.rollover = {}; + } + if (isNumber(phase.selectedMaxAge)) { + esPhase.actions.rollover.max_age = `${phase.selectedMaxAge}${phase.selectedMaxAgeUnits}`; + } + if (isNumber(phase.selectedMaxSizeStored)) { + esPhase.actions.rollover.max_size = `${phase.selectedMaxSizeStored}${phase.selectedMaxSizeStoredUnits}`; + } + if (isNumber(phase.selectedMaxDocuments)) { + esPhase.actions.rollover.max_docs = parseInt(phase.selectedMaxDocuments, 10); + } + } else { + delete esPhase.actions.rollover; + } + + if (isNumber(phase.phaseIndexPriority)) { + esPhase.actions.set_priority = { + priority: parseInt(phase.phaseIndexPriority, 10), + }; + } else { + delete esPhase.actions.set_priority; + } + + return esPhase; +}; + +export const validateHotPhase = (phase: HotPhase): PhaseValidationErrors => { + if (!phase.phaseEnabled) { + return {}; + } + + const phaseErrors = {} as PhaseValidationErrors; + + // index priority is optional, but if it's set, it needs to be a positive number + if (phase.phaseIndexPriority) { + if (!isNumber(phase.phaseIndexPriority)) { + phaseErrors.phaseIndexPriority = [numberRequiredMessage]; + } else if (parseInt(phase.phaseIndexPriority, 10) < 0) { + phaseErrors.phaseIndexPriority = [positiveNumberRequiredMessage]; + } + } + + // if rollover is enabled + if (phase.rolloverEnabled) { + // either max_age, max_size or max_documents need to be set + if ( + !isNumber(phase.selectedMaxAge) && + !isNumber(phase.selectedMaxSizeStored) && + !isNumber(phase.selectedMaxDocuments) + ) { + phaseErrors.selectedMaxAge = [maximumAgeRequiredMessage]; + phaseErrors.selectedMaxSizeStored = [maximumSizeRequiredMessage]; + phaseErrors.selectedMaxDocuments = [maximumDocumentsRequiredMessage]; + } + + // max age, max size and max docs need to be above zero if set + if (isNumber(phase.selectedMaxAge) && parseInt(phase.selectedMaxAge, 10) < 1) { + phaseErrors.selectedMaxAge = [positiveNumbersAboveZeroErrorMessage]; + } + if (isNumber(phase.selectedMaxSizeStored) && parseInt(phase.selectedMaxSizeStored, 10) < 1) { + phaseErrors.selectedMaxSizeStored = [positiveNumbersAboveZeroErrorMessage]; + } + if (isNumber(phase.selectedMaxDocuments) && parseInt(phase.selectedMaxDocuments, 10) < 1) { + phaseErrors.selectedMaxDocuments = [positiveNumbersAboveZeroErrorMessage]; + } + } + + return { + ...phaseErrors, + }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/lifecycle.js b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts similarity index 58% rename from x-pack/plugins/index_lifecycle_management/public/application/store/actions/lifecycle.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts index 0bb6543482bd6..12df071544952 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/lifecycle.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts @@ -5,28 +5,36 @@ */ import { i18n } from '@kbn/i18n'; +import { METRIC_TYPE } from '@kbn/analytics'; -import { UIM_POLICY_CREATE, UIM_POLICY_UPDATE } from '../../constants'; -import { showApiError } from '../../services/api_errors'; -import { toasts } from '../../services/notification'; -import { savePolicy as savePolicyApi } from '../../services/api'; -import { trackUiMetric, getUiMetricsForPhases } from '../../services/ui_metric'; +import { savePolicy as savePolicyApi } from '../api'; +import { showApiError } from '../api_errors'; +import { getUiMetricsForPhases, trackUiMetric } from '../ui_metric'; +import { UIM_POLICY_CREATE, UIM_POLICY_UPDATE } from '../../constants/ui_metric'; +import { toasts } from '../notification'; +import { Policy, PolicyFromES } from './types'; +import { serializePolicy } from './policy_serialization'; -export const saveLifecyclePolicy = (lifecycle, isNew) => async () => { +export const savePolicy = async ( + policy: Policy, + isNew: boolean, + originalEsPolicy?: PolicyFromES +): Promise => { + const serializedPolicy = serializePolicy(policy, originalEsPolicy?.policy); try { - await savePolicyApi(lifecycle); + await savePolicyApi(serializedPolicy); } catch (err) { const title = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.saveErrorMessage', { defaultMessage: 'Error saving lifecycle policy {lifecycleName}', - values: { lifecycleName: lifecycle.name }, + values: { lifecycleName: policy.name }, }); showApiError(err, title); return false; } - const uiMetrics = getUiMetricsForPhases(lifecycle.phases); + const uiMetrics = getUiMetricsForPhases(serializedPolicy.phases); uiMetrics.push(isNew ? UIM_POLICY_CREATE : UIM_POLICY_UPDATE); - trackUiMetric('count', uiMetrics); + trackUiMetric(METRIC_TYPE.COUNT, uiMetrics); const message = i18n.translate('xpack.indexLifecycleMgmt.editPolicy.successfulSaveMessage', { defaultMessage: '{verb} lifecycle policy "{lifecycleName}"', @@ -38,7 +46,7 @@ export const saveLifecyclePolicy = (lifecycle, isNew) => async () => { : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.updatedMessage', { defaultMessage: 'Updated', }), - lifecycleName: lifecycle.name, + lifecycleName: policy.name, }, }); toasts.addSuccess(message); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts new file mode 100644 index 0000000000000..3953521df1817 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + defaultNewColdPhase, + defaultNewDeletePhase, + defaultNewHotPhase, + defaultNewWarmPhase, + serializedPhaseInitialization, +} from '../../constants'; + +import { Policy, PolicyFromES, SerializedPolicy } from './types'; + +import { hotPhaseFromES, hotPhaseToES } from './hot_phase'; +import { warmPhaseFromES, warmPhaseToES } from './warm_phase'; +import { coldPhaseFromES, coldPhaseToES } from './cold_phase'; +import { deletePhaseFromES, deletePhaseToES } from './delete_phase'; + +export const splitSizeAndUnits = (field: string): { size: string; units: string } => { + let size = ''; + let units = ''; + + const result = /(\d+)(\w+)/.exec(field); + if (result) { + size = result[1]; + units = result[2]; + } + + return { + size, + units, + }; +}; + +export const isNumber = (value: any): boolean => value !== '' && value !== null && isFinite(value); + +export const getPolicyByName = ( + policies: PolicyFromES[] | null | undefined, + policyName: string = '' +): PolicyFromES | undefined => { + if (policies && policies.length > 0) { + return policies.find((policy: PolicyFromES) => policy.name === policyName); + } +}; + +export const initializeNewPolicy = (newPolicyName: string = ''): Policy => { + return { + name: newPolicyName, + phases: { + hot: { ...defaultNewHotPhase }, + warm: { ...defaultNewWarmPhase }, + cold: { ...defaultNewColdPhase }, + delete: { ...defaultNewDeletePhase }, + }, + }; +}; + +export const deserializePolicy = (policy: PolicyFromES): Policy => { + const { + name, + policy: { phases }, + } = policy; + + return { + name, + phases: { + hot: hotPhaseFromES(phases.hot), + warm: warmPhaseFromES(phases.warm), + cold: coldPhaseFromES(phases.cold), + delete: deletePhaseFromES(phases.delete), + }, + }; +}; + +export const serializePolicy = ( + policy: Policy, + originalEsPolicy: SerializedPolicy = { + name: policy.name, + phases: { hot: { ...serializedPhaseInitialization } }, + } +): SerializedPolicy => { + const serializedPolicy = { + name: policy.name, + phases: { hot: hotPhaseToES(policy.phases.hot, originalEsPolicy.phases.hot) }, + } as SerializedPolicy; + if (policy.phases.warm.phaseEnabled) { + serializedPolicy.phases.warm = warmPhaseToES(policy.phases.warm, originalEsPolicy.phases.warm); + } + + if (policy.phases.cold.phaseEnabled) { + serializedPolicy.phases.cold = coldPhaseToES(policy.phases.cold, originalEsPolicy.phases.cold); + } + + if (policy.phases.delete.phaseEnabled) { + serializedPolicy.phases.delete = deletePhaseToES( + policy.phases.delete, + originalEsPolicy.phases.delete + ); + } + return serializedPolicy; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts new file mode 100644 index 0000000000000..545488be2cd5e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.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 { i18n } from '@kbn/i18n'; +import { validateHotPhase } from './hot_phase'; +import { validateWarmPhase } from './warm_phase'; +import { validateColdPhase } from './cold_phase'; +import { validateDeletePhase } from './delete_phase'; +import { ColdPhase, DeletePhase, HotPhase, Phase, Policy, PolicyFromES, WarmPhase } from './types'; + +export const propertyof = (propertyName: keyof T & string) => propertyName; + +export const numberRequiredMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.numberRequiredError', + { + defaultMessage: 'A number is required.', + } +); + +// TODO validation includes 0 -> should be non-negative number? +export const positiveNumberRequiredMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberRequiredError', + { + defaultMessage: 'Only positive numbers are allowed.', + } +); + +export const maximumAgeRequiredMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.maximumAgeMissingError', + { + defaultMessage: 'A maximum age is required.', + } +); + +export const maximumSizeRequiredMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.maximumIndexSizeMissingError', + { + defaultMessage: 'A maximum index size is required.', + } +); + +export const maximumDocumentsRequiredMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.maximumDocumentsMissingError', + { + defaultMessage: 'Maximum documents is required.', + } +); + +export const positiveNumbersAboveZeroErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberAboveZeroRequiredError', + { + defaultMessage: 'Only numbers above 0 are allowed.', + } +); + +export const policyNameRequiredMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError', + { + defaultMessage: 'A policy name is required.', + } +); + +export const policyNameStartsWithUnderscoreErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError', + { + defaultMessage: 'A policy name cannot start with an underscore.', + } +); +export const policyNameContainsCommaErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsCommaError', + { + defaultMessage: 'A policy name cannot include a comma.', + } +); +export const policyNameContainsSpaceErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsSpaceError', + { + defaultMessage: 'A policy name cannot include a space.', + } +); + +export const policyNameTooLongErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError', + { + defaultMessage: 'A policy name cannot be longer than 255 bytes.', + } +); +export const policyNameMustBeDifferentErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError', + { + defaultMessage: 'The policy name must be different.', + } +); +export const policyNameAlreadyUsedErrorMessage = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError', + { + defaultMessage: 'That policy name is already used.', + } +); +export type PhaseValidationErrors = { + [P in keyof Partial]: string[]; +}; + +export interface ValidationErrors { + hot: PhaseValidationErrors; + warm: PhaseValidationErrors; + cold: PhaseValidationErrors; + delete: PhaseValidationErrors; + policyName: string[]; +} + +export const validatePolicy = ( + saveAsNew: boolean, + policy: Policy, + policies: PolicyFromES[], + originalPolicyName: string +): [boolean, ValidationErrors] => { + const policyNameErrors: string[] = []; + if (!policy.name) { + policyNameErrors.push(policyNameRequiredMessage); + } else { + if (policy.name.startsWith('_')) { + policyNameErrors.push(policyNameStartsWithUnderscoreErrorMessage); + } + if (policy.name.includes(',')) { + policyNameErrors.push(policyNameContainsCommaErrorMessage); + } + if (policy.name.includes(' ')) { + policyNameErrors.push(policyNameContainsSpaceErrorMessage); + } + if (window.TextEncoder && new window.TextEncoder().encode(policy.name).length > 255) { + policyNameErrors.push(policyNameTooLongErrorMessage); + } + + if (saveAsNew && policy.name === originalPolicyName) { + policyNameErrors.push(policyNameMustBeDifferentErrorMessage); + } else if (policy.name !== originalPolicyName) { + const policyNames = policies.map((existingPolicy) => existingPolicy.name); + if (policyNames.includes(policy.name)) { + policyNameErrors.push(policyNameAlreadyUsedErrorMessage); + } + } + } + + const hotPhaseErrors = validateHotPhase(policy.phases.hot); + const warmPhaseErrors = validateWarmPhase(policy.phases.warm); + const coldPhaseErrors = validateColdPhase(policy.phases.cold); + const deletePhaseErrors = validateDeletePhase(policy.phases.delete); + const isValid = + policyNameErrors.length === 0 && + Object.keys(hotPhaseErrors).length === 0 && + Object.keys(warmPhaseErrors).length === 0 && + Object.keys(coldPhaseErrors).length === 0 && + Object.keys(deletePhaseErrors).length === 0; + return [ + isValid, + { + policyName: [...policyNameErrors], + hot: hotPhaseErrors, + warm: warmPhaseErrors, + cold: coldPhaseErrors, + delete: deletePhaseErrors, + }, + ]; +}; + +export const findFirstError = (errors?: ValidationErrors): string | undefined => { + if (!errors) { + return; + } + + if (errors.policyName.length > 0) { + return propertyof('policyName'); + } + + if (Object.keys(errors.hot).length > 0) { + return `${propertyof('hot')}.${Object.keys(errors.hot)[0]}`; + } + if (Object.keys(errors.warm).length > 0) { + return `${propertyof('warm')}.${Object.keys(errors.warm)[0]}`; + } + if (Object.keys(errors.cold).length > 0) { + return `${propertyof('cold')}.${Object.keys(errors.cold)[0]}`; + } + if (Object.keys(errors.delete).length > 0) { + return `${propertyof('delete')}.${Object.keys(errors.delete)[0]}`; + } +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts new file mode 100644 index 0000000000000..2e2ed5b38bb87 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts @@ -0,0 +1,140 @@ +/* + * 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 SerializedPolicy { + name: string; + phases: Phases; +} + +export interface Phases { + hot?: SerializedHotPhase; + warm?: SerializedWarmPhase; + cold?: SerializedColdPhase; + delete?: SerializedDeletePhase; +} + +export interface PolicyFromES { + modified_date: string; + name: string; + policy: SerializedPolicy; + version: number; +} + +export interface SerializedPhase { + min_age: string; + actions: { + [action: string]: any; + }; +} + +export interface SerializedHotPhase extends SerializedPhase { + actions: { + rollover?: { + max_size?: string; + max_age?: string; + max_docs?: number; + }; + set_priority?: { + priority: number | null; + }; + }; +} + +export interface SerializedWarmPhase extends SerializedPhase { + actions: { + allocate?: AllocateAction; + shrink?: { + number_of_shards: number; + }; + forcemerge?: { + max_num_segments: number; + }; + set_priority?: { + priority: number | null; + }; + }; +} + +export interface SerializedColdPhase extends SerializedPhase { + actions: { + freeze?: {}; + allocate?: AllocateAction; + set_priority?: { + priority: number | null; + }; + }; +} + +export interface SerializedDeletePhase extends SerializedPhase { + actions: { + wait_for_snapshot?: { + policy: string; + }; + delete?: { + delete_searchable_snapshot: boolean; + }; + }; +} + +export interface AllocateAction { + number_of_replicas: number; + include: {}; + exclude: {}; + require: { + [attribute: string]: string; + }; +} + +export interface Policy { + name: string; + phases: { + hot: HotPhase; + warm: WarmPhase; + cold: ColdPhase; + delete: DeletePhase; + }; +} + +export interface Phase { + phaseEnabled: boolean; +} +export interface HotPhase extends Phase { + rolloverEnabled: boolean; + selectedMaxSizeStored: string; + selectedMaxSizeStoredUnits: string; + selectedMaxDocuments: string; + selectedMaxAge: string; + selectedMaxAgeUnits: string; + phaseIndexPriority: string; +} + +export interface WarmPhase extends Phase { + warmPhaseOnRollover: boolean; + selectedMinimumAge: string; + selectedMinimumAgeUnits: string; + selectedNodeAttrs: string; + selectedReplicaCount: string; + shrinkEnabled: boolean; + selectedPrimaryShardCount: string; + forceMergeEnabled: boolean; + selectedForceMergeSegments: string; + phaseIndexPriority: string; +} + +export interface ColdPhase extends Phase { + selectedMinimumAge: string; + selectedMinimumAgeUnits: string; + selectedNodeAttrs: string; + selectedReplicaCount: string; + freezeEnabled: boolean; + phaseIndexPriority: string; +} + +export interface DeletePhase extends Phase { + selectedMinimumAge: string; + selectedMinimumAgeUnits: string; + waitForSnapshotPolicy: string; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts new file mode 100644 index 0000000000000..3ca1a1cc83371 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts @@ -0,0 +1,219 @@ +/* + * 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 { isEmpty } from 'lodash'; +import { serializedPhaseInitialization } from '../../constants'; +import { AllocateAction, WarmPhase, SerializedWarmPhase } from './types'; +import { isNumber, splitSizeAndUnits } from './policy_serialization'; + +import { + numberRequiredMessage, + PhaseValidationErrors, + positiveNumberRequiredMessage, + positiveNumbersAboveZeroErrorMessage, +} from './policy_validation'; + +const warmPhaseInitialization: WarmPhase = { + phaseEnabled: false, + warmPhaseOnRollover: false, + selectedMinimumAge: '0', + selectedMinimumAgeUnits: 'd', + selectedNodeAttrs: '', + selectedReplicaCount: '', + shrinkEnabled: false, + selectedPrimaryShardCount: '', + forceMergeEnabled: false, + selectedForceMergeSegments: '', + phaseIndexPriority: '', +}; + +export const warmPhaseFromES = (phaseSerialized?: SerializedWarmPhase): WarmPhase => { + const phase: WarmPhase = { ...warmPhaseInitialization }; + + if (phaseSerialized === undefined || phaseSerialized === null) { + return phase; + } + + phase.phaseEnabled = true; + + if (phaseSerialized.min_age) { + if (phaseSerialized.min_age === '0ms') { + phase.warmPhaseOnRollover = true; + } else { + const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); + phase.selectedMinimumAge = minAge; + phase.selectedMinimumAgeUnits = minAgeUnits; + } + } + if (phaseSerialized.actions) { + const actions = phaseSerialized.actions; + if (actions.allocate) { + const allocate = actions.allocate; + if (allocate.require) { + Object.entries(allocate.require).forEach((entry) => { + phase.selectedNodeAttrs = entry.join(':'); + }); + if (allocate.number_of_replicas) { + phase.selectedReplicaCount = allocate.number_of_replicas.toString(); + } + } + } + + if (actions.forcemerge) { + const forcemerge = actions.forcemerge; + phase.forceMergeEnabled = true; + phase.selectedForceMergeSegments = forcemerge.max_num_segments.toString(); + } + + if (actions.shrink) { + phase.shrinkEnabled = true; + phase.selectedPrimaryShardCount = actions.shrink.number_of_shards + ? actions.shrink.number_of_shards.toString() + : ''; + } + } + return phase; +}; + +export const warmPhaseToES = ( + phase: WarmPhase, + originalEsPhase?: SerializedWarmPhase +): SerializedWarmPhase => { + if (!originalEsPhase) { + originalEsPhase = { ...serializedPhaseInitialization }; + } + + const esPhase = { ...originalEsPhase }; + + if (isNumber(phase.selectedMinimumAge)) { + esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; + } + + // If warm phase on rollover is enabled, delete min age field + // An index lifecycle switches to warm phase when rollover occurs, so you cannot specify a warm phase time + // They are mutually exclusive + if (phase.warmPhaseOnRollover) { + delete esPhase.min_age; + } + + esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; + + if (phase.selectedNodeAttrs) { + const [name, value] = phase.selectedNodeAttrs.split(':'); + esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); + esPhase.actions.allocate.require = { + [name]: value, + }; + } else { + if (esPhase.actions.allocate) { + delete esPhase.actions.allocate.require; + } + } + + if (isNumber(phase.selectedReplicaCount)) { + esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); + esPhase.actions.allocate.number_of_replicas = parseInt(phase.selectedReplicaCount, 10); + } else { + if (esPhase.actions.allocate) { + delete esPhase.actions.allocate.number_of_replicas; + } + } + + if ( + esPhase.actions.allocate && + !esPhase.actions.allocate.require && + !isNumber(esPhase.actions.allocate.number_of_replicas) && + isEmpty(esPhase.actions.allocate.include) && + isEmpty(esPhase.actions.allocate.exclude) + ) { + // remove allocate action if it does not define require or number of nodes + // and both include and exclude are empty objects (ES will fail to parse if we don't) + delete esPhase.actions.allocate; + } + + if (phase.forceMergeEnabled) { + esPhase.actions.forcemerge = { + max_num_segments: parseInt(phase.selectedForceMergeSegments, 10), + }; + } else { + delete esPhase.actions.forcemerge; + } + + if (phase.shrinkEnabled && isNumber(phase.selectedPrimaryShardCount)) { + esPhase.actions.shrink = { + number_of_shards: parseInt(phase.selectedPrimaryShardCount, 10), + }; + } else { + delete esPhase.actions.shrink; + } + + if (isNumber(phase.phaseIndexPriority)) { + esPhase.actions.set_priority = { + priority: parseInt(phase.phaseIndexPriority, 10), + }; + } else { + delete esPhase.actions.set_priority; + } + + return esPhase; +}; + +export const validateWarmPhase = (phase: WarmPhase): PhaseValidationErrors => { + if (!phase.phaseEnabled) { + return {}; + } + + const phaseErrors = {} as PhaseValidationErrors; + + // index priority is optional, but if it's set, it needs to be a positive number + if (phase.phaseIndexPriority) { + if (!isNumber(phase.phaseIndexPriority)) { + phaseErrors.phaseIndexPriority = [numberRequiredMessage]; + } else if (parseInt(phase.phaseIndexPriority, 10) < 0) { + phaseErrors.phaseIndexPriority = [positiveNumberRequiredMessage]; + } + } + + // if warm phase on rollover is disabled, min age needs to be a positive number + if (!phase.warmPhaseOnRollover) { + if (!isNumber(phase.selectedMinimumAge)) { + phaseErrors.selectedMinimumAge = [numberRequiredMessage]; + } else if (parseInt(phase.selectedMinimumAge, 10) < 0) { + phaseErrors.selectedMinimumAge = [positiveNumberRequiredMessage]; + } + } + + // if forcemerge is enabled, force merge segments needs to be a number above zero + if (phase.forceMergeEnabled) { + if (!isNumber(phase.selectedForceMergeSegments)) { + phaseErrors.selectedForceMergeSegments = [numberRequiredMessage]; + } else if (parseInt(phase.selectedForceMergeSegments, 10) < 1) { + phaseErrors.selectedForceMergeSegments = [positiveNumbersAboveZeroErrorMessage]; + } + } + + // if shrink is enabled, primary shard count needs to be a number above zero + if (phase.shrinkEnabled) { + if (!isNumber(phase.selectedPrimaryShardCount)) { + phaseErrors.selectedPrimaryShardCount = [numberRequiredMessage]; + } else if (parseInt(phase.selectedPrimaryShardCount, 10) < 1) { + phaseErrors.selectedPrimaryShardCount = [positiveNumbersAboveZeroErrorMessage]; + } + } + + // replica count is optional, but if it's set, it needs to be a positive number + if (phase.selectedReplicaCount) { + if (!isNumber(phase.selectedReplicaCount)) { + phaseErrors.selectedReplicaCount = [numberRequiredMessage]; + } else if (parseInt(phase.selectedReplicaCount, 10) < 0) { + phaseErrors.selectedReplicaCount = [numberRequiredMessage]; + } + } + + return { + ...phaseErrors, + }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.js b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.ts similarity index 75% rename from x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.js rename to x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.ts index 99e6bfb99472c..7c7c0b70c0eed 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.ts @@ -5,14 +5,13 @@ */ import { - PHASE_INDEX_PRIORITY, UIM_CONFIG_COLD_PHASE, UIM_CONFIG_WARM_PHASE, UIM_CONFIG_SET_PRIORITY, UIM_CONFIG_FREEZE_INDEX, -} from '../constants'; - -import { defaultColdPhase, defaultWarmPhase } from '../store/defaults'; + defaultNewWarmPhase, + defaultNewColdPhase, +} from '../constants/'; import { getUiMetricsForPhases } from './ui_metric'; jest.mock('ui/new_platform'); @@ -22,9 +21,10 @@ describe('getUiMetricsForPhases', () => { expect( getUiMetricsForPhases({ cold: { + min_age: '0ms', actions: { set_priority: { - priority: defaultColdPhase[PHASE_INDEX_PRIORITY], + priority: parseInt(defaultNewColdPhase.phaseIndexPriority, 10), }, }, }, @@ -36,9 +36,10 @@ describe('getUiMetricsForPhases', () => { expect( getUiMetricsForPhases({ warm: { + min_age: '0ms', actions: { set_priority: { - priority: defaultWarmPhase[PHASE_INDEX_PRIORITY], + priority: parseInt(defaultNewWarmPhase.phaseIndexPriority, 10), }, }, }, @@ -50,9 +51,10 @@ describe('getUiMetricsForPhases', () => { expect( getUiMetricsForPhases({ warm: { + min_age: '0ms', actions: { set_priority: { - priority: defaultWarmPhase[PHASE_INDEX_PRIORITY] + 1, + priority: parseInt(defaultNewWarmPhase.phaseIndexPriority, 10) + 1, }, }, }, @@ -64,10 +66,11 @@ describe('getUiMetricsForPhases', () => { expect( getUiMetricsForPhases({ cold: { + min_age: '0ms', actions: { freeze: {}, set_priority: { - priority: defaultColdPhase[PHASE_INDEX_PRIORITY], + priority: parseInt(defaultNewColdPhase.phaseIndexPriority, 10), }, }, }, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts index d71e38d0b31de..b38a734770546 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts @@ -4,24 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get } from 'lodash'; - import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; import { UiStatsMetricType } from '@kbn/analytics'; import { UIM_APP_NAME, UIM_CONFIG_COLD_PHASE, - UIM_CONFIG_WARM_PHASE, - UIM_CONFIG_SET_PRIORITY, UIM_CONFIG_FREEZE_INDEX, - PHASE_HOT, - PHASE_WARM, - PHASE_COLD, - PHASE_INDEX_PRIORITY, + UIM_CONFIG_SET_PRIORITY, + UIM_CONFIG_WARM_PHASE, + defaultNewColdPhase, + defaultNewHotPhase, + defaultNewWarmPhase, } from '../constants'; -import { defaultColdPhase, defaultWarmPhase, defaultHotPhase } from '../store/defaults'; +import { Phases } from './policies/types'; export let trackUiMetric = (metricType: UiStatsMetricType, eventName: string) => {}; @@ -31,49 +28,54 @@ export function init(usageCollection?: UsageCollectionSetup): void { } } -export function getUiMetricsForPhases(phases: any): any { +export function getUiMetricsForPhases(phases: Phases): any { const phaseUiMetrics = [ { metric: UIM_CONFIG_COLD_PHASE, - isTracked: () => Boolean(phases[PHASE_COLD]), + isTracked: () => Boolean(phases.cold), }, { metric: UIM_CONFIG_WARM_PHASE, - isTracked: () => Boolean(phases[PHASE_WARM]), + isTracked: () => Boolean(phases.warm), }, { metric: UIM_CONFIG_SET_PRIORITY, isTracked: () => { - const phaseToDefaultIndexPriorityMap = { - [PHASE_HOT]: defaultHotPhase[PHASE_INDEX_PRIORITY], - [PHASE_WARM]: defaultWarmPhase[PHASE_INDEX_PRIORITY], - [PHASE_COLD]: defaultColdPhase[PHASE_INDEX_PRIORITY], - }; - // We only care about whether the user has interacted with the priority of *any* phase at all. - return [PHASE_HOT, PHASE_WARM, PHASE_COLD].some((phase) => { - // If the priority is different than the default, we'll consider it a user interaction, - // even if the user has set it to undefined. - return ( - phases[phase] && - get(phases[phase], 'actions.set_priority.priority') !== - phaseToDefaultIndexPriorityMap[phase] - ); - }); + const isHotPhasePriorityChanged = + phases.hot && + phases.hot.actions.set_priority && + phases.hot.actions.set_priority.priority !== + parseInt(defaultNewHotPhase.phaseIndexPriority, 10); + + const isWarmPhasePriorityChanged = + phases.warm && + phases.warm.actions.set_priority && + phases.warm.actions.set_priority.priority !== + parseInt(defaultNewWarmPhase.phaseIndexPriority, 10); + + const isColdPhasePriorityChanged = + phases.cold && + phases.cold.actions.set_priority && + phases.cold.actions.set_priority.priority !== + parseInt(defaultNewColdPhase.phaseIndexPriority, 10); + // If the priority is different than the default, we'll consider it a user interaction, + // even if the user has set it to undefined. + return ( + isHotPhasePriorityChanged || isWarmPhasePriorityChanged || isColdPhasePriorityChanged + ); }, }, { metric: UIM_CONFIG_FREEZE_INDEX, - isTracked: () => phases[PHASE_COLD] && get(phases[PHASE_COLD], 'actions.freeze'), + isTracked: () => phases.cold && phases.cold.actions.freeze, }, ]; - const trackedUiMetrics = phaseUiMetrics.reduce((tracked: any, { metric, isTracked }) => { + return phaseUiMetrics.reduce((tracked: any, { metric, isTracked }) => { if (isTracked()) { tracked.push(metric); } return tracked; }, []); - - return trackedUiMetrics; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/general.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/general.js deleted file mode 100644 index 28719fde87b0c..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/general.js +++ /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 { createAction } from 'redux-actions'; - -export const setBootstrapEnabled = createAction('SET_BOOTSTRAP_ENABLED'); -export const setIndexName = createAction('SET_INDEX_NAME'); -export const setAliasName = createAction('SET_ALIAS_NAME'); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js index ea539578c885c..fef79c7782bb0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/index.js @@ -4,7 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './nodes'; export * from './policies'; -export * from './lifecycle'; -export * from './general'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/nodes.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/nodes.js deleted file mode 100644 index 45a8e63f70e83..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/nodes.js +++ /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. - */ -import { createAction } from 'redux-actions'; -export const setSelectedPrimaryShardCount = createAction('SET_SELECTED_PRIMARY_SHARED_COUNT'); -export const setSelectedReplicaCount = createAction('SET_SELECTED_REPLICA_COUNT'); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js index aa20c0eb1d326..d47136679604f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/actions/policies.js @@ -9,7 +9,6 @@ import { createAction } from 'redux-actions'; import { showApiError } from '../../services/api_errors'; import { loadPolicies } from '../../services/api'; -import { SET_PHASE_DATA } from '../../constants'; export const fetchedPolicies = createAction('FETCHED_POLICIES'); export const setSelectedPolicy = createAction('SET_SELECTED_POLICY'); @@ -41,9 +40,3 @@ export const fetchPolicies = (withIndices, callback) => async (dispatch) => { callback && callback(); return policies; }; - -export const setPhaseData = createAction(SET_PHASE_DATA, (phase, key, value) => ({ - phase, - key, - value, -})); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/cold_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/cold_phase.js deleted file mode 100644 index a8f7fd3f4bdfa..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/cold_phase.js +++ /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 { - PHASE_ENABLED, - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_NODE_ATTRS, - PHASE_REPLICA_COUNT, - PHASE_ROLLOVER_MINIMUM_AGE_UNITS, - PHASE_ROLLOVER_ALIAS, - PHASE_FREEZE_ENABLED, - PHASE_INDEX_PRIORITY, -} from '../../constants'; - -export const defaultColdPhase = { - [PHASE_ENABLED]: false, - [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_ROLLOVER_MINIMUM_AGE]: 0, - [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', - [PHASE_NODE_ATTRS]: '', - [PHASE_REPLICA_COUNT]: '', - [PHASE_FREEZE_ENABLED]: false, - [PHASE_INDEX_PRIORITY]: 0, -}; -export const defaultEmptyColdPhase = { - ...defaultColdPhase, - [PHASE_INDEX_PRIORITY]: '', -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/delete_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/delete_phase.js deleted file mode 100644 index 8534893e7e3b3..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/delete_phase.js +++ /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 { - PHASE_ENABLED, - PHASE_ROLLOVER_ENABLED, - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_ROLLOVER_MINIMUM_AGE_UNITS, - PHASE_ROLLOVER_ALIAS, - PHASE_WAIT_FOR_SNAPSHOT_POLICY, -} from '../../constants'; - -export const defaultDeletePhase = { - [PHASE_ENABLED]: false, - [PHASE_ROLLOVER_ENABLED]: false, - [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_ROLLOVER_MINIMUM_AGE]: 0, - [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', - [PHASE_WAIT_FOR_SNAPSHOT_POLICY]: '', -}; -export const defaultEmptyDeletePhase = defaultDeletePhase; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/hot_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/hot_phase.js deleted file mode 100644 index 1f5b5c399a642..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/hot_phase.js +++ /dev/null @@ -1,35 +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 { - PHASE_ENABLED, - PHASE_ROLLOVER_ENABLED, - PHASE_ROLLOVER_MAX_AGE, - PHASE_ROLLOVER_MAX_AGE_UNITS, - PHASE_ROLLOVER_MAX_SIZE_STORED, - PHASE_ROLLOVER_MAX_DOCUMENTS, - PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS, - PHASE_INDEX_PRIORITY, -} from '../../constants'; - -export const defaultHotPhase = { - [PHASE_ENABLED]: true, - [PHASE_ROLLOVER_ENABLED]: true, - [PHASE_ROLLOVER_MAX_AGE]: 30, - [PHASE_ROLLOVER_MAX_AGE_UNITS]: 'd', - [PHASE_ROLLOVER_MAX_SIZE_STORED]: 50, - [PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS]: 'gb', - [PHASE_INDEX_PRIORITY]: 100, - [PHASE_ROLLOVER_MAX_DOCUMENTS]: '', -}; -export const defaultEmptyHotPhase = { - ...defaultHotPhase, - [PHASE_ENABLED]: false, - [PHASE_ROLLOVER_ENABLED]: false, - [PHASE_ROLLOVER_MAX_AGE]: '', - [PHASE_ROLLOVER_MAX_SIZE_STORED]: '', - [PHASE_INDEX_PRIORITY]: '', - [PHASE_ROLLOVER_MAX_DOCUMENTS]: '', -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.d.ts b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.d.ts deleted file mode 100644 index abf6db416c7f4..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.d.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 declare const defaultDeletePhase: any; -export declare const defaultColdPhase: any; -export declare const defaultWarmPhase: any; -export declare const defaultHotPhase: any; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.js deleted file mode 100644 index f5661eae91a8c..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/index.js +++ /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 * from './delete_phase'; -export * from './cold_phase'; -export * from './hot_phase'; -export * from './warm_phase'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/warm_phase.js b/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/warm_phase.js deleted file mode 100644 index f02ac2096675f..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/defaults/warm_phase.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { - PHASE_ENABLED, - PHASE_FORCE_MERGE_SEGMENTS, - PHASE_FORCE_MERGE_ENABLED, - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_NODE_ATTRS, - PHASE_PRIMARY_SHARD_COUNT, - PHASE_REPLICA_COUNT, - PHASE_ROLLOVER_MINIMUM_AGE_UNITS, - PHASE_ROLLOVER_ALIAS, - PHASE_SHRINK_ENABLED, - WARM_PHASE_ON_ROLLOVER, - PHASE_INDEX_PRIORITY, -} from '../../constants'; - -export const defaultWarmPhase = { - [PHASE_ENABLED]: false, - [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_FORCE_MERGE_SEGMENTS]: '', - [PHASE_FORCE_MERGE_ENABLED]: false, - [PHASE_ROLLOVER_MINIMUM_AGE]: 0, - [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', - [PHASE_NODE_ATTRS]: '', - [PHASE_SHRINK_ENABLED]: false, - [PHASE_PRIMARY_SHARD_COUNT]: '', - [PHASE_REPLICA_COUNT]: '', - [WARM_PHASE_ON_ROLLOVER]: true, - [PHASE_INDEX_PRIORITY]: 50, -}; -export const defaultEmptyWarmPhase = { - ...defaultWarmPhase, - [WARM_PHASE_ON_ROLLOVER]: false, - [PHASE_INDEX_PRIORITY]: '', -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/general.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/general.js deleted file mode 100644 index fcba2fd1358b0..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/general.js +++ /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 { handleActions } from 'redux-actions'; -import { setIndexName, setAliasName, setBootstrapEnabled } from '../actions/general'; - -const defaultState = { - bootstrapEnabled: false, - indexName: '', - aliasName: '', -}; - -export const general = handleActions( - { - [setIndexName](state, { payload: indexName }) { - return { - ...state, - indexName, - }; - }, - [setAliasName](state, { payload: aliasName }) { - return { - ...state, - aliasName, - }; - }, - [setBootstrapEnabled](state, { payload: bootstrapEnabled }) { - return { - ...state, - bootstrapEnabled, - }; - }, - }, - defaultState -); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js index 60126b85c313e..7fe7134f5f5db 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/index.js @@ -5,12 +5,8 @@ */ import { combineReducers } from 'redux'; -import { nodes } from './nodes'; import { policies } from './policies'; -import { general } from './general'; export const indexLifecycleManagement = combineReducers({ - nodes, policies, - general, }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/nodes.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/nodes.js deleted file mode 100644 index 383e61b5aacde..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/nodes.js +++ /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 { handleActions } from 'redux-actions'; -import { setSelectedPrimaryShardCount, setSelectedReplicaCount } from '../actions'; - -const defaultState = { - isLoading: false, - selectedNodeAttrs: '', - selectedPrimaryShardCount: 1, - selectedReplicaCount: 1, - nodes: undefined, - details: {}, -}; - -export const nodes = handleActions( - { - [setSelectedPrimaryShardCount](state, { payload }) { - let selectedPrimaryShardCount = parseInt(payload); - if (isNaN(selectedPrimaryShardCount)) { - selectedPrimaryShardCount = ''; - } - return { - ...state, - selectedPrimaryShardCount, - }; - }, - [setSelectedReplicaCount](state, { payload }) { - let selectedReplicaCount; - if (payload != null) { - selectedReplicaCount = parseInt(payload); - if (isNaN(selectedReplicaCount)) { - selectedReplicaCount = ''; - } - } else { - // default value for Elasticsearch - selectedReplicaCount = 1; - } - - return { - ...state, - selectedReplicaCount, - }; - }, - }, - defaultState -); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js index a94e875a71845..ca9d59e295a29 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/reducers/policies.js @@ -7,49 +7,17 @@ import { handleActions } from 'redux-actions'; import { fetchedPolicies, - setSelectedPolicy, - unsetSelectedPolicy, - setSelectedPolicyName, - setSaveAsNewPolicy, - setPhaseData, policyFilterChanged, policyPageChanged, policyPageSizeChanged, policySortChanged, } from '../actions'; -import { policyFromES } from '../selectors'; -import { - PHASE_HOT, - PHASE_WARM, - PHASE_COLD, - PHASE_DELETE, - PHASE_ATTRIBUTES_THAT_ARE_NUMBERS, -} from '../../constants'; - -import { - defaultColdPhase, - defaultDeletePhase, - defaultHotPhase, - defaultWarmPhase, -} from '../defaults'; -export const defaultPolicy = { - name: '', - saveAsNew: true, - isNew: true, - phases: { - [PHASE_HOT]: defaultHotPhase, - [PHASE_WARM]: defaultWarmPhase, - [PHASE_COLD]: defaultColdPhase, - [PHASE_DELETE]: defaultDeletePhase, - }, -}; const defaultState = { isLoading: false, isLoaded: false, originalPolicyName: undefined, selectedPolicySet: false, - selectedPolicy: defaultPolicy, policies: [], sort: { sortField: 'name', @@ -70,71 +38,6 @@ export const policies = handleActions( policies, }; }, - [setSelectedPolicy](state, { payload: selectedPolicy }) { - if (!selectedPolicy) { - return { - ...state, - selectedPolicy: defaultPolicy, - selectedPolicySet: true, - }; - } - - return { - ...state, - originalPolicyName: selectedPolicy.name, - selectedPolicySet: true, - selectedPolicy: { - ...defaultPolicy, - ...policyFromES(selectedPolicy), - }, - }; - }, - [unsetSelectedPolicy]() { - return defaultState; - }, - [setSelectedPolicyName](state, { payload: name }) { - return { - ...state, - selectedPolicy: { - ...state.selectedPolicy, - name, - }, - }; - }, - [setSaveAsNewPolicy](state, { payload: saveAsNew }) { - return { - ...state, - selectedPolicy: { - ...state.selectedPolicy, - saveAsNew, - }, - }; - }, - [setPhaseData](state, { payload }) { - const { phase, key } = payload; - - let value = payload.value; - if (PHASE_ATTRIBUTES_THAT_ARE_NUMBERS.includes(key)) { - value = parseInt(value); - if (isNaN(value)) { - value = ''; - } - } - - return { - ...state, - selectedPolicy: { - ...state.selectedPolicy, - phases: { - ...state.selectedPolicy.phases, - [phase]: { - ...state.selectedPolicy.phases[phase], - [key]: value, - }, - }, - }, - }; - }, [policyFilterChanged](state, action) { const { filter } = action.payload; return { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/general.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/general.js deleted file mode 100644 index 2d01749be3087..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/general.js +++ /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. - */ - -export const getBootstrapEnabled = (state) => state.general.bootstrapEnabled; -export const getIndexName = (state) => state.general.indexName; -export const getAliasName = (state) => state.general.aliasName; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js index ea539578c885c..fef79c7782bb0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/index.js @@ -4,7 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './nodes'; export * from './policies'; -export * from './lifecycle'; -export * from './general'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js deleted file mode 100644 index 03538fad9aa83..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js +++ /dev/null @@ -1,287 +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 { - PHASE_HOT, - PHASE_WARM, - PHASE_COLD, - PHASE_DELETE, - PHASE_ENABLED, - PHASE_ROLLOVER_ENABLED, - PHASE_ROLLOVER_MAX_AGE, - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_ROLLOVER_MAX_SIZE_STORED, - STRUCTURE_POLICY_NAME, - ERROR_STRUCTURE, - PHASE_ATTRIBUTES_THAT_ARE_NUMBERS_VALIDATE, - PHASE_PRIMARY_SHARD_COUNT, - PHASE_SHRINK_ENABLED, - PHASE_FORCE_MERGE_ENABLED, - PHASE_FORCE_MERGE_SEGMENTS, - PHASE_REPLICA_COUNT, - WARM_PHASE_ON_ROLLOVER, - PHASE_INDEX_PRIORITY, - PHASE_ROLLOVER_MAX_DOCUMENTS, -} from '../../constants'; - -import { - getPhase, - getPhases, - phaseToES, - getSelectedPolicyName, - isNumber, - getSaveAsNewPolicy, - getSelectedOriginalPolicyName, - getPolicies, -} from '.'; - -import { getPolicyByName } from './policies'; - -export const numberRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.numberRequiredError', - { - defaultMessage: 'A number is required.', - } -); - -export const positiveNumberRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberRequiredError', - { - defaultMessage: 'Only positive numbers are allowed.', - } -); - -export const maximumAgeRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.maximumAgeMissingError', - { - defaultMessage: 'A maximum age is required.', - } -); - -export const maximumSizeRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.maximumIndexSizeMissingError', - { - defaultMessage: 'A maximum index size is required.', - } -); - -export const maximumDocumentsRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.maximumDocumentsMissingError', - { - defaultMessage: 'Maximum documents is required.', - } -); - -export const positiveNumbersAboveZeroErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberAboveZeroRequiredError', - { - defaultMessage: 'Only numbers above 0 are allowed.', - } -); - -export const validatePhase = (type, phase, errors) => { - const phaseErrors = {}; - - if (!phase[PHASE_ENABLED]) { - return; - } - - for (const numberedAttribute of PHASE_ATTRIBUTES_THAT_ARE_NUMBERS_VALIDATE) { - if (phase.hasOwnProperty(numberedAttribute)) { - // If WARM_PHASE_ON_ROLLOVER or PHASE_HOT there is no need to validate this - if ( - numberedAttribute === PHASE_ROLLOVER_MINIMUM_AGE && - (phase[WARM_PHASE_ON_ROLLOVER] || type === PHASE_HOT) - ) { - continue; - } - // If shrink is disabled, there is no need to validate this - if (numberedAttribute === PHASE_PRIMARY_SHARD_COUNT && !phase[PHASE_SHRINK_ENABLED]) { - continue; - } - // If forcemerge is disabled, there is no need to validate this - if (numberedAttribute === PHASE_FORCE_MERGE_SEGMENTS && !phase[PHASE_FORCE_MERGE_ENABLED]) { - continue; - } - // PHASE_REPLICA_COUNT is optional and can be zero - if (numberedAttribute === PHASE_REPLICA_COUNT && !phase[numberedAttribute]) { - continue; - } - // PHASE_INDEX_PRIORITY is optional and can be zero - if (numberedAttribute === PHASE_INDEX_PRIORITY && !phase[numberedAttribute]) { - continue; - } - if (!isNumber(phase[numberedAttribute])) { - phaseErrors[numberedAttribute] = [numberRequiredMessage]; - } else if (phase[numberedAttribute] < 0) { - phaseErrors[numberedAttribute] = [positiveNumberRequiredMessage]; - } - } - } - if (phase[PHASE_ROLLOVER_ENABLED]) { - if ( - !isNumber(phase[PHASE_ROLLOVER_MAX_AGE]) && - !isNumber(phase[PHASE_ROLLOVER_MAX_SIZE_STORED]) && - !isNumber(phase[PHASE_ROLLOVER_MAX_DOCUMENTS]) - ) { - phaseErrors[PHASE_ROLLOVER_MAX_AGE] = [maximumAgeRequiredMessage]; - phaseErrors[PHASE_ROLLOVER_MAX_SIZE_STORED] = [maximumSizeRequiredMessage]; - phaseErrors[PHASE_ROLLOVER_MAX_DOCUMENTS] = [maximumDocumentsRequiredMessage]; - } - if (isNumber(phase[PHASE_ROLLOVER_MAX_AGE]) && phase[PHASE_ROLLOVER_MAX_AGE] < 1) { - phaseErrors[PHASE_ROLLOVER_MAX_AGE] = [positiveNumbersAboveZeroErrorMessage]; - } - if ( - isNumber(phase[PHASE_ROLLOVER_MAX_SIZE_STORED]) && - phase[PHASE_ROLLOVER_MAX_SIZE_STORED] < 1 - ) { - phaseErrors[PHASE_ROLLOVER_MAX_SIZE_STORED] = [positiveNumbersAboveZeroErrorMessage]; - } - if (isNumber(phase[PHASE_ROLLOVER_MAX_DOCUMENTS]) && phase[PHASE_ROLLOVER_MAX_DOCUMENTS] < 1) { - phaseErrors[PHASE_ROLLOVER_MAX_DOCUMENTS] = [positiveNumbersAboveZeroErrorMessage]; - } - } - if (phase[PHASE_SHRINK_ENABLED]) { - if (!isNumber(phase[PHASE_PRIMARY_SHARD_COUNT])) { - phaseErrors[PHASE_PRIMARY_SHARD_COUNT] = [numberRequiredMessage]; - } else if (phase[PHASE_PRIMARY_SHARD_COUNT] < 1) { - phaseErrors[PHASE_PRIMARY_SHARD_COUNT] = [positiveNumbersAboveZeroErrorMessage]; - } - } - - if (phase[PHASE_FORCE_MERGE_ENABLED]) { - if (!isNumber(phase[PHASE_FORCE_MERGE_SEGMENTS])) { - phaseErrors[PHASE_FORCE_MERGE_SEGMENTS] = [numberRequiredMessage]; - } else if (phase[PHASE_FORCE_MERGE_SEGMENTS] < 1) { - phaseErrors[PHASE_FORCE_MERGE_SEGMENTS] = [positiveNumbersAboveZeroErrorMessage]; - } - } - errors[type] = { - ...errors[type], - ...phaseErrors, - }; -}; - -export const policyNameRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError', - { - defaultMessage: 'A policy name is required.', - } -); -export const policyNameStartsWithUnderscoreErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError', - { - defaultMessage: 'A policy name cannot start with an underscore.', - } -); -export const policyNameContainsCommaErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsCommaError', - { - defaultMessage: 'A policy name cannot include a comma.', - } -); -export const policyNameContainsSpaceErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsSpaceError', - { - defaultMessage: 'A policy name cannot include a space.', - } -); -export const policyNameTooLongErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError', - { - defaultMessage: 'A policy name cannot be longer than 255 bytes.', - } -); -export const policyNameMustBeDifferentErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError', - { - defaultMessage: 'The policy name must be different.', - } -); -export const policyNameAlreadyUsedErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError', - { - defaultMessage: 'That policy name is already used.', - } -); -export const validateLifecycle = (state) => { - // This method of deep copy does not always work but it should be fine here - const errors = JSON.parse(JSON.stringify(ERROR_STRUCTURE)); - const policyName = getSelectedPolicyName(state); - if (!policyName) { - errors[STRUCTURE_POLICY_NAME].push(policyNameRequiredMessage); - } else { - if (policyName.startsWith('_')) { - errors[STRUCTURE_POLICY_NAME].push(policyNameStartsWithUnderscoreErrorMessage); - } - if (policyName.includes(',')) { - errors[STRUCTURE_POLICY_NAME].push(policyNameContainsCommaErrorMessage); - } - if (policyName.includes(' ')) { - errors[STRUCTURE_POLICY_NAME].push(policyNameContainsSpaceErrorMessage); - } - if (window.TextEncoder && new window.TextEncoder('utf-8').encode(policyName).length > 255) { - errors[STRUCTURE_POLICY_NAME].push(policyNameTooLongErrorMessage); - } - } - - if ( - getSaveAsNewPolicy(state) && - getSelectedOriginalPolicyName(state) === getSelectedPolicyName(state) - ) { - errors[STRUCTURE_POLICY_NAME].push(policyNameMustBeDifferentErrorMessage); - } else if (getSelectedOriginalPolicyName(state) !== getSelectedPolicyName(state)) { - const policyNames = getPolicies(state).map((policy) => policy.name); - if (policyNames.includes(getSelectedPolicyName(state))) { - errors[STRUCTURE_POLICY_NAME].push(policyNameAlreadyUsedErrorMessage); - } - } - - const hotPhase = getPhase(state, PHASE_HOT); - const warmPhase = getPhase(state, PHASE_WARM); - const coldPhase = getPhase(state, PHASE_COLD); - const deletePhase = getPhase(state, PHASE_DELETE); - - validatePhase(PHASE_HOT, hotPhase, errors); - validatePhase(PHASE_WARM, warmPhase, errors); - validatePhase(PHASE_COLD, coldPhase, errors); - validatePhase(PHASE_DELETE, deletePhase, errors); - return errors; -}; - -export const getLifecycle = (state) => { - const policyName = getSelectedPolicyName(state); - const phases = Object.entries(getPhases(state)).reduce((accum, [phaseName, phase]) => { - // Hot is ALWAYS enabled - if (phaseName === PHASE_HOT) { - phase[PHASE_ENABLED] = true; - } - const esPolicy = getPolicyByName(state, policyName).policy || {}; - const esPhase = esPolicy.phases ? esPolicy.phases[phaseName] : {}; - if (phase[PHASE_ENABLED]) { - accum[phaseName] = phaseToES(phase, esPhase); - - // These seem to be constants - if (phaseName === PHASE_DELETE) { - accum[phaseName].actions = { - ...accum[phaseName].actions, - delete: { - ...accum[phaseName].actions.delete, - }, - }; - } - } - return accum; - }, {}); - - return { - name: getSelectedPolicyName(state), - //type, TODO: figure this out (jsut store it and not let the user change it?) - phases, - }; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/nodes.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/nodes.js deleted file mode 100644 index 72bfd4b15a78a..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/nodes.js +++ /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 const getNodes = (state) => state.nodes.nodes; - -export const getSelectedPrimaryShardCount = (state) => state.nodes.selectedPrimaryShardCount; - -export const getSelectedReplicaCount = (state) => - state.nodes.selectedReplicaCount !== undefined ? state.nodes.selectedReplicaCount : 1; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js index 5bea22f0b3a76..e1c89314a2ec5 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/policies.js @@ -7,49 +7,9 @@ import { createSelector } from 'reselect'; import { Pager } from '@elastic/eui'; -import { - PHASE_HOT, - PHASE_WARM, - PHASE_COLD, - PHASE_DELETE, - PHASE_ROLLOVER_MINIMUM_AGE, - PHASE_ROLLOVER_MINIMUM_AGE_UNITS, - PHASE_ROLLOVER_ENABLED, - PHASE_ROLLOVER_MAX_AGE, - PHASE_ROLLOVER_MAX_AGE_UNITS, - PHASE_ROLLOVER_MAX_SIZE_STORED, - PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS, - PHASE_NODE_ATTRS, - PHASE_FORCE_MERGE_ENABLED, - PHASE_FORCE_MERGE_SEGMENTS, - PHASE_PRIMARY_SHARD_COUNT, - PHASE_REPLICA_COUNT, - PHASE_ENABLED, - PHASE_ATTRIBUTES_THAT_ARE_NUMBERS, - WARM_PHASE_ON_ROLLOVER, - PHASE_SHRINK_ENABLED, - PHASE_FREEZE_ENABLED, - PHASE_INDEX_PRIORITY, - PHASE_ROLLOVER_MAX_DOCUMENTS, - PHASE_WAIT_FOR_SNAPSHOT_POLICY, -} from '../../constants'; - import { filterItems, sortTable } from '../../services'; -import { - defaultEmptyDeletePhase, - defaultEmptyColdPhase, - defaultEmptyWarmPhase, - defaultEmptyHotPhase, -} from '../defaults'; - export const getPolicies = (state) => state.policies.policies; -export const getPolicyByName = (state, name) => - getPolicies(state).find((policy) => policy.name === name) || {}; -export const getIsNewPolicy = (state) => state.policies.selectedPolicy.isNew; -export const getSelectedPolicy = (state) => state.policies.selectedPolicy; -export const getIsSelectedPolicySet = (state) => state.policies.selectedPolicySet; -export const getSelectedOriginalPolicyName = (state) => state.policies.originalPolicyName; export const getPolicyFilter = (state) => state.policies.filter; export const getPolicySort = (state) => state.policies.sort; export const getPolicyCurrentPage = (state) => state.policies.currentPage; @@ -77,255 +37,6 @@ export const getPageOfPolicies = createSelector( (filteredPolicies, sort, pager) => { const sortedPolicies = sortTable(filteredPolicies, sort.sortField, sort.isSortAscending); const { firstItemIndex, lastItemIndex } = pager; - const pagedPolicies = sortedPolicies.slice(firstItemIndex, lastItemIndex + 1); - return pagedPolicies; + return sortedPolicies.slice(firstItemIndex, lastItemIndex + 1); } ); -export const getSaveAsNewPolicy = (state) => state.policies.selectedPolicy.saveAsNew; - -export const getSelectedPolicyName = (state) => { - if (!getSaveAsNewPolicy(state)) { - return getSelectedOriginalPolicyName(state); - } - return state.policies.selectedPolicy.name; -}; - -export const getPhases = (state) => state.policies.selectedPolicy.phases; - -export const getPhase = (state, phase) => getPhases(state)[phase]; - -export const getPhaseData = (state, phase, key) => { - if (PHASE_ATTRIBUTES_THAT_ARE_NUMBERS.includes(key)) { - return parseInt(getPhase(state, phase)[key]); - } - return getPhase(state, phase)[key]; -}; - -export const splitSizeAndUnits = (field) => { - let size; - let units; - - const result = /(\d+)(\w+)/.exec(field); - if (result) { - size = parseInt(result[1]) || 0; - units = result[2]; - } - - return { - size, - units, - }; -}; - -export const isNumber = (value) => typeof value === 'number'; -export const isEmptyObject = (obj) => { - return !obj || (Object.entries(obj).length === 0 && obj.constructor === Object); -}; - -const phaseFromES = (phase, phaseName, defaultEmptyPolicy) => { - const policy = { ...defaultEmptyPolicy }; - if (!phase) { - return policy; - } - - policy[PHASE_ENABLED] = true; - - if (phase.min_age) { - if (phaseName === PHASE_WARM && phase.min_age === '0ms') { - policy[WARM_PHASE_ON_ROLLOVER] = true; - } else { - const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phase.min_age); - policy[PHASE_ROLLOVER_MINIMUM_AGE] = minAge; - policy[PHASE_ROLLOVER_MINIMUM_AGE_UNITS] = minAgeUnits; - } - } - if (phaseName === PHASE_WARM) { - policy[PHASE_SHRINK_ENABLED] = false; - policy[PHASE_FORCE_MERGE_ENABLED] = false; - } - if (phase.actions) { - const actions = phase.actions; - - if (actions.rollover) { - const rollover = actions.rollover; - policy[PHASE_ROLLOVER_ENABLED] = true; - if (rollover.max_age) { - const { size: maxAge, units: maxAgeUnits } = splitSizeAndUnits(rollover.max_age); - policy[PHASE_ROLLOVER_MAX_AGE] = maxAge; - policy[PHASE_ROLLOVER_MAX_AGE_UNITS] = maxAgeUnits; - } - if (rollover.max_size) { - const { size: maxSize, units: maxSizeUnits } = splitSizeAndUnits(rollover.max_size); - policy[PHASE_ROLLOVER_MAX_SIZE_STORED] = maxSize; - policy[PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS] = maxSizeUnits; - } - if (rollover.max_docs) { - policy[PHASE_ROLLOVER_MAX_DOCUMENTS] = rollover.max_docs; - } - } - - if (actions.allocate) { - const allocate = actions.allocate; - if (allocate.require) { - Object.entries(allocate.require).forEach((entry) => { - policy[PHASE_NODE_ATTRS] = entry.join(':'); - }); - // checking for null or undefined here - if (allocate.number_of_replicas != null) { - policy[PHASE_REPLICA_COUNT] = allocate.number_of_replicas; - } - } - } - - if (actions.forcemerge) { - const forcemerge = actions.forcemerge; - policy[PHASE_FORCE_MERGE_ENABLED] = true; - policy[PHASE_FORCE_MERGE_SEGMENTS] = forcemerge.max_num_segments; - } - - if (actions.shrink) { - policy[PHASE_SHRINK_ENABLED] = true; - policy[PHASE_PRIMARY_SHARD_COUNT] = actions.shrink.number_of_shards; - } - - if (actions.freeze) { - policy[PHASE_FREEZE_ENABLED] = true; - } - - if (actions.set_priority) { - const { priority } = actions.set_priority; - - policy[PHASE_INDEX_PRIORITY] = priority ?? ''; - } - - if (actions.wait_for_snapshot) { - policy[PHASE_WAIT_FOR_SNAPSHOT_POLICY] = actions.wait_for_snapshot.policy; - } - } - return policy; -}; - -export const policyFromES = (policy) => { - const { - name, - policy: { phases }, - } = policy; - - return { - name, - phases: { - [PHASE_HOT]: phaseFromES(phases[PHASE_HOT], PHASE_HOT, defaultEmptyHotPhase), - [PHASE_WARM]: phaseFromES(phases[PHASE_WARM], PHASE_WARM, defaultEmptyWarmPhase), - [PHASE_COLD]: phaseFromES(phases[PHASE_COLD], PHASE_COLD, defaultEmptyColdPhase), - [PHASE_DELETE]: phaseFromES(phases[PHASE_DELETE], PHASE_DELETE, defaultEmptyDeletePhase), - }, - isNew: false, - saveAsNew: false, - }; -}; - -export const phaseToES = (phase, originalEsPhase) => { - const esPhase = { ...originalEsPhase }; - - if (!phase[PHASE_ENABLED]) { - return {}; - } - if (isNumber(phase[PHASE_ROLLOVER_MINIMUM_AGE])) { - esPhase.min_age = `${phase[PHASE_ROLLOVER_MINIMUM_AGE]}${phase[PHASE_ROLLOVER_MINIMUM_AGE_UNITS]}`; - } - - // If warm phase on rollover is enabled, delete min age field - // An index lifecycle switches to warm phase when rollover occurs, so you cannot specify a warm phase time - // They are mutually exclusive - if (phase[WARM_PHASE_ON_ROLLOVER]) { - delete esPhase.min_age; - } - - esPhase.actions = esPhase.actions || {}; - - if (phase[PHASE_ROLLOVER_ENABLED]) { - esPhase.actions.rollover = {}; - - if (isNumber(phase[PHASE_ROLLOVER_MAX_AGE])) { - esPhase.actions.rollover.max_age = `${phase[PHASE_ROLLOVER_MAX_AGE]}${phase[PHASE_ROLLOVER_MAX_AGE_UNITS]}`; - } - if (isNumber(phase[PHASE_ROLLOVER_MAX_SIZE_STORED])) { - esPhase.actions.rollover.max_size = `${phase[PHASE_ROLLOVER_MAX_SIZE_STORED]}${phase[PHASE_ROLLOVER_MAX_SIZE_STORED_UNITS]}`; - } - if (isNumber(phase[PHASE_ROLLOVER_MAX_DOCUMENTS])) { - esPhase.actions.rollover.max_docs = phase[PHASE_ROLLOVER_MAX_DOCUMENTS]; - } - } else { - delete esPhase.actions.rollover; - } - if (phase[PHASE_NODE_ATTRS]) { - const [name, value] = phase[PHASE_NODE_ATTRS].split(':'); - esPhase.actions.allocate = esPhase.actions.allocate || {}; - esPhase.actions.allocate.require = { - [name]: value, - }; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.require; - } - } - if (isNumber(phase[PHASE_REPLICA_COUNT])) { - esPhase.actions.allocate = esPhase.actions.allocate || {}; - esPhase.actions.allocate.number_of_replicas = phase[PHASE_REPLICA_COUNT]; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.number_of_replicas; - } - } - if ( - esPhase.actions.allocate && - !esPhase.actions.allocate.require && - !isNumber(esPhase.actions.allocate.number_of_replicas) && - isEmptyObject(esPhase.actions.allocate.include) && - isEmptyObject(esPhase.actions.allocate.exclude) - ) { - // remove allocate action if it does not define require or number of nodes - // and both include and exclude are empty objects (ES will fail to parse if we don't) - delete esPhase.actions.allocate; - } - - if (phase[PHASE_FORCE_MERGE_ENABLED]) { - esPhase.actions.forcemerge = { - max_num_segments: phase[PHASE_FORCE_MERGE_SEGMENTS], - }; - } else { - delete esPhase.actions.forcemerge; - } - - if (phase[PHASE_SHRINK_ENABLED] && isNumber(phase[PHASE_PRIMARY_SHARD_COUNT])) { - esPhase.actions.shrink = { - number_of_shards: phase[PHASE_PRIMARY_SHARD_COUNT], - }; - } else { - delete esPhase.actions.shrink; - } - - if (phase[PHASE_FREEZE_ENABLED]) { - esPhase.actions.freeze = {}; - } else { - delete esPhase.actions.freeze; - } - if (isNumber(phase[PHASE_INDEX_PRIORITY])) { - esPhase.actions.set_priority = { - priority: phase[PHASE_INDEX_PRIORITY], - }; - } else if (phase[PHASE_INDEX_PRIORITY] === '') { - esPhase.actions.set_priority = { - priority: null, - }; - } - - if (phase[PHASE_WAIT_FOR_SNAPSHOT_POLICY]) { - esPhase.actions.wait_for_snapshot = { - policy: phase[PHASE_WAIT_FOR_SNAPSHOT_POLICY], - }; - } else { - delete esPhase.actions.wait_for_snapshot; - } - return esPhase; -}; From e9446b2060efd1d25f3a7bda4ee9298cb7844e06 Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Tue, 25 Aug 2020 13:34:29 -0400 Subject: [PATCH 03/56] [Resolver] restore function to the resolverTest plugin. (#75799) Restore the resolverTest plugin. This will allow us to run the test plugin and try out Resolver using our mock data access layers. Eventually this could be expanded to support multiple different data access layers. It could even be expanded to allow us to control the data access layer via the browser. Another option: we could export the APIs from the server and use those in this test plugin. We eventually expect other plugins to use Resolver. This test plugin could allow us to test Resolver via the FTR (separately of the Security Solution.) This would also be useful for writing tests than use the FTR but which are essentially unit tests. For example: taking screenshots, using the mouse to zoom/pan. Start using: `yarn start --plugin-path x-pack/test/plugin_functional/plugins/resolver_test/` --- .../public/common/store/epic.ts | 12 +- .../public/common/store/store.ts | 6 +- .../security_solution/public/plugin.tsx | 9 +- .../public/resolver/index.ts | 30 ++++ .../public/resolver/store/index.ts | 2 +- .../test_utilities/simulator/index.tsx | 2 +- .../public/resolver/types.ts | 42 ++++- .../public/resolver/view/index.tsx | 4 +- .../public/timelines/store/timeline/types.ts | 4 +- .../plugins/security_solution/public/types.ts | 6 +- .../plugins/resolver_test/kibana.json | 9 +- .../applications/resolver_test/index.tsx | 158 ++++++++---------- .../plugins/resolver_test/public/plugin.ts | 48 +++--- 13 files changed, 195 insertions(+), 137 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/index.ts diff --git a/x-pack/plugins/security_solution/public/common/store/epic.ts b/x-pack/plugins/security_solution/public/common/store/epic.ts index d9de7951a86f4..51a9377b9fd04 100644 --- a/x-pack/plugins/security_solution/public/common/store/epic.ts +++ b/x-pack/plugins/security_solution/public/common/store/epic.ts @@ -4,14 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { combineEpics } from 'redux-observable'; +import { combineEpics, Epic } from 'redux-observable'; +import { Action } from 'redux'; + import { createTimelineEpic } from '../../timelines/store/timeline/epic'; import { createTimelineFavoriteEpic } from '../../timelines/store/timeline/epic_favorite'; import { createTimelineNoteEpic } from '../../timelines/store/timeline/epic_note'; import { createTimelinePinnedEventEpic } from '../../timelines/store/timeline/epic_pinned_event'; import { createTimelineLocalStorageEpic } from '../../timelines/store/timeline/epic_local_storage'; +import { TimelineEpicDependencies } from '../../timelines/store/timeline/types'; -export const createRootEpic = () => +export const createRootEpic = (): Epic< + Action, + Action, + State, + TimelineEpicDependencies +> => combineEpics( createTimelineEpic(), createTimelineFavoriteEpic(), diff --git a/x-pack/plugins/security_solution/public/common/store/store.ts b/x-pack/plugins/security_solution/public/common/store/store.ts index a39c9f18bcdb8..f041e1fd82a9f 100644 --- a/x-pack/plugins/security_solution/public/common/store/store.ts +++ b/x-pack/plugins/security_solution/public/common/store/store.ts @@ -13,6 +13,7 @@ import { Middleware, Dispatch, PreloadedState, + CombinedState, } from 'redux'; import { createEpicMiddleware } from 'redux-observable'; @@ -30,6 +31,7 @@ import { Immutable } from '../../../common/endpoint/types'; import { State } from './types'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; import { CoreStart } from '../../../../../../src/core/public'; +import { TimelineEpicDependencies } from '../../timelines/store/timeline/types'; type ComposeType = typeof compose; declare global { @@ -56,7 +58,7 @@ export const createStore = ( ): Store => { const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; - const middlewareDependencies = { + const middlewareDependencies: TimelineEpicDependencies = { apolloClient$: apolloClient, kibana$: kibana, selectAllTimelineQuery: inputsSelectors.globalQueryByIdSelector, @@ -80,7 +82,7 @@ export const createStore = ( ) ); - epicMiddleware.run(createRootEpic()); + epicMiddleware.run(createRootEpic>()); return store; }; diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index f1a933fb34d66..a691dd98e7081 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -66,7 +66,7 @@ export class Plugin implements IPlugin, plugins: SetupPlugins) { + public setup(core: CoreSetup, plugins: SetupPlugins): PluginSetup { initTelemetry(plugins.usageCollection, APP_ID); plugins.home.featureCatalogue.register({ @@ -319,7 +319,12 @@ export class Plugin implements IPlugin { + const { resolverPluginSetup } = await import('./resolver'); + return resolverPluginSetup(); + }, + }; } public start(core: CoreStart, plugins: StartPlugins) { diff --git a/x-pack/plugins/security_solution/public/resolver/index.ts b/x-pack/plugins/security_solution/public/resolver/index.ts new file mode 100644 index 0000000000000..409f82c9d1560 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/index.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 { Provider } from 'react-redux'; +import { ResolverPluginSetup } from './types'; +import { resolverStoreFactory } from './store/index'; +import { ResolverWithoutProviders } from './view/resolver_without_providers'; +import { noAncestorsTwoChildren } from './data_access_layer/mocks/no_ancestors_two_children'; + +/** + * These exports are used by the plugin 'resolverTest' defined in x-pack's plugin_functional suite. + */ + +/** + * Provide access to Resolver APIs. + */ +export function resolverPluginSetup(): ResolverPluginSetup { + return { + Provider, + storeFactory: resolverStoreFactory, + ResolverWithoutProviders, + mocks: { + dataAccessLayer: { + noAncestorsTwoChildren, + }, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/index.ts b/x-pack/plugins/security_solution/public/resolver/store/index.ts index 950a61db33f17..ed8a5129c7ff6 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/index.ts @@ -11,7 +11,7 @@ import { resolverReducer } from './reducer'; import { resolverMiddlewareFactory } from './middleware'; import { ResolverAction } from './actions'; -export const storeFactory = ( +export const resolverStoreFactory = ( dataAccessLayer: DataAccessLayer ): Store => { const actionsDenylist: Array = ['userMovedPointer']; diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx index b79b7df48a6de..a6520c8f0e06f 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { Store, createStore, applyMiddleware } from 'redux'; import { mount, ReactWrapper } from 'enzyme'; -import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history'; +import { History as HistoryPackageHistoryInterface, createMemoryHistory } from 'history'; import { CoreStart } from '../../../../../../../src/core/public'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { spyMiddlewareFactory } from '../spy_middleware_factory'; diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 97d97700b11ae..33f7a1d97db13 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -9,6 +9,7 @@ import { Store } from 'redux'; import { Middleware, Dispatch } from 'redux'; import { BBox } from 'rbush'; +import { Provider } from 'react-redux'; import { ResolverAction } from './store/actions'; import { ResolverRelatedEvents, @@ -410,7 +411,7 @@ export interface SideEffectSimulator { /** * Mocked `SideEffectors`. */ - mock: jest.Mocked> & Pick; + mock: SideEffectors; } /** @@ -532,3 +533,42 @@ export interface SpyMiddleware { */ debugActions: () => () => void; } + +/** + * values of this type are exposed by the Security Solution plugin's setup phase. + */ +export interface ResolverPluginSetup { + /** + * Provide access to the instance of the `react-redux` `Provider` that Resolver recognizes. + */ + Provider: typeof Provider; + /** + * Takes a `DataAccessLayer`, which could be a mock one, and returns an redux Store. + * All data acess (e.g. HTTP requests) are done through the store. + */ + storeFactory: (dataAccessLayer: DataAccessLayer) => Store; + + /** + * The Resolver component without the required Providers. + * You must wrap this component in: `I18nProvider`, `Router` (from react-router,) `KibanaContextProvider`, + * and the `Provider` component provided by this object. + */ + ResolverWithoutProviders: React.MemoExoticComponent< + React.ForwardRefExoticComponent> + >; + + /** + * A collection of mock objects that can be used in examples or in testing. + */ + mocks: { + /** + * Mock `DataAccessLayer`s. All of Resolver's HTTP access is provided by a `DataAccessLayer`. + */ + dataAccessLayer: { + /** + * A mock `DataAccessLayer` that returns a tree that has no ancestor nodes but which has 2 children nodes. + */ + noAncestorsTwoChildren: () => { dataAccessLayer: DataAccessLayer }; + }; + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/index.tsx index d9a0bf291d0e4..bcc420435e5d9 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/index.tsx @@ -7,7 +7,7 @@ import React, { useMemo } from 'react'; import { Provider } from 'react-redux'; -import { storeFactory } from '../store'; +import { resolverStoreFactory } from '../store'; import { StartServices } from '../../types'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { DataAccessLayer, ResolverProps } from '../types'; @@ -24,7 +24,7 @@ export const Resolver = React.memo((props: ResolverProps) => { ]); const store = useMemo(() => { - return storeFactory(dataAccessLayer); + return resolverStoreFactory(dataAccessLayer); }, [dataAccessLayer]); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts index c64ed608339b6..8a5344e0754db 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts @@ -10,9 +10,9 @@ import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; import { AppApolloClient } from '../../../common/lib/lib'; import { inputsModel } from '../../../common/store/inputs'; import { NotesById } from '../../../common/store/app/model'; -import { StartServices } from '../../../types'; import { TimelineModel } from './model'; +import { CoreStart } from '../../../../../../../src/core/public'; export interface AutoSavedWarningMsg { timelineId: string | null; @@ -55,6 +55,6 @@ export interface TimelineEpicDependencies { selectAllTimelineQuery: () => (state: State, id: string) => inputsModel.GlobalQuery; selectNotesByIdSelector: (state: State) => NotesById; apolloClient$: Observable; - kibana$: Observable; + kibana$: Observable; storage: Storage; } diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 3913b96b3e11a..fd1ff566a7719 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -21,6 +21,7 @@ import { } from '../../triggers_actions_ui/public'; import { SecurityPluginSetup } from '../../security/public'; import { AppFrontendLibs } from './common/lib/lib'; +import { ResolverPluginSetup } from './resolver/types'; export interface SetupPlugins { home: HomePublicPluginSetup; @@ -46,8 +47,9 @@ export type StartServices = CoreStart & storage: Storage; }; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PluginSetup {} +export interface PluginSetup { + resolver: () => Promise; +} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PluginStart {} diff --git a/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json b/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json index c715a0aaa3b20..499983561e89d 100644 --- a/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json +++ b/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json @@ -2,8 +2,13 @@ "id": "resolver_test", "version": "1.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "resolver_test"], - "requiredPlugins": ["embeddable"], + "configPath": ["xpack", "resolverTest"], + "requiredPlugins": [ + "securitySolution" + ], + "requiredBundles": [ + "kibanaReact" + ], "server": false, "ui": true } diff --git a/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx b/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx index 79665b6a393df..4afd71fd67a69 100644 --- a/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx +++ b/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx @@ -4,119 +4,95 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; +import { Router } from 'react-router-dom'; + +import React from 'react'; import ReactDOM from 'react-dom'; -import { AppMountParameters } from 'kibana/public'; -import { I18nProvider } from '@kbn/i18n/react'; -import { IEmbeddable } from 'src/plugins/embeddable/public'; -import { useEffect } from 'react'; +import { AppMountParameters, CoreStart } from 'kibana/public'; +import { useMemo } from 'react'; import styled from 'styled-components'; +import { I18nProvider } from '@kbn/i18n/react'; +import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public'; +import { + DataAccessLayer, + ResolverPluginSetup, +} from '../../../../../../../plugins/security_solution/public/resolver/types'; /** * Render the Resolver Test app. Returns a cleanup function. */ export function renderApp( - { element }: AppMountParameters, - embeddable: Promise + coreStart: CoreStart, + parameters: AppMountParameters, + resolverPluginSetup: ResolverPluginSetup ) { /** * The application DOM node should take all available space. */ - element.style.display = 'flex'; - element.style.flexGrow = '1'; + parameters.element.style.display = 'flex'; + parameters.element.style.flexGrow = '1'; ReactDOM.render( - - - , - element + , + parameters.element ); return () => { - ReactDOM.unmountComponentAtNode(element); + ReactDOM.unmountComponentAtNode(parameters.element); }; } -const AppRoot = styled( - React.memo( - ({ - embeddable: embeddablePromise, - className, - }: { - /** - * A promise which resolves to the Resolver embeddable. - */ - embeddable: Promise; - /** - * A `className` string provided by `styled` - */ - className?: string; - }) => { - /** - * This state holds the reference to the embeddable, once resolved. - */ - const [embeddable, setEmbeddable] = React.useState(undefined); - /** - * This state holds the reference to the DOM node that will contain the embeddable. - */ - const [renderTarget, setRenderTarget] = React.useState(null); - - /** - * Keep component state with the Resolver embeddable. - * - * If the reference to the embeddablePromise changes, we ignore the stale promise. - */ - useEffect(() => { - /** - * A promise rejection function that will prevent a stale embeddable promise from being resolved - * as the current eembeddable. - * - * If the embeddablePromise itself changes before the old one is resolved, we cancel and restart this effect. - */ - let cleanUp; - - const cleanupPromise = new Promise((_resolve, reject) => { - cleanUp = reject; - }); - - /** - * Either set the embeddable in state, or cancel and restart this process. - */ - Promise.race([cleanupPromise, embeddablePromise]).then((value) => { - setEmbeddable(value); - }); +const AppRoot = React.memo( + ({ + coreStart, + parameters, + resolverPluginSetup, + }: { + coreStart: CoreStart; + parameters: AppMountParameters; + resolverPluginSetup: ResolverPluginSetup; + }) => { + const { + Provider, + storeFactory, + ResolverWithoutProviders, + mocks: { + dataAccessLayer: { noAncestorsTwoChildren }, + }, + } = resolverPluginSetup; + const dataAccessLayer: DataAccessLayer = useMemo( + () => noAncestorsTwoChildren().dataAccessLayer, + [noAncestorsTwoChildren] + ); - /** - * If `embeddablePromise` is changed, the cleanup function is run. - */ - return cleanUp; - }, [embeddablePromise]); + const store = useMemo(() => { + return storeFactory(dataAccessLayer); + }, [storeFactory, dataAccessLayer]); - /** - * Render the eembeddable into the DOM node. - */ - useEffect(() => { - if (embeddable && renderTarget) { - embeddable.render(renderTarget); - /** - * If the embeddable or DOM node changes then destroy the old embeddable. - */ - return () => { - embeddable.destroy(); - }; - } - }, [embeddable, renderTarget]); + return ( + + + + + + + + + + + + ); + } +); - return ( -
- ); - } - ) -)` +const Wrapper = styled.div` /** * Take all available space. */ diff --git a/x-pack/test/plugin_functional/plugins/resolver_test/public/plugin.ts b/x-pack/test/plugin_functional/plugins/resolver_test/public/plugin.ts index 853265ae6e5de..3da3044283556 100644 --- a/x-pack/test/plugin_functional/plugins/resolver_test/public/plugin.ts +++ b/x-pack/test/plugin_functional/plugins/resolver_test/public/plugin.ts @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Plugin, CoreSetup } from 'kibana/public'; +import { Plugin, CoreSetup, AppMountParameters } from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { IEmbeddable, EmbeddableStart } from '../../../../../../src/plugins/embeddable/public'; +import { PluginSetup as SecuritySolutionPluginSetup } from '../../../../../plugins/security_solution/public'; export type ResolverTestPluginSetup = void; export type ResolverTestPluginStart = void; -export interface ResolverTestPluginSetupDependencies {} // eslint-disable-line @typescript-eslint/no-empty-interface -export interface ResolverTestPluginStartDependencies { - embeddable: EmbeddableStart; +export interface ResolverTestPluginSetupDependencies { + securitySolution: SecuritySolutionPluginSetup; } +export interface ResolverTestPluginStartDependencies {} // eslint-disable-line @typescript-eslint/no-empty-interface export class ResolverTestPlugin implements @@ -23,34 +23,24 @@ export class ResolverTestPlugin ResolverTestPluginSetupDependencies, ResolverTestPluginStartDependencies > { - public setup(core: CoreSetup) { + public setup( + core: CoreSetup, + setupDependencies: ResolverTestPluginSetupDependencies + ) { core.application.register({ - id: 'resolver_test', - title: i18n.translate('xpack.resolver_test.pluginTitle', { + id: 'resolverTest', + title: i18n.translate('xpack.resolverTest.pluginTitle', { defaultMessage: 'Resolver Test', }), - mount: async (_context, params) => { - let resolveEmbeddable: ( - value: IEmbeddable | undefined | PromiseLike | undefined - ) => void; + mount: async (params: AppMountParameters) => { + const startServices = await core.getStartServices(); + const [coreStart] = startServices; - const promise = new Promise((resolve) => { - resolveEmbeddable = resolve; - }); - - (async () => { - const [, { embeddable }] = await core.getStartServices(); - const factory = embeddable.getEmbeddableFactory('resolver'); - if (factory) { - resolveEmbeddable!(factory.create({ id: 'test basic render' })); - } - })(); - - const { renderApp } = await import('./applications/resolver_test'); - /** - * Pass a promise which resolves to the Resolver embeddable. - */ - return renderApp(params, promise); + const [{ renderApp }, resolverPluginSetup] = await Promise.all([ + import('./applications/resolver_test'), + setupDependencies.securitySolution.resolver(), + ]); + return renderApp(coreStart, params, resolverPluginSetup); }, }); } From e236bdf4af95a8219e30a9e176d3f2169cb19ab8 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Tue, 25 Aug 2020 12:13:57 -0600 Subject: [PATCH 04/56] [Maps] add message to empty add tooltip card (#75809) * [Maps] add message to empty add tooltip card * use suggested text --- .../tooltip_selector/tooltip_selector.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/maps/public/components/tooltip_selector/tooltip_selector.tsx b/x-pack/plugins/maps/public/components/tooltip_selector/tooltip_selector.tsx index 84316a1b9105d..9bab590d1f5ea 100644 --- a/x-pack/plugins/maps/public/components/tooltip_selector/tooltip_selector.tsx +++ b/x-pack/plugins/maps/public/components/tooltip_selector/tooltip_selector.tsx @@ -13,8 +13,10 @@ import { EuiDroppable, EuiText, EuiTextAlign, + EuiTextColor, EuiSpacer, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { AddTooltipFieldPopover, FieldProps } from './add_tooltip_field_popover'; import { IField } from '../../classes/fields/field'; @@ -156,7 +158,18 @@ export class TooltipSelector extends Component { _renderProperties() { if (!this.state.selectedFieldProps.length) { - return null; + return ( + +

+ + + +

+
+ ); } return ( From f2fef70282d7d2e7ecbf4e38b6b9cc075b51f361 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Tue, 25 Aug 2020 14:21:35 -0600 Subject: [PATCH 05/56] Migrate legacy map UI settings (#75887) * Migrate legacy map UI settings * i18n fixes --- .../kibana/server/ui_setting_defaults.js | 83 +------------ src/plugins/maps_legacy/server/index.ts | 7 +- src/plugins/maps_legacy/server/ui_settings.ts | 113 ++++++++++++++++++ src/plugins/region_map/server/index.ts | 7 +- src/plugins/region_map/server/ui_settings.ts | 42 +++++++ .../translations/translations/ja-JP.json | 8 -- .../translations/translations/zh-CN.json | 8 -- 7 files changed, 167 insertions(+), 101 deletions(-) create mode 100644 src/plugins/maps_legacy/server/ui_settings.ts create mode 100644 src/plugins/region_map/server/ui_settings.ts diff --git a/src/legacy/core_plugins/kibana/server/ui_setting_defaults.js b/src/legacy/core_plugins/kibana/server/ui_setting_defaults.js index 2562657a71624..7de5fb581643a 100644 --- a/src/legacy/core_plugins/kibana/server/ui_setting_defaults.js +++ b/src/legacy/core_plugins/kibana/server/ui_setting_defaults.js @@ -17,88 +17,7 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; - export function getUiSettingDefaults() { // wrapped in provider so that a new instance is given to each app/test - return { - 'visualization:tileMap:maxPrecision': { - name: i18n.translate('kbn.advancedSettings.visualization.tileMap.maxPrecisionTitle', { - defaultMessage: 'Maximum tile map precision', - }), - value: 7, - description: i18n.translate('kbn.advancedSettings.visualization.tileMap.maxPrecisionText', { - defaultMessage: - 'The maximum geoHash precision displayed on tile maps: 7 is high, 10 is very high, 12 is the max. {cellDimensionsLink}', - description: - 'Part of composite text: kbn.advancedSettings.visualization.tileMap.maxPrecisionText + ' + - 'kbn.advancedSettings.visualization.tileMap.maxPrecision.cellDimensionsLinkText', - values: { - cellDimensionsLink: - `` + - i18n.translate( - 'kbn.advancedSettings.visualization.tileMap.maxPrecision.cellDimensionsLinkText', - { - defaultMessage: 'Explanation of cell dimensions', - } - ) + - '', - }, - }), - category: ['visualization'], - }, - 'visualization:tileMap:WMSdefaults': { - name: i18n.translate('kbn.advancedSettings.visualization.tileMap.wmsDefaultsTitle', { - defaultMessage: 'Default WMS properties', - }), - value: JSON.stringify( - { - enabled: false, - url: undefined, - options: { - version: undefined, - layers: undefined, - format: 'image/png', - transparent: true, - attribution: undefined, - styles: undefined, - }, - }, - null, - 2 - ), - type: 'json', - description: i18n.translate('kbn.advancedSettings.visualization.tileMap.wmsDefaultsText', { - defaultMessage: - 'Default {propertiesLink} for the WMS map server support in the coordinate map', - description: - 'Part of composite text: kbn.advancedSettings.visualization.tileMap.wmsDefaultsText + ' + - 'kbn.advancedSettings.visualization.tileMap.wmsDefaults.propertiesLinkText', - values: { - propertiesLink: - '' + - i18n.translate( - 'kbn.advancedSettings.visualization.tileMap.wmsDefaults.propertiesLinkText', - { - defaultMessage: 'properties', - } - ) + - '', - }, - }), - category: ['visualization'], - }, - 'visualization:regionmap:showWarnings': { - name: i18n.translate('kbn.advancedSettings.visualization.showRegionMapWarningsTitle', { - defaultMessage: 'Show region map warning', - }), - value: true, - description: i18n.translate('kbn.advancedSettings.visualization.showRegionMapWarningsText', { - defaultMessage: - 'Whether the region map shows a warning when terms cannot be joined to a shape on the map.', - }), - category: ['visualization'], - }, - }; + return {}; } diff --git a/src/plugins/maps_legacy/server/index.ts b/src/plugins/maps_legacy/server/index.ts index 5da3ce1a84408..79ecbb238314a 100644 --- a/src/plugins/maps_legacy/server/index.ts +++ b/src/plugins/maps_legacy/server/index.ts @@ -18,9 +18,10 @@ */ import { Plugin, PluginConfigDescriptor } from 'kibana/server'; -import { PluginInitializerContext } from 'src/core/server'; +import { CoreSetup, PluginInitializerContext } from 'src/core/server'; import { Observable } from 'rxjs'; import { configSchema, ConfigSchema } from '../config'; +import { getUiSettings } from './ui_settings'; export const config: PluginConfigDescriptor = { exposeToBrowser: { @@ -49,7 +50,9 @@ export class MapsLegacyPlugin implements Plugin { this._initializerContext = initializerContext; } - public setup() { + public setup(core: CoreSetup) { + core.uiSettings.register(getUiSettings()); + // @ts-ignore const config$ = this._initializerContext.config.create(); return { diff --git a/src/plugins/maps_legacy/server/ui_settings.ts b/src/plugins/maps_legacy/server/ui_settings.ts new file mode 100644 index 0000000000000..f92ccf848f409 --- /dev/null +++ b/src/plugins/maps_legacy/server/ui_settings.ts @@ -0,0 +1,113 @@ +/* + * 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 { UiSettingsParams } from 'kibana/server'; +import { schema } from '@kbn/config-schema'; + +export function getUiSettings(): Record> { + return { + 'visualization:tileMap:maxPrecision': { + name: i18n.translate('maps_legacy.advancedSettings.visualization.tileMap.maxPrecisionTitle', { + defaultMessage: 'Maximum tile map precision', + }), + value: 7, + description: i18n.translate( + 'maps_legacy.advancedSettings.visualization.tileMap.maxPrecisionText', + { + defaultMessage: + 'The maximum geoHash precision displayed on tile maps: 7 is high, 10 is very high, 12 is the max. {cellDimensionsLink}', + description: + 'Part of composite text: maps_legacy.advancedSettings.visualization.tileMap.maxPrecisionText + ' + + 'maps_legacy.advancedSettings.visualization.tileMap.maxPrecision.cellDimensionsLinkText', + values: { + cellDimensionsLink: + `` + + i18n.translate( + 'maps_legacy.advancedSettings.visualization.tileMap.maxPrecision.cellDimensionsLinkText', + { + defaultMessage: 'Explanation of cell dimensions', + } + ) + + '', + }, + } + ), + schema: schema.number(), + category: ['visualization'], + }, + 'visualization:tileMap:WMSdefaults': { + name: i18n.translate('maps_legacy.advancedSettings.visualization.tileMap.wmsDefaultsTitle', { + defaultMessage: 'Default WMS properties', + }), + value: JSON.stringify( + { + enabled: false, + url: '', + options: { + version: '', + layers: '', + format: 'image/png', + transparent: true, + attribution: '', + styles: '', + }, + }, + null, + 2 + ), + type: 'json', + description: i18n.translate( + 'maps_legacy.advancedSettings.visualization.tileMap.wmsDefaultsText', + { + defaultMessage: + 'Default {propertiesLink} for the WMS map server support in the coordinate map', + description: + 'Part of composite text: maps_legacy.advancedSettings.visualization.tileMap.wmsDefaultsText + ' + + 'maps_legacy.advancedSettings.visualization.tileMap.wmsDefaults.propertiesLinkText', + values: { + propertiesLink: + '' + + i18n.translate( + 'maps_legacy.advancedSettings.visualization.tileMap.wmsDefaults.propertiesLinkText', + { + defaultMessage: 'properties', + } + ) + + '', + }, + } + ), + schema: schema.object({ + enabled: schema.boolean(), + url: schema.string(), + options: schema.object({ + version: schema.string(), + layers: schema.string(), + format: schema.string(), + transparent: schema.boolean(), + attribution: schema.string(), + styles: schema.string(), + }), + }), + category: ['visualization'], + }, + }; +} diff --git a/src/plugins/region_map/server/index.ts b/src/plugins/region_map/server/index.ts index e2c544d2d0ba6..f4684e1c60349 100644 --- a/src/plugins/region_map/server/index.ts +++ b/src/plugins/region_map/server/index.ts @@ -18,7 +18,9 @@ */ import { PluginConfigDescriptor } from 'kibana/server'; +import { CoreSetup } from 'src/core/server'; import { configSchema, ConfigSchema } from '../config'; +import { getUiSettings } from './ui_settings'; export const config: PluginConfigDescriptor = { exposeToBrowser: { @@ -29,6 +31,9 @@ export const config: PluginConfigDescriptor = { }; export const plugin = () => ({ - setup() {}, + setup(core: CoreSetup) { + core.uiSettings.register(getUiSettings()); + }, + start() {}, }); diff --git a/src/plugins/region_map/server/ui_settings.ts b/src/plugins/region_map/server/ui_settings.ts new file mode 100644 index 0000000000000..9c404676b9ffd --- /dev/null +++ b/src/plugins/region_map/server/ui_settings.ts @@ -0,0 +1,42 @@ +/* + * 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 { UiSettingsParams } from 'kibana/server'; +import { schema } from '@kbn/config-schema'; + +export function getUiSettings(): Record> { + return { + 'visualization:regionmap:showWarnings': { + name: i18n.translate('regionMap.advancedSettings.visualization.showRegionMapWarningsTitle', { + defaultMessage: 'Show region map warning', + }), + value: true, + description: i18n.translate( + 'regionMap.advancedSettings.visualization.showRegionMapWarningsText', + { + defaultMessage: + 'Whether the region map shows a warning when terms cannot be joined to a shape on the map.', + } + ), + schema: schema.boolean(), + category: ['visualization'], + }, + }; +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 118362f494b47..0b51c00475d7e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2775,14 +2775,6 @@ "inspector.requests.statisticsTabLabel": "統計", "inspector.title": "インスペクター", "inspector.view": "{viewName} を表示", - "kbn.advancedSettings.visualization.showRegionMapWarningsText": "用語がマップの形に合わない場合に地域マップに警告を表示するかどうかです。", - "kbn.advancedSettings.visualization.showRegionMapWarningsTitle": "地域マップに警告を表示", - "kbn.advancedSettings.visualization.tileMap.maxPrecision.cellDimensionsLinkText": "ディメンションの説明", - "kbn.advancedSettings.visualization.tileMap.maxPrecisionText": "マップに表示されるジオハッシュの最高精度です。7 が高い、10 が非常に高い、12 が最高を意味します。{cellDimensionsLink}", - "kbn.advancedSettings.visualization.tileMap.maxPrecisionTitle": "タイルマップの最高精度", - "kbn.advancedSettings.visualization.tileMap.wmsDefaults.propertiesLinkText": "プロパティ", - "kbn.advancedSettings.visualization.tileMap.wmsDefaultsText": "座標マップの WMS マップサーバーサポートのデフォルトの {propertiesLink} です。", - "kbn.advancedSettings.visualization.tileMap.wmsDefaultsTitle": "デフォルトの WMS プロパティ", "kibana_legacy.notify.fatalError.errorStatusMessage": "エラー {errStatus} {errStatusText}: {errMessage}", "kibana_legacy.notify.fatalError.unavailableServerErrorMessage": "HTTP リクエストで接続に失敗しました。Kibana サーバーが実行されていて、ご使用のブラウザの接続が正常に動作していることを確認するか、システム管理者にお問い合わせください。", "kibana_legacy.notify.toaster.errorMessage": "エラー: {errorMessage}\n {errorStack}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index de1f206118447..d520f63fe7484 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2776,14 +2776,6 @@ "inspector.requests.statisticsTabLabel": "统计信息", "inspector.title": "检查器", "inspector.view": "视图:{viewName}", - "kbn.advancedSettings.visualization.showRegionMapWarningsText": "词无法联接到地图上的形状时,区域地图是否显示警告。", - "kbn.advancedSettings.visualization.showRegionMapWarningsTitle": "显示区域地图警告", - "kbn.advancedSettings.visualization.tileMap.maxPrecision.cellDimensionsLinkText": "单元格维度的解释", - "kbn.advancedSettings.visualization.tileMap.maxPrecisionText": "在磁贴地图上显示的最大 geoHash 精确度:7 为高,10 为很高,12 为最大值。{cellDimensionsLink}", - "kbn.advancedSettings.visualization.tileMap.maxPrecisionTitle": "最大磁贴地图精确度", - "kbn.advancedSettings.visualization.tileMap.wmsDefaults.propertiesLinkText": "属性", - "kbn.advancedSettings.visualization.tileMap.wmsDefaultsText": "坐标地图中 WMS 地图服务器支持的默认{propertiesLink}", - "kbn.advancedSettings.visualization.tileMap.wmsDefaultsTitle": "默认 WMS 属性", "kibana_legacy.notify.fatalError.errorStatusMessage": "错误 {errStatus} {errStatusText}:{errMessage}", "kibana_legacy.notify.fatalError.unavailableServerErrorMessage": "HTTP 请求无法连接。请检查 Kibana 服务器是否正在运行以及您的浏览器是否具有有效的连接,或请联系您的系统管理员。", "kibana_legacy.notify.toaster.errorMessage": "错误:{errorMessage}\n {errorStack}", From c3e226cf31899203c69d8d0616861c7dadecfc3e Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Tue, 25 Aug 2020 14:24:14 -0600 Subject: [PATCH 06/56] [Maps] Originating App Breadcrumb (#75692) * [Maps] Originating App Breadcrumb * pass getHasUnsavedChanges instead of passing boolean Co-authored-by: Elastic Machine --- .../routes/maps_app/get_breadcrumbs.test.tsx | 36 +++++++++++ .../routes/maps_app/get_breadcrumbs.tsx | 59 +++++++++++++++++++ .../routing/routes/maps_app/maps_app_view.js | 36 ++++------- 3 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.test.tsx create mode 100644 x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.tsx diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.test.tsx b/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.test.tsx new file mode 100644 index 0000000000000..e8e0e583a7c6d --- /dev/null +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.test.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 { getBreadcrumbs } from './get_breadcrumbs'; + +jest.mock('../../../kibana_services', () => {}); +jest.mock('../../maps_router', () => {}); + +const getHasUnsavedChanges = () => { + return false; +}; + +test('should get breadcrumbs "Maps / mymap"', () => { + const breadcrumbs = getBreadcrumbs({ title: 'mymap', getHasUnsavedChanges }); + expect(breadcrumbs.length).toBe(2); + expect(breadcrumbs[0].text).toBe('Maps'); + expect(breadcrumbs[1].text).toBe('mymap'); +}); + +test('should get breadcrumbs "Dashboard / Maps / mymap" with originatingApp', () => { + const breadcrumbs = getBreadcrumbs({ + title: 'mymap', + getHasUnsavedChanges, + originatingApp: 'dashboardId', + getAppNameFromId: (appId) => { + return 'Dashboard'; + }, + }); + expect(breadcrumbs.length).toBe(3); + expect(breadcrumbs[0].text).toBe('Dashboard'); + expect(breadcrumbs[1].text).toBe('Maps'); + expect(breadcrumbs[2].text).toBe('mymap'); +}); diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.tsx b/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.tsx new file mode 100644 index 0000000000000..1ccf890597edc --- /dev/null +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.tsx @@ -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 { i18n } from '@kbn/i18n'; +import { getNavigateToApp } from '../../../kibana_services'; +// @ts-expect-error +import { goToSpecifiedPath } from '../../maps_router'; + +export const unsavedChangesWarning = i18n.translate( + 'xpack.maps.breadCrumbs.unsavedChangesWarning', + { + defaultMessage: 'Your map has unsaved changes. Are you sure you want to leave?', + } +); + +export function getBreadcrumbs({ + title, + getHasUnsavedChanges, + originatingApp, + getAppNameFromId, +}: { + title: string; + getHasUnsavedChanges: () => boolean; + originatingApp?: string; + getAppNameFromId?: (id: string) => string; +}) { + const breadcrumbs = []; + if (originatingApp && getAppNameFromId) { + breadcrumbs.push({ + onClick: () => { + getNavigateToApp()(originatingApp); + }, + text: getAppNameFromId(originatingApp), + }); + } + + breadcrumbs.push({ + text: i18n.translate('xpack.maps.mapController.mapsBreadcrumbLabel', { + defaultMessage: 'Maps', + }), + onClick: () => { + if (getHasUnsavedChanges()) { + const navigateAway = window.confirm(unsavedChangesWarning); + if (navigateAway) { + goToSpecifiedPath('/'); + } + } else { + goToSpecifiedPath('/'); + } + }, + }); + + breadcrumbs.push({ text: title }); + + return breadcrumbs; +} diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js index 58f0bf16e93f2..485b0ed7682fa 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js @@ -5,7 +5,6 @@ */ import React from 'react'; -import { i18n } from '@kbn/i18n'; import 'mapbox-gl/dist/mapbox-gl.css'; import _ from 'lodash'; import { DEFAULT_IS_LAYER_TOC_OPEN } from '../../../reducers/ui'; @@ -29,13 +28,9 @@ import { AppStateManager } from '../../state_syncing/app_state_manager'; import { startAppStateSyncing } from '../../state_syncing/app_sync'; import { esFilters } from '../../../../../../../src/plugins/data/public'; import { MapContainer } from '../../../connected_components/map_container'; -import { goToSpecifiedPath } from '../../maps_router'; import { getIndexPatternsFromIds } from '../../../index_pattern_util'; import { getTopNavConfig } from './top_nav_config'; - -const unsavedChangesWarning = i18n.translate('xpack.maps.breadCrumbs.unsavedChangesWarning', { - defaultMessage: 'Your map has unsaved changes. Are you sure you want to leave?', -}); +import { getBreadcrumbs, unsavedChangesWarning } from './get_breadcrumbs'; export class MapsAppView extends React.Component { _globalSyncUnsubscribe = null; @@ -104,7 +99,7 @@ export class MapsAppView extends React.Component { getCoreChrome().setBreadcrumbs([]); } - _hasUnsavedChanges() { + _hasUnsavedChanges = () => { const savedLayerList = this.props.savedMap.getLayerList(); return !savedLayerList ? !_.isEqual(this.props.layerListConfigOnly, this.state.initialLayerListConfig) @@ -114,27 +109,16 @@ export class MapsAppView extends React.Component { // Need to perform the same process for layerListConfigOnly to compare apples to apples // and avoid undefined properties in layerListConfigOnly triggering unsaved changes. !_.isEqual(JSON.parse(JSON.stringify(this.props.layerListConfigOnly)), savedLayerList); - } + }; _setBreadcrumbs = () => { - getCoreChrome().setBreadcrumbs([ - { - text: i18n.translate('xpack.maps.mapController.mapsBreadcrumbLabel', { - defaultMessage: 'Maps', - }), - onClick: () => { - if (this._hasUnsavedChanges()) { - const navigateAway = window.confirm(unsavedChangesWarning); - if (navigateAway) { - goToSpecifiedPath('/'); - } - } else { - goToSpecifiedPath('/'); - } - }, - }, - { text: this.props.savedMap.title }, - ]); + const breadcrumbs = getBreadcrumbs({ + title: this.props.savedMap.title, + getHasUnsavedChanges: this._hasUnsavedChanges, + originatingApp: this.state.originatingApp, + getAppNameFromId: this.props.stateTransfer.getAppNameFromId, + }); + getCoreChrome().setBreadcrumbs(breadcrumbs); }; _updateFromGlobalState = ({ changes, state: globalState }) => { From 9511285bbd95f9f074dc28077c22505208272cf9 Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 25 Aug 2020 13:27:27 -0700 Subject: [PATCH 07/56] [src/dev/build] report file count of archives when building (#75900) Co-authored-by: spalger Co-authored-by: Elastic Machine --- src/dev/build/lib/fs.ts | 22 ++++++- src/dev/build/tasks/create_archives_task.ts | 71 +++++++++++---------- 2 files changed, 56 insertions(+), 37 deletions(-) diff --git a/src/dev/build/lib/fs.ts b/src/dev/build/lib/fs.ts index d86901c41e436..a91113ab2d1c4 100644 --- a/src/dev/build/lib/fs.ts +++ b/src/dev/build/lib/fs.ts @@ -273,7 +273,16 @@ export async function compressTar({ archive.pipe(output); - return archive.directory(source, name).finalize(); + let fileCount = 0; + archive.on('entry', (entry) => { + if (entry.stats?.isFile()) { + fileCount += 1; + } + }); + + await archive.directory(source, name).finalize(); + + return fileCount; } interface CompressZipOptions { @@ -294,5 +303,14 @@ export async function compressZip({ archive.pipe(output); - return archive.directory(source, name).finalize(); + let fileCount = 0; + archive.on('entry', (entry) => { + if (entry.stats?.isFile()) { + fileCount += 1; + } + }); + + await archive.directory(source, name).finalize(); + + return fileCount; } diff --git a/src/dev/build/tasks/create_archives_task.ts b/src/dev/build/tasks/create_archives_task.ts index 3ffb1afef7469..0083881e9f748 100644 --- a/src/dev/build/tasks/create_archives_task.ts +++ b/src/dev/build/tasks/create_archives_task.ts @@ -21,7 +21,7 @@ import Path from 'path'; import Fs from 'fs'; import { promisify } from 'util'; -import { CiStatsReporter } from '@kbn/dev-utils'; +import { CiStatsReporter, CiStatsMetrics } from '@kbn/dev-utils'; import { mkdirp, compressTar, compressZip, Task } from '../lib'; @@ -47,17 +47,16 @@ export const CreateArchives: Task = { archives.push({ format: 'zip', path: destination, - }); - - await compressZip({ - source, - destination, - archiverOptions: { - zlib: { - level: 9, + fileCount: await compressZip({ + source, + destination, + archiverOptions: { + zlib: { + level: 9, + }, }, - }, - createRootDirectory: true, + createRootDirectory: true, + }), }); break; @@ -65,18 +64,17 @@ export const CreateArchives: Task = { archives.push({ format: 'tar', path: destination, - }); - - await compressTar({ - source, - destination, - archiverOptions: { - gzip: true, - gzipOptions: { - level: 9, + fileCount: await compressTar({ + source, + destination, + archiverOptions: { + gzip: true, + gzipOptions: { + level: 9, + }, }, - }, - createRootDirectory: true, + createRootDirectory: true, + }), }); break; @@ -85,19 +83,22 @@ export const CreateArchives: Task = { } } - const reporter = CiStatsReporter.fromEnv(log); - if (reporter.isEnabled()) { - await reporter.metrics( - await Promise.all( - archives.map(async ({ format, path }) => { - return { - group: `${build.isOss() ? 'oss ' : ''}distributable size`, - id: format, - value: (await asyncStat(path)).size, - }; - }) - ) - ); + const metrics: CiStatsMetrics = []; + for (const { format, path, fileCount } of archives) { + metrics.push({ + group: `${build.isOss() ? 'oss ' : ''}distributable size`, + id: format, + value: (await asyncStat(path)).size, + }); + + metrics.push({ + group: `${build.isOss() ? 'oss ' : ''}distributable file count`, + id: 'total', + value: fileCount, + }); } + log.debug('archive metrics:', metrics); + + await CiStatsReporter.fromEnv(log).metrics(metrics); }, }; From 947a93900d05e8837ac26eae706c96254f48c86d Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Tue, 25 Aug 2020 15:02:38 -0600 Subject: [PATCH 08/56] [Maps] fix IVectorLayer.getStyle typing (#75829) * [Maps] fix IVectorLayer.getStyle typing * update typing in VectorLayer type definition * fix unit tests * review feedback --- .../public/classes/layers/vector_layer/vector_layer.d.ts | 2 -- .../classes/styles/vector/properties/__tests__/test_util.ts | 4 ++-- .../styles/vector/properties/dynamic_style_property.tsx | 5 +++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts index ad4479d3a324b..fa614ae87b290 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts @@ -32,7 +32,6 @@ export interface IVectorLayer extends ILayer { getJoins(): IJoin[]; getValidJoins(): IJoin[]; getSource(): IVectorSource; - getStyle(): IVectorStyle; getFeatureById(id: string | number): Feature | null; getPropertiesForTooltip(properties: GeoJsonProperties): Promise; hasJoins(): boolean; @@ -79,7 +78,6 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer { _setMbPointsProperties(mbMap: unknown, mvtSourceLayer?: string): void; _setMbLinePolygonProperties(mbMap: unknown, mvtSourceLayer?: string): void; getSource(): IVectorSource; - getStyle(): IVectorStyle; getFeatureById(id: string | number): Feature | null; getPropertiesForTooltip(properties: GeoJsonProperties): Promise; hasJoins(): boolean; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/__tests__/test_util.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/__tests__/test_util.ts index 3f6edc81e30ef..a2dfdc94d8058 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/__tests__/test_util.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/__tests__/test_util.ts @@ -5,7 +5,7 @@ */ // eslint-disable-next-line max-classes-per-file -import { FIELD_ORIGIN } from '../../../../../../common/constants'; +import { FIELD_ORIGIN, LAYER_STYLE_TYPE } from '../../../../../../common/constants'; import { StyleMeta } from '../../style_meta'; import { CategoryFieldMeta, @@ -44,7 +44,7 @@ export class MockStyle implements IStyle { } getType() { - return 'mockStyle'; + return LAYER_STYLE_TYPE.VECTOR; } getStyleMeta(): StyleMeta { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx index 47659e055936e..826acd41e27a9 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx @@ -27,6 +27,7 @@ import { import { IField } from '../../../fields/field'; import { IVectorLayer } from '../../../layers/vector_layer/vector_layer'; import { IJoin } from '../../../joins/join'; +import { IVectorStyle } from '../vector_style'; export interface IDynamicStyleProperty extends IStyleProperty { getFieldMetaOptions(): FieldMetaOptions; @@ -88,7 +89,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty } getRangeFieldMeta() { - const style = this._layer.getStyle(); + const style = this._layer.getStyle() as IVectorStyle; const styleMeta = style.getStyleMeta(); const fieldName = this.getFieldName(); const rangeFieldMetaFromLocalFeatures = styleMeta.getRangeFieldMetaDescriptor(fieldName); @@ -113,7 +114,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty } getCategoryFieldMeta() { - const style = this._layer.getStyle(); + const style = this._layer.getStyle() as IVectorStyle; const styleMeta = style.getStyleMeta(); const fieldName = this.getFieldName(); const categoryFieldMetaFromLocalFeatures = styleMeta.getCategoryFieldMetaDescriptor(fieldName); From fef89334b573b5a4fca89969ff6e2dca9de95a43 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Tue, 25 Aug 2020 16:43:28 -0500 Subject: [PATCH 09/56] [Enterprise Search] Move views into separate folder from components (#75906) * Move views into separate folder from components * Fix paths in tests * More error_state to views --- .../public/applications/workplace_search/index.test.tsx | 4 ++-- .../public/applications/workplace_search/index.tsx | 8 ++++---- .../error_state/error_state.test.tsx | 0 .../{components => views}/error_state/error_state.tsx | 2 +- .../{components => views}/error_state/index.ts | 0 .../{components => views}/overview/__mocks__/index.ts | 0 .../overview/__mocks__/overview_logic.mock.ts | 0 .../{components => views}/overview/index.ts | 0 .../overview/onboarding_card.test.tsx | 0 .../{components => views}/overview/onboarding_card.tsx | 0 .../overview/onboarding_steps.test.tsx | 0 .../{components => views}/overview/onboarding_steps.tsx | 4 ++-- .../overview/organization_stats.test.tsx | 0 .../{components => views}/overview/organization_stats.tsx | 2 +- .../{components => views}/overview/overview.test.tsx | 4 ++-- .../{components => views}/overview/overview.tsx | 6 +++--- .../{components => views}/overview/overview_logic.test.ts | 0 .../{components => views}/overview/overview_logic.ts | 0 .../{components => views}/overview/recent_activity.scss | 0 .../overview/recent_activity.test.tsx | 0 .../{components => views}/overview/recent_activity.tsx | 2 +- .../overview/statistic_card.test.tsx | 0 .../{components => views}/overview/statistic_card.tsx | 0 .../{components => views}/setup_guide/index.ts | 0 .../setup_guide/setup_guide.test.tsx | 0 .../{components => views}/setup_guide/setup_guide.tsx | 0 26 files changed, 16 insertions(+), 16 deletions(-) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/error_state/error_state.test.tsx (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/error_state/error_state.tsx (93%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/error_state/index.ts (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/__mocks__/index.ts (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/__mocks__/overview_logic.mock.ts (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/index.ts (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/onboarding_card.test.tsx (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/onboarding_card.tsx (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/onboarding_steps.test.tsx (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/onboarding_steps.tsx (97%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/organization_stats.test.tsx (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/organization_stats.tsx (97%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/overview.test.tsx (93%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/overview.tsx (93%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/overview_logic.test.ts (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/overview_logic.ts (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/recent_activity.scss (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/recent_activity.test.tsx (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/recent_activity.tsx (98%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/statistic_card.test.tsx (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/overview/statistic_card.tsx (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/setup_guide/index.ts (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/setup_guide/setup_guide.test.tsx (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/{components => views}/setup_guide/setup_guide.tsx (100%) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index 654f4dce0ebf3..a0d9352ee9f82 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -12,8 +12,8 @@ import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; import { useValues } from 'kea'; -import { Overview } from './components/overview'; -import { ErrorState } from './components/error_state'; +import { Overview } from './views/overview'; +import { ErrorState } from './views/error_state'; import { WorkplaceSearch } from './'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index b261c83e30dde..8582a003c6fa8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -16,11 +16,11 @@ import { WorkplaceSearchNav } from './components/layout/nav'; import { SETUP_GUIDE_PATH } from './routes'; -import { SetupGuide } from './components/setup_guide'; -import { ErrorState } from './components/error_state'; -import { Overview } from './components/overview'; +import { SetupGuide } from './views/setup_guide'; +import { ErrorState } from './views/error_state'; +import { Overview } from './views/overview'; -export const WorkplaceSearch: React.FC = (props) => { +export const WorkplaceSearch: React.FC = () => { const { config } = useContext(KibanaContext) as IKibanaContext; const { errorConnecting } = useValues(HttpLogic) as IHttpLogicValues; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx similarity index 93% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx index 53f3a7a274429..9ad649c292fb7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx @@ -13,7 +13,7 @@ import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; import { ErrorStatePrompt } from '../../../shared/error_state'; import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import { ViewContentHeader } from '../shared/view_content_header'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; export const ErrorState: React.FC = () => { return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/__mocks__/index.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/__mocks__/overview_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/overview_logic.mock.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/__mocks__/overview_logic.mock.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/__mocks__/overview_logic.mock.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx similarity index 97% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx index d0f5893bdb88a..fa4decccb34b1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx @@ -21,12 +21,12 @@ import { EuiButtonEmptyProps, EuiLinkProps, } from '@elastic/eui'; -import sharedSourcesIcon from '../shared/assets/share_circle.svg'; +import sharedSourcesIcon from '../../components/shared/assets/share_circle.svg'; import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; -import { ContentSection } from '../shared/content_section'; +import { ContentSection } from '../../components/shared/content_section'; import { OverviewLogic, IOverviewValues } from './overview_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx similarity index 97% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx index 4c5efce9baf12..53549cfcdbce7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx @@ -11,7 +11,7 @@ import { useValues } from 'kea'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { ContentSection } from '../shared/content_section'; +import { ContentSection } from '../../components/shared/content_section'; import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes'; import { OverviewLogic, IOverviewValues } from './overview_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx similarity index 93% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx index fee966a56923d..e4531ff03587b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx @@ -11,8 +11,8 @@ import { mockLogicActions, setMockValues } from './__mocks__'; import React from 'react'; import { shallow, mount } from 'enzyme'; -import { Loading } from '../shared/loading'; -import { ViewContentHeader } from '../shared/view_content_header'; +import { Loading } from '../../components/shared/loading'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; import { OnboardingSteps } from './onboarding_steps'; import { OrganizationStats } from './organization_stats'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx similarity index 93% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx index 6aa3e1e608bfe..134fc9389694d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx @@ -16,9 +16,9 @@ import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/t import { OverviewLogic, IOverviewActions, IOverviewValues } from './overview_logic'; -import { Loading } from '../shared/loading'; -import { ProductButton } from '../shared/product_button'; -import { ViewContentHeader } from '../shared/view_content_header'; +import { Loading } from '../../components/shared/loading'; +import { ProductButton } from '../../components/shared/product_button'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; import { OnboardingSteps } from './onboarding_steps'; import { OrganizationStats } from './organization_stats'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.test.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview_logic.test.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.test.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview_logic.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.scss similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.scss diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx similarity index 98% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx index 0f4f6c65d083c..ada89c33be7e2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx @@ -12,7 +12,7 @@ import { useValues } from 'kea'; import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ContentSection } from '../shared/content_section'; +import { ContentSection } from '../../components/shared/content_section'; import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; import { getSourcePath } from '../../routes'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx From 1fee8f16ef8c6399bfa9d00a7c59cdb92be12361 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Wed, 26 Aug 2020 00:00:24 +0200 Subject: [PATCH 10/56] [Lens] fix dimension popover design on mobile (#75866) --- .../indexpattern_datasource/dimension_panel/field_select.tsx | 2 +- .../indexpattern_datasource/dimension_panel/popover_editor.scss | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx index b2a59788b50f9..e4dfa69813743 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx @@ -181,7 +181,7 @@ export function FieldSelect({ }} renderOption={(option, searchValue) => { return ( - + Date: Tue, 25 Aug 2020 18:13:41 -0400 Subject: [PATCH 11/56] [Security Solution][Detections] Disables add exception for ML and threshold rules (#75802) --- .../components/alerts_table/default_config.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 5bab2e3c78970..ca17d331c67e5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -9,6 +9,8 @@ import ApolloClient from 'apollo-client'; import { Dispatch } from 'redux'; import { EuiText } from '@elastic/eui'; +import { RuleType } from '../../../../common/detection_engine/types'; +import { isMlRule } from '../../../../common/machine_learning/helpers'; import { RowRendererId } from '../../../../common/types/timeline'; import { DEFAULT_INDEX_PATTERN } from '../../../../common/constants'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; @@ -39,6 +41,7 @@ import { import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; import { AddExceptionModalBaseProps } from '../../../common/components/exceptions/add_exception_modal'; import { getMappedNonEcsValue } from '../../../common/components/exceptions/helpers'; +import { isThresholdRule } from '../../../../common/detection_engine/utils'; export const buildAlertStatusFilter = (status: Status): Filter[] => [ { @@ -193,6 +196,7 @@ export const requiredFieldsForActions = [ 'signal.rule.query', 'signal.rule.to', 'signal.rule.id', + 'signal.rule.type', 'signal.original_event.kind', 'signal.original_event.module', @@ -317,6 +321,15 @@ export const getAlertActions = ({ return module === 'endpoint' && kind === 'alert'; }; + const exceptionsAreAllowed = () => { + const ruleTypes = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.rule.type', + }); + const [ruleType] = ruleTypes as RuleType[]; + return !isMlRule(ruleType) && !isThresholdRule(ruleType); + }; + return [ { ...getInvestigateInResolverAction({ dispatch, timelineId }), @@ -386,7 +399,7 @@ export const getAlertActions = ({ } }, id: 'addException', - isActionDisabled: () => !canUserCRUD || !hasIndexWrite, + isActionDisabled: () => !canUserCRUD || !hasIndexWrite || !exceptionsAreAllowed(), dataTestSubj: 'add-exception-menu-item', ariaLabel: 'Add Exception', content: {i18n.ACTION_ADD_EXCEPTION}, From ba9a60738425a2948f6e074408a0e5d22d07c721 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 25 Aug 2020 19:48:18 -0600 Subject: [PATCH 12/56] Optimizes the index queries to not block the NodeJS event loop (#75716) ## Summary Before this PR you can see event loop block times of: ```ts formatIndexFields: 7986.884ms ``` After this PR you will see event loop block times of: ```ts formatIndexFields: 85.012ms ``` within the file: ```ts x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts ``` For the GraphQL query of `SourceQuery`/`IndexFields` This also fixes the issue of `unknown` being returned to the front end by removing code that is no longer functioning as it was intended. Ensure during testing of this PR that blank/default and non exist indexes within `securitySolution:defaultIndex` still work as expected. Before, notice the `unknown` instead of the `filebeat-*`: Screen Shot 2020-08-20 at 4 55 52 PM After: Screen Shot 2020-08-20 at 4 56 03 PM An explanation of how to see the block times for before and after --- For perf testing you first add timed testing to the file: ```ts x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts ``` Before this PR, around lines 42: ```ts console.time('formatIndexFields'); // <--- start timer const fields = formatIndexFields( responsesIndexFields, Object.keys(indexesAliasIndices) as IndexAlias[] ); console.timeEnd('formatIndexFields'); // <--- outputs the end timer return fields; ``` After this PR, around lines 42: ```ts console.time('formatIndexFields'); // <--- start timer const fields = await formatIndexFields(responsesIndexFields, indices); console.timeEnd('formatIndexFields'); // <--- outputs the end timer return fields; ``` And then reload the security solutions application web page here: ``` http://localhost:5601/app/security/timelines/default ``` Be sure to load it _twice_ for testing as NodeJS will sometimes report better numbers the second time as it does optimizations after the first time it encounters some code paths. You will begin to see numbers similar to this before this PR: ```ts formatIndexFields: 2553.279ms ``` This indicates that it is blocking the event loop for ~2.5 seconds befofe this fix. If you add additional indexes to your `securitySolution:defaultIndex` indexes that have additional fields then this amount will increase exponentially. For developers using our test servers I created two other indexes called delme-1 and delme-2 with additional mappings you can add like below ```ts apm-*-transaction*, auditbeat-*, endgame-*, filebeat-*, logs-*, packetbeat-*, winlogbeat-*, delme-1, delme-2 ``` Screen Shot 2020-08-21 at 8 21 50 PM Then you are going to see times approaching 8 seconds of blocking the event loop like so: ```ts formatIndexFields: 7986.884ms ``` After this fix on the first pass unoptimized it will report ```ts formatIndexFields: 373.082ms ``` Then after it optimizes the code paths on a second page load it will report ```ts formatIndexFields: 84.304ms ``` ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../elasticsearch_adapter.test.ts | 564 +++++++++++++++++- .../lib/index_fields/elasticsearch_adapter.ts | 203 ++++--- .../server/utils/beat_schema/index.test.ts | 22 +- .../server/utils/beat_schema/index.ts | 15 - .../server/utils/beat_schema/type.ts | 2 - 5 files changed, 692 insertions(+), 114 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.test.ts b/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.test.ts index 20bc1387a3c4e..e8883111c95f6 100644 --- a/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.test.ts @@ -6,16 +6,21 @@ import { sortBy } from 'lodash/fp'; -import { formatIndexFields } from './elasticsearch_adapter'; +import { + formatIndexFields, + formatFirstFields, + formatSecondFields, + createFieldItem, +} from './elasticsearch_adapter'; import { mockAuditbeatIndexField, mockFilebeatIndexField, mockPacketbeatIndexField } from './mock'; describe('Index Fields', () => { describe('formatIndexFields', () => { - test('Test Basic functionality', async () => { + test('Basic functionality', async () => { expect( sortBy( 'name', - formatIndexFields( + await formatIndexFields( [mockAuditbeatIndexField, mockFilebeatIndexField, mockPacketbeatIndexField], ['auditbeat', 'filebeat', 'packetbeat'] ) @@ -130,4 +135,557 @@ describe('Index Fields', () => { ); }); }); + + describe('formatFirstFields', () => { + test('Basic functionality', async () => { + const fields = await formatFirstFields( + [mockAuditbeatIndexField, mockFilebeatIndexField, mockPacketbeatIndexField], + ['auditbeat', 'filebeat', 'packetbeat'] + ); + expect(fields).toEqual([ + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + footnote: '', + group: 1, + level: 'core', + name: '_id', + required: true, + type: 'string', + searchable: true, + aggregatable: false, + readFromDocValues: true, + category: '_id', + indexes: ['auditbeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + footnote: '', + group: 1, + level: 'core', + name: '_index', + required: true, + type: 'string', + searchable: true, + aggregatable: true, + readFromDocValues: true, + category: '_index', + indexes: ['auditbeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['auditbeat'], + }, + { + description: + 'Ephemeral identifier of this agent (if one exists).\n\nThis id normally changes across restarts, but `agent.id` does not.', + example: '8a4f500f', + name: 'agent.ephemeral_id', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: + 'Custom name of the agent.\n\nThis is a name that can be given to an agent. This can be helpful if for example\ntwo Filebeat instances are running on the same host but a human readable separation\nis needed on which Filebeat instance data is coming from.\n\nIf no name is given, the name is often left empty.', + example: 'foo', + name: 'agent.name', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: + 'Type of the agent.\n\nThe agent type stays always the same and should be given by the agent used.\nIn case of Filebeat the agent would always be Filebeat also if two Filebeat\ninstances are run on the same machine.', + example: 'filebeat', + name: 'agent.type', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: 'Version of the agent.', + example: '6.0.0-rc2', + name: 'agent.version', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + footnote: '', + group: 1, + level: 'core', + name: '_id', + required: true, + type: 'string', + searchable: true, + aggregatable: false, + readFromDocValues: true, + category: '_id', + indexes: ['filebeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + footnote: '', + group: 1, + level: 'core', + name: '_index', + required: true, + type: 'string', + searchable: true, + aggregatable: true, + readFromDocValues: true, + category: '_index', + indexes: ['filebeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['filebeat'], + }, + { + name: 'agent.hostname', + searchable: true, + type: 'string', + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: + 'Custom name of the agent.\n\nThis is a name that can be given to an agent. This can be helpful if for example\ntwo Filebeat instances are running on the same host but a human readable separation\nis needed on which Filebeat instance data is coming from.\n\nIf no name is given, the name is often left empty.', + example: 'foo', + name: 'agent.name', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: 'Version of the agent.', + example: '6.0.0-rc2', + name: 'agent.version', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + footnote: '', + group: 1, + level: 'core', + name: '_id', + required: true, + type: 'string', + searchable: true, + aggregatable: false, + readFromDocValues: true, + category: '_id', + indexes: ['packetbeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + footnote: '', + group: 1, + level: 'core', + name: '_index', + required: true, + type: 'string', + searchable: true, + aggregatable: true, + readFromDocValues: true, + category: '_index', + indexes: ['packetbeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['packetbeat'], + }, + { + description: + 'Unique identifier of this agent (if one exists).\n\nExample: For Beats this would be beat.id.', + example: '8a4f500d', + name: 'agent.id', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['packetbeat'], + }, + { + description: + 'Type of the agent.\n\nThe agent type stays always the same and should be given by the agent used.\nIn case of Filebeat the agent would always be Filebeat also if two Filebeat\ninstances are run on the same machine.', + example: 'filebeat', + name: 'agent.type', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['packetbeat'], + }, + ]); + }); + }); + + describe('formatSecondFields', () => { + test('Basic functionality', async () => { + const fields = await formatSecondFields([ + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + category: '_id', + indexes: ['auditbeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + name: '_index', + type: 'string', + searchable: true, + aggregatable: true, + category: '_index', + indexes: ['auditbeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['auditbeat'], + }, + { + description: + 'Ephemeral identifier of this agent (if one exists).\n\nThis id normally changes across restarts, but `agent.id` does not.', + example: '8a4f500f', + name: 'agent.ephemeral_id', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: + 'Custom name of the agent.\n\nThis is a name that can be given to an agent. This can be helpful if for example\ntwo Filebeat instances are running on the same host but a human readable separation\nis needed on which Filebeat instance data is coming from.\n\nIf no name is given, the name is often left empty.', + example: 'foo', + name: 'agent.name', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: + 'Type of the agent.\n\nThe agent type stays always the same and should be given by the agent used.\nIn case of Filebeat the agent would always be Filebeat also if two Filebeat\ninstances are run on the same machine.', + example: 'filebeat', + name: 'agent.type', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: 'Version of the agent.', + example: '6.0.0-rc2', + name: 'agent.version', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + category: '_id', + indexes: ['filebeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + name: '_index', + type: 'string', + searchable: true, + aggregatable: true, + category: '_index', + indexes: ['filebeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['filebeat'], + }, + { + name: 'agent.hostname', + searchable: true, + type: 'string', + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: + 'Custom name of the agent.\n\nThis is a name that can be given to an agent. This can be helpful if for example\ntwo Filebeat instances are running on the same host but a human readable separation\nis needed on which Filebeat instance data is coming from.\n\nIf no name is given, the name is often left empty.', + example: 'foo', + name: 'agent.name', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: 'Version of the agent.', + example: '6.0.0-rc2', + name: 'agent.version', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + category: '_id', + indexes: ['packetbeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + name: '_index', + type: 'string', + searchable: true, + aggregatable: true, + category: '_index', + indexes: ['packetbeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['packetbeat'], + }, + { + description: + 'Unique identifier of this agent (if one exists).\n\nExample: For Beats this would be beat.id.', + example: '8a4f500d', + name: 'agent.id', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['packetbeat'], + }, + { + description: + 'Type of the agent.\n\nThe agent type stays always the same and should be given by the agent used.\nIn case of Filebeat the agent would always be Filebeat also if two Filebeat\ninstances are run on the same machine.', + example: 'filebeat', + name: 'agent.type', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['packetbeat'], + }, + ]); + expect(fields).toEqual([ + { + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + category: '_id', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, + { + description: + 'An index is like a ‘database’ in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.', + example: 'auditbeat-8.0.0-2019.02.19-000001', + name: '_index', + type: 'string', + searchable: true, + aggregatable: true, + category: '_index', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, + { + description: + 'Date/time when the event originated.\n\nThis is the date/time extracted from the event, typically representing when\nthe event was generated by the source.\n\nIf the event source has no original timestamp, this value is typically populated\nby the first time the event was received by the pipeline.\n\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + name: '@timestamp', + type: 'date', + searchable: true, + aggregatable: true, + category: 'base', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + }, + { + description: + 'Ephemeral identifier of this agent (if one exists).\n\nThis id normally changes across restarts, but `agent.id` does not.', + example: '8a4f500f', + name: 'agent.ephemeral_id', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat'], + }, + { + description: + 'Custom name of the agent.\n\nThis is a name that can be given to an agent. This can be helpful if for example\ntwo Filebeat instances are running on the same host but a human readable separation\nis needed on which Filebeat instance data is coming from.\n\nIf no name is given, the name is often left empty.', + example: 'foo', + name: 'agent.name', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat', 'filebeat'], + }, + { + description: + 'Type of the agent.\n\nThe agent type stays always the same and should be given by the agent used.\nIn case of Filebeat the agent would always be Filebeat also if two Filebeat\ninstances are run on the same machine.', + example: 'filebeat', + name: 'agent.type', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat', 'packetbeat'], + }, + { + description: 'Version of the agent.', + example: '6.0.0-rc2', + name: 'agent.version', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['auditbeat', 'filebeat'], + }, + { + name: 'agent.hostname', + searchable: true, + type: 'string', + aggregatable: true, + category: 'agent', + indexes: ['filebeat'], + }, + { + description: + 'Unique identifier of this agent (if one exists).\n\nExample: For Beats this would be beat.id.', + example: '8a4f500d', + name: 'agent.id', + type: 'string', + searchable: true, + aggregatable: true, + category: 'agent', + indexes: ['packetbeat'], + }, + ]); + }); + }); + + describe('createFieldItem', () => { + test('Basic functionality', () => { + const item = createFieldItem( + ['auditbeat'], + { + name: '_id', + type: 'string', + searchable: true, + aggregatable: false, + }, + 0 + ); + expect(item).toEqual({ + description: 'Each document has an _id that uniquely identifies it', + example: 'Y-6TfmcB0WOhS6qyMv3s', + footnote: '', + group: 1, + level: 'core', + name: '_id', + required: true, + type: 'string', + searchable: true, + aggregatable: false, + category: '_id', + indexes: ['auditbeat'], + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts b/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts index bb0a4b9e2ba9b..777b1cf3bb80d 100644 --- a/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts +++ b/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts @@ -4,45 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty, get } from 'lodash/fp'; +import { isEmpty } from 'lodash/fp'; import { IndexField } from '../../graphql/types'; -import { - baseCategoryFields, - getDocumentation, - getIndexAlias, - hasDocumentation, - IndexAlias, -} from '../../utils/beat_schema'; +import { baseCategoryFields, getDocumentation, hasDocumentation } from '../../utils/beat_schema'; import { FrameworkAdapter, FrameworkRequest } from '../framework'; import { FieldsAdapter, IndexFieldDescriptor } from './types'; export class ElasticsearchIndexFieldAdapter implements FieldsAdapter { constructor(private readonly framework: FrameworkAdapter) {} - public async getIndexFields(request: FrameworkRequest, indices: string[]): Promise { const indexPatternsService = this.framework.getIndexPatternsService(request); - const indexesAliasIndices = indices.reduce>((accumulator, indice) => { - const key = getIndexAlias(indices, indice); - - if (get(key, accumulator)) { - accumulator[key] = [...accumulator[key], indice]; - } else { - accumulator[key] = [indice]; - } - return accumulator; - }, {}); - const responsesIndexFields: IndexFieldDescriptor[][] = await Promise.all( - Object.values(indexesAliasIndices).map((indicesByGroup) => - indexPatternsService.getFieldsForWildcard({ - pattern: indicesByGroup, - }) - ) - ); - return formatIndexFields( - responsesIndexFields, - Object.keys(indexesAliasIndices) as IndexAlias[] + const responsesIndexFields = await Promise.all( + indices.map((index) => { + return indexPatternsService.getFieldsForWildcard({ + pattern: index, + }); + }) ); + return formatIndexFields(responsesIndexFields, indices); } } @@ -63,51 +43,128 @@ const missingFields = [ }, ]; -export const formatIndexFields = ( +/** + * Creates a single field item. + * + * This is a mutatious HOT CODE PATH function that will have array sizes up to 4.7 megs + * in size at a time calling this function repeatedly. This function should be as optimized as possible + * and should avoid any and all creation of new arrays, iterating over the arrays or performing + * any n^2 operations. + * @param indexesAlias The index alias + * @param index The index its self + * @param indexesAliasIdx The index within the alias + */ +export const createFieldItem = ( + indexesAlias: string[], + index: IndexFieldDescriptor, + indexesAliasIdx: number +): IndexField => { + const alias = indexesAlias[indexesAliasIdx]; + const splitName = index.name.split('.'); + const category = baseCategoryFields.includes(splitName[0]) ? 'base' : splitName[0]; + return { + ...(hasDocumentation(alias, index.name) ? getDocumentation(alias, index.name) : {}), + ...index, + category, + indexes: [alias], + }; +}; + +/** + * This is a mutatious HOT CODE PATH function that will have array sizes up to 4.7 megs + * in size at a time when being called. This function should be as optimized as possible + * and should avoid any and all creation of new arrays, iterating over the arrays or performing + * any n^2 operations. The `.push`, and `forEach` operations are expected within this function + * to speed up performance. + * + * This intentionally waits for the next tick on the event loop to process as the large 4.7 megs + * has already consumed a lot of the event loop processing up to this function and we want to give + * I/O opportunity to occur by scheduling this on the next loop. + * @param responsesIndexFields The response index fields to loop over + * @param indexesAlias The index aliases such as filebeat-* + */ +export const formatFirstFields = async ( responsesIndexFields: IndexFieldDescriptor[][], - indexesAlias: IndexAlias[] -): IndexField[] => - responsesIndexFields - .reduce( - (accumulator: IndexField[], indexFields: IndexFieldDescriptor[], indexesAliasIdx: number) => [ - ...accumulator, - ...[...missingFields, ...indexFields].reduce( - (itemAccumulator: IndexField[], index: IndexFieldDescriptor) => { - const alias: IndexAlias = indexesAlias[indexesAliasIdx]; - const splitName = index.name.split('.'); - const category = baseCategoryFields.includes(splitName[0]) ? 'base' : splitName[0]; - return [ - ...itemAccumulator, - { - ...(hasDocumentation(alias, index.name) ? getDocumentation(alias, index.name) : {}), - ...index, - category, - indexes: [alias], - } as IndexField, - ]; + indexesAlias: string[] +): Promise => { + return new Promise((resolve) => { + setTimeout(() => { + resolve( + responsesIndexFields.reduce( + ( + accumulator: IndexField[], + indexFields: IndexFieldDescriptor[], + indexesAliasIdx: number + ) => { + missingFields.forEach((index) => { + const item = createFieldItem(indexesAlias, index, indexesAliasIdx); + accumulator.push(item); + }); + indexFields.forEach((index) => { + const item = createFieldItem(indexesAlias, index, indexesAliasIdx); + accumulator.push(item); + }); + return accumulator; }, [] - ), - ], - [] - ) - .reduce((accumulator: IndexField[], indexfield: IndexField) => { - const alreadyExistingIndexField = accumulator.findIndex( - (acc) => acc.name === indexfield.name + ) ); - if (alreadyExistingIndexField > -1) { - const existingIndexField = accumulator[alreadyExistingIndexField]; - return [ - ...accumulator.slice(0, alreadyExistingIndexField), - { - ...existingIndexField, - description: isEmpty(existingIndexField.description) - ? indexfield.description - : existingIndexField.description, - indexes: Array.from(new Set([...existingIndexField.indexes, ...indexfield.indexes])), - }, - ...accumulator.slice(alreadyExistingIndexField + 1), - ]; - } - return [...accumulator, indexfield]; - }, []); + }); + }); +}; + +/** + * This is a mutatious HOT CODE PATH function that will have array sizes up to 4.7 megs + * in size at a time when being called. This function should be as optimized as possible + * and should avoid any and all creation of new arrays, iterating over the arrays or performing + * any n^2 operations. The `.push`, and `forEach` operations are expected within this function + * to speed up performance. The "indexFieldNameHash" side effect hash avoids additional expensive n^2 + * look ups. + * + * This intentionally waits for the next tick on the event loop to process as the large 4.7 megs + * has already consumed a lot of the event loop processing up to this function and we want to give + * I/O opportunity to occur by scheduling this on the next loop. + * @param fields The index fields to create the secondary fields for + */ +export const formatSecondFields = async (fields: IndexField[]): Promise => { + return new Promise((resolve) => { + setTimeout(() => { + const indexFieldNameHash: Record = {}; + const reduced = fields.reduce((accumulator: IndexField[], indexfield: IndexField) => { + const alreadyExistingIndexField = indexFieldNameHash[indexfield.name]; + if (alreadyExistingIndexField != null) { + const existingIndexField = accumulator[alreadyExistingIndexField]; + if (isEmpty(accumulator[alreadyExistingIndexField].description)) { + accumulator[alreadyExistingIndexField].description = indexfield.description; + } + accumulator[alreadyExistingIndexField].indexes = Array.from( + new Set([...existingIndexField.indexes, ...indexfield.indexes]) + ); + return accumulator; + } + accumulator.push(indexfield); + indexFieldNameHash[indexfield.name] = accumulator.length - 1; + return accumulator; + }, []); + resolve(reduced); + }); + }); +}; + +/** + * Formats the index fields into a format the UI wants. + * + * NOTE: This will have array sizes up to 4.7 megs in size at a time when being called. + * This function should be as optimized as possible and should avoid any and all creation + * of new arrays, iterating over the arrays or performing any n^2 operations. + * @param responsesIndexFields The response index fields to format + * @param indexesAlias The index alias + */ +export const formatIndexFields = async ( + responsesIndexFields: IndexFieldDescriptor[][], + indexesAlias: string[] +): Promise => { + const fields = await formatFirstFields(responsesIndexFields, indexesAlias); + const secondFields = await formatSecondFields(fields); + return secondFields; +}; diff --git a/x-pack/plugins/security_solution/server/utils/beat_schema/index.test.ts b/x-pack/plugins/security_solution/server/utils/beat_schema/index.test.ts index 5f002aa7fad7b..29944edf382f4 100644 --- a/x-pack/plugins/security_solution/server/utils/beat_schema/index.test.ts +++ b/x-pack/plugins/security_solution/server/utils/beat_schema/index.test.ts @@ -6,7 +6,7 @@ import { cloneDeep, isArray } from 'lodash/fp'; -import { convertSchemaToAssociativeArray, getIndexSchemaDoc, getIndexAlias } from '.'; +import { convertSchemaToAssociativeArray, getIndexSchemaDoc } from '.'; import { auditbeatSchema, filebeatSchema, packetbeatSchema } from './8.0.0'; import { Schema } from './type'; @@ -394,24 +394,4 @@ describe('Schema Beat', () => { ]); }); }); - - describe('getIndexAlias', () => { - test('getIndexAlias handles values with leading wildcard', () => { - const leadingWildcardIndex = '*-auditbeat-*'; - const result = getIndexAlias([leadingWildcardIndex], leadingWildcardIndex); - expect(result).toBe(leadingWildcardIndex); - }); - - test('getIndexAlias no match returns "unknown" string', () => { - const index = 'auditbeat-*'; - const result = getIndexAlias([index], 'hello'); - expect(result).toBe('unknown'); - }); - - test('empty index should not cause an error to return although it will cause an invalid regular expression to occur', () => { - const index = ''; - const result = getIndexAlias([index], 'hello'); - expect(result).toBe('unknown'); - }); - }); }); diff --git a/x-pack/plugins/security_solution/server/utils/beat_schema/index.ts b/x-pack/plugins/security_solution/server/utils/beat_schema/index.ts index 6ec15d328714d..58627a199a181 100644 --- a/x-pack/plugins/security_solution/server/utils/beat_schema/index.ts +++ b/x-pack/plugins/security_solution/server/utils/beat_schema/index.ts @@ -76,21 +76,6 @@ const convertFieldsToAssociativeArray = ( }, {}) : {}; -export const getIndexAlias = (defaultIndex: string[], indexName: string): string => { - try { - const found = defaultIndex.find((index) => `\\${indexName}`.match(`\\${index}`) != null); - if (found != null) { - return found; - } else { - return 'unknown'; - } - } catch (error) { - // if we encounter an error because the index contains invalid regular expressions then we should return an unknown - // rather than blow up with a toaster error upstream - return 'unknown'; - } -}; - export const getIndexSchemaDoc = memoize((index: string) => { if (index.match('auditbeat') != null) { return { diff --git a/x-pack/plugins/security_solution/server/utils/beat_schema/type.ts b/x-pack/plugins/security_solution/server/utils/beat_schema/type.ts index 2b7be8f4b7539..722589ce7e2bb 100644 --- a/x-pack/plugins/security_solution/server/utils/beat_schema/type.ts +++ b/x-pack/plugins/security_solution/server/utils/beat_schema/type.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export type IndexAlias = 'auditbeat' | 'filebeat' | 'packetbeat' | 'ecs' | 'winlogbeat' | 'unknown'; - /* * BEAT Interface * From eecf4aa71f27b0fc22113f73ddf745a4fcd59cb5 Mon Sep 17 00:00:00 2001 From: Justin Ibarra Date: Tue, 25 Aug 2020 23:25:07 -0500 Subject: [PATCH 13/56] [Detection Rules] Add 7.9.1 rules (#75939) * increase lookback (`from`) and bump versions --- .../command_and_control_certutil_network_connection.json | 3 ++- .../credential_access_credential_dumping_msbuild.json | 3 ++- .../prepackaged_rules/credential_access_tcpdump_activity.json | 3 ++- ...on_adding_the_hidden_file_attribute_with_via_attribexe.json | 3 ++- ...efense_evasion_attempt_to_disable_iptables_or_firewall.json | 3 ++- .../defense_evasion_attempt_to_disable_syslog_service.json | 3 ++- ...evasion_base16_or_base32_encoding_or_decoding_activity.json | 3 ++- .../defense_evasion_base64_encoding_or_decoding_activity.json | 3 ++- .../defense_evasion_clearing_windows_event_logs.json | 3 ++- .../defense_evasion_delete_volume_usn_journal_with_fsutil.json | 3 ++- .../defense_evasion_deleting_backup_catalogs_with_wbadmin.json | 3 ++- .../defense_evasion_deletion_of_bash_command_line_history.json | 3 ++- .../defense_evasion_disable_selinux_attempt.json | 3 ++- ...ense_evasion_disable_windows_firewall_rules_with_netsh.json | 3 ++- ...efense_evasion_encoding_or_decoding_files_via_certutil.json | 3 ++- ...efense_evasion_execution_msbuild_started_by_office_app.json | 3 ++- .../defense_evasion_execution_msbuild_started_by_script.json | 3 ++- ...se_evasion_execution_msbuild_started_by_system_process.json | 3 ++- .../defense_evasion_execution_msbuild_started_renamed.json | 3 ++- ...fense_evasion_execution_msbuild_started_unusal_process.json | 3 ++- .../defense_evasion_file_deletion_via_shred.json | 3 ++- .../defense_evasion_file_mod_writable_dir.json | 3 ++- .../defense_evasion_hex_encoding_or_decoding_activity.json | 3 ++- .../prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json | 3 ++- .../defense_evasion_kernel_module_removal.json | 3 ++- ...defense_evasion_misc_lolbin_connecting_to_the_internet.json | 3 ++- .../defense_evasion_modification_of_boot_config.json | 3 ++- ...fense_evasion_volume_shadow_copy_deletion_via_vssadmin.json | 3 ++- .../defense_evasion_volume_shadow_copy_deletion_via_wmic.json | 3 ++- .../prepackaged_rules/discovery_kernel_module_enumeration.json | 3 ++- .../discovery_net_command_system_account.json | 3 ++- .../discovery_virtual_machine_fingerprinting.json | 3 ++- .../rules/prepackaged_rules/discovery_whoami_commmand.json | 3 ++- .../execution_command_prompt_connecting_to_the_internet.json | 3 ++- .../execution_command_shell_started_by_powershell.json | 3 ++- .../execution_command_shell_started_by_svchost.json | 3 ++- ...tml_help_executable_program_connecting_to_the_internet.json | 3 ++- .../prepackaged_rules/execution_local_service_commands.json | 3 ++- .../execution_msbuild_making_network_connections.json | 3 ++- .../execution_mshta_making_network_connections.json | 3 ++- .../rules/prepackaged_rules/execution_msxsl_network.json | 3 ++- .../rules/prepackaged_rules/execution_perl_tty_shell.json | 3 ++- .../execution_psexec_lateral_movement_command.json | 3 ++- .../rules/prepackaged_rules/execution_python_tty_shell.json | 3 ++- ...ion_register_server_program_connecting_to_the_internet.json | 3 ++- .../execution_script_executing_powershell.json | 3 ++- .../execution_suspicious_ms_office_child_process.json | 3 ++- .../execution_suspicious_ms_outlook_child_process.json | 3 ++- .../prepackaged_rules/execution_suspicious_pdf_reader.json | 3 ++- .../execution_unusual_network_connection_via_rundll32.json | 3 ++- .../execution_unusual_process_network_connection.json | 3 ++- .../prepackaged_rules/execution_via_net_com_assemblies.json | 3 ++- .../lateral_movement_direct_outbound_smb_connection.json | 3 ++- .../lateral_movement_telnet_network_activity_external.json | 3 ++- .../lateral_movement_telnet_network_activity_internal.json | 3 ++- .../rules/prepackaged_rules/linux_hping_activity.json | 3 ++- .../rules/prepackaged_rules/linux_iodine_activity.json | 3 ++- .../rules/prepackaged_rules/linux_mknod_activity.json | 3 ++- .../prepackaged_rules/linux_netcat_network_connection.json | 3 ++- .../rules/prepackaged_rules/linux_nmap_activity.json | 3 ++- .../rules/prepackaged_rules/linux_nping_activity.json | 3 ++- .../linux_process_started_in_temp_directory.json | 3 ++- .../rules/prepackaged_rules/linux_socat_activity.json | 3 ++- .../rules/prepackaged_rules/linux_strace_activity.json | 3 ++- .../persistence_adobe_hijack_persistence.json | 3 ++- .../prepackaged_rules/persistence_kernel_module_activity.json | 3 ++- .../persistence_local_scheduled_task_commands.json | 3 ++- .../persistence_shell_activity_by_web_server.json | 3 ++- .../persistence_system_shells_via_services.json | 3 ++- .../prepackaged_rules/persistence_user_account_creation.json | 3 ++- .../privilege_escalation_setgid_bit_set_via_chmod.json | 3 ++- .../privilege_escalation_setuid_bit_set_via_chmod.json | 3 ++- .../privilege_escalation_sudoers_file_mod.json | 3 ++- .../privilege_escalation_uac_bypass_event_viewer.json | 3 ++- .../privilege_escalation_unusual_parentchild_relationship.json | 3 ++- 75 files changed, 150 insertions(+), 75 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json index 25274928aa2b7..a8be0fe97524e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies certutil.exe making a network connection. Adversaries could abuse certutil.exe to download a certificate, or malware, from a remote URL.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json index 6be1f037f967e..f2032b5bef218 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json @@ -6,6 +6,7 @@ "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_tcpdump_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_tcpdump_activity.json index d5b069f7b81e7..306a38f5d2a28 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_tcpdump_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_tcpdump_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Some normal use of this command may originate from server or network administrators engaged in network troubleshooting." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json index b22b74ebc53bc..c80f24a21d958 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Adversaries can add the 'hidden' attribute to files to hide them from the user in an attempt to evade detection.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -51,5 +52,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_iptables_or_firewall.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_iptables_or_firewall.json index e2ba81da917b3..4d4f10bbaa599 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_iptables_or_firewall.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_iptables_or_firewall.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Adversaries may attempt to disable the iptables or firewall service in an attempt to affect how a host is allowed to receive or send network traffic.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_syslog_service.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_syslog_service.json index 4f4a9aacd79aa..3c34b04a77a50 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_syslog_service.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_syslog_service.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Adversaries may attempt to disable the syslog service in an attempt to an attempt to disrupt event logging and evade detection by security controls.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json index 5bcc4a00ccd82..3cdfac92572b1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Automated tools such as Jenkins may encode or decode files as part of their normal behavior. These events can be filtered by the process executable or username values." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json index a17fd6d2702dd..2d26d867b8718 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Automated tools such as Jenkins may encode or decode files as part of their normal behavior. These events can be filtered by the process executable or username values." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json index cf09bc512916f..60ce575148f4c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies attempts to clear Windows event log stores. This is often done by attackers in an attempt to evade detection or destroy forensic evidence on a system.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_delete_volume_usn_journal_with_fsutil.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_delete_volume_usn_journal_with_fsutil.json index 0c82444dd9397..50213b9f1a42c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_delete_volume_usn_journal_with_fsutil.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_delete_volume_usn_journal_with_fsutil.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of the fsutil.exe to delete the volume USNJRNL. This technique is used by attackers to eliminate evidence of files created during post-exploitation activities.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_backup_catalogs_with_wbadmin.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_backup_catalogs_with_wbadmin.json index c76c5f20fa88b..026735f413eab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_backup_catalogs_with_wbadmin.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_backup_catalogs_with_wbadmin.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of the wbadmin.exe to delete the backup catalog. Ransomware and other malware may do this to prevent system recovery.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deletion_of_bash_command_line_history.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deletion_of_bash_command_line_history.json index b38ed94e132e1..85d8bdcb2582f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deletion_of_bash_command_line_history.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deletion_of_bash_command_line_history.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Adversaries may attempt to clear the bash command line history in an attempt to evade detection or forensic investigations.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_selinux_attempt.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_selinux_attempt.json index 229a03de39600..d107c0b262091 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_selinux_attempt.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_selinux_attempt.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies potential attempts to disable Security-Enhanced Linux (SELinux), which is a Linux kernel security feature to support access control policies. Adversaries may disable security tools to avoid possible detection of their tools and activities.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_windows_firewall_rules_with_netsh.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_windows_firewall_rules_with_netsh.json index 4800e87c180e2..6fbf9ca800f79 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_windows_firewall_rules_with_netsh.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_windows_firewall_rules_with_netsh.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of the netsh.exe to disable or weaken the local firewall. Attackers will use this command line tool to disable the firewall during troubleshooting or to enable network mobility.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_encoding_or_decoding_files_via_certutil.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_encoding_or_decoding_files_via_certutil.json index 075dd13d9819b..0d47aab2c64bd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_encoding_or_decoding_files_via_certutil.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_encoding_or_decoding_files_via_certutil.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies the use of certutil.exe to encode or decode data. CertUtil is a native Windows component which is part of Certificate Services. CertUtil is often abused by attackers to encode or decode base64 data for stealthier command and control or exfiltration.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json index 133863f8e2148..df7fc85b63d4a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json @@ -6,6 +6,7 @@ "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual. It is quite unusual for this program to be started by an Office application like Word or Excel." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -57,5 +58,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json index 85d348bb14be0..aa4674f75bcd0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json @@ -6,6 +6,7 @@ "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json index 38482c0a70fc9..da7d91933bd2a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json @@ -6,6 +6,7 @@ "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json index 7db683caf2bb2..8e4f7366a7657 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json @@ -6,6 +6,7 @@ "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_unusal_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_unusal_process.json index 1c4666955dde0..4f353a6ff9e6f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_unusal_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_unusal_process.json @@ -6,6 +6,7 @@ "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual. If a build system triggers this rule it can be exempted by process, user or host name." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -42,5 +43,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_deletion_via_shred.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_deletion_via_shred.json index c375ea7b19b37..5b02f63a1c7f7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_deletion_via_shred.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_deletion_via_shred.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Malware or other files dropped or created on a system by an adversary may leave traces behind as to what was done within a network and how. Adversaries may remove these files over the course of an intrusion to keep their footprint low or remove them at the end as part of the post-intrusion cleanup process.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_mod_writable_dir.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_mod_writable_dir.json index 22090e1a241e7..8ee2d4fda7bf8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_mod_writable_dir.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_mod_writable_dir.json @@ -6,6 +6,7 @@ "false_positives": [ "Certain programs or applications may modify files or change ownership in writable directories. These can be exempted by username." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json index 00491937e9aae..f5345b2276e8a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Automated tools such as Jenkins may encode or decode files as part of their normal behavior. These events can be filtered by the process executable or username values." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json index 16a398011fc53..e66968a50709e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json @@ -6,6 +6,7 @@ "false_positives": [ "Certain tools may create hidden temporary files or directories upon installation or as part of their normal behavior. These events can be filtered by the process arguments, username, or process name values." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -55,5 +56,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_kernel_module_removal.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_kernel_module_removal.json index 11781cb719599..ad751a1031437 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_kernel_module_removal.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_kernel_module_removal.json @@ -6,6 +6,7 @@ "false_positives": [ "There is usually no reason to remove modules, but some buggy modules require it. These can be exempted by username. Note that some Linux distributions are not built to support the removal of modules at all." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -57,5 +58,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_misc_lolbin_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_misc_lolbin_connecting_to_the_internet.json index 7d931725fa6eb..5b5f69a0aef74 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_misc_lolbin_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_misc_lolbin_connecting_to_the_internet.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Binaries signed with trusted digital certificates can execute on Windows systems protected by digital signature validation. Adversaries may use these binaries to 'live off the land' and execute malicious files that could bypass application allowlists and signature validation.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -51,5 +52,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_modification_of_boot_config.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_modification_of_boot_config.json index 1bffe7a1cfc24..6025fc5ca6452 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_modification_of_boot_config.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_modification_of_boot_config.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of bcdedit.exe to delete boot configuration data. This tactic is sometimes used as by malware or an attacker as a destructive technique.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json index f3cc5c2eec8a3..8a504281b03f7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of vssadmin.exe for shadow copy deletion on endpoints. This commonly occurs in tandem with ransomware or other destructive attacks.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_wmic.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_wmic.json index 334276142ca42..2ae938bb34104 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_wmic.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_wmic.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of wmic.exe for shadow copy deletion on endpoints. This commonly occurs in tandem with ransomware or other destructive attacks.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_kernel_module_enumeration.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_kernel_module_enumeration.json index 0e4bea426c591..af9c4b5409964 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_kernel_module_enumeration.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_kernel_module_enumeration.json @@ -6,6 +6,7 @@ "false_positives": [ "Security tools and device drivers may run these programs in order to enumerate kernel modules. Use of these programs by ordinary users is uncommon. These can be exempted by process name or username." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json index 6ac2bbf355961..f1a214b7cd436 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies the SYSTEM account using the Net utility. The Net utility is a component of the Windows operating system. It is used in command line operations for control of users, groups, services, and network connections.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_virtual_machine_fingerprinting.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_virtual_machine_fingerprinting.json index e73aa5f4566a7..d913a92e2ee0e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_virtual_machine_fingerprinting.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_virtual_machine_fingerprinting.json @@ -6,6 +6,7 @@ "false_positives": [ "Certain tools or automated software may enumerate hardware information. These tools can be exempted via user name or process arguments to eliminate potential noise." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_commmand.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_commmand.json index 0017186787139..a8b34362d9579 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_commmand.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_commmand.json @@ -6,6 +6,7 @@ "false_positives": [ "Security testing tools and frameworks may run this command. Some normal use of this command may originate from automation tools and frameworks." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json index 0ba6480fe42a1..46208f3753fa1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json @@ -6,6 +6,7 @@ "false_positives": [ "Administrators may use the command prompt for regular administrative tasks. It's important to baseline your environment for network connections being made from the command prompt to determine any abnormal use of this tool." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json index 2d3edb0f5f6cc..c619d8f764bc4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies a suspicious parent child process relationship with cmd.exe descending from PowerShell.exe.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -51,5 +52,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json index 3a4b4915f3c8b..140212e4148eb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies a suspicious parent child process relationship with cmd.exe descending from svchost.exe", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_html_help_executable_program_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_html_help_executable_program_connecting_to_the_internet.json index a2eb76b9831f0..963c6b2e53ed6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_html_help_executable_program_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_html_help_executable_program_connecting_to_the_internet.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Compiled HTML files (.chm) are commonly distributed as part of the Microsoft HTML Help system. Adversaries may conceal malicious code in a CHM file and deliver it to a victim for execution. CHM content is loaded by the HTML Help executable program (hh.exe).", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -51,5 +52,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_local_service_commands.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_local_service_commands.json index e43ab9de86ef7..7b20cefdc67f0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_local_service_commands.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_local_service_commands.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies use of sc.exe to create, modify, or start services on remote hosts. This could be indicative of adversary lateral movement but will be noisy if commonly done by admins.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json index 9d480259d49de..629efa90a71ea 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies MsBuild.exe making outbound network connections. This may indicate adversarial activity as MsBuild is often leveraged by adversaries to execute code and evade detection.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_mshta_making_network_connections.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_mshta_making_network_connections.json index cdef5f16e5cd7..7af823070889f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_mshta_making_network_connections.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_mshta_making_network_connections.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies mshta.exe making a network connection. This may indicate adversarial activity as mshta.exe is often leveraged by adversaries to execute malicious scripts and evade detection.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msxsl_network.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msxsl_network.json index d501bda08c3a5..1dc75575636fb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msxsl_network.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msxsl_network.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies msxsl.exe making a network connection. This may indicate adversarial activity as msxsl.exe is often leveraged by adversaries to execute malicious scripts and evade detection.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json index e82b42869e44d..9b6ee099116f3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies when a terminal (tty) is spawned via Perl. Attackers may upgrade a simple reverse shell to a fully interactive tty after obtaining initial access to a host.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_psexec_lateral_movement_command.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_psexec_lateral_movement_command.json index e4c84fd3c3b83..f647d8d00e084 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_psexec_lateral_movement_command.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_psexec_lateral_movement_command.json @@ -6,6 +6,7 @@ "false_positives": [ "PsExec is a dual-use tool that can be used for benign or malicious activity. It's important to baseline your environment to determine the amount of noise to expect from this tool." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json index 3aa9ac20bba9e..d9c26a9c26cc9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies when a terminal (tty) is spawned via Python. Attackers may upgrade a simple reverse shell to a fully interactive tty after obtaining initial access to a host.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json index 0a1ba97bd01ea..b3b6a2b0c7fab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json @@ -6,6 +6,7 @@ "false_positives": [ "Security testing may produce events like this. Activity of this kind performed by non-engineers and ordinary users is unusual." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -54,5 +55,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_script_executing_powershell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_script_executing_powershell.json index 7305247192f57..6d7f11f01fae0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_script_executing_powershell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_script_executing_powershell.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies a PowerShell process launched by either cscript.exe or wscript.exe. Observing Windows scripting processes executing a PowerShell script, may be indicative of malicious activity.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json index 7ff8eb9424d5f..005a0c38c8a8b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies suspicious child processes of frequently targeted Microsoft Office applications (Word, PowerPoint, Excel). These child processes are often launched during exploitation of Office applications or from documents with malicious macros.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_outlook_child_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_outlook_child_process.json index e923407765f8f..74e21c7d17479 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_outlook_child_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_outlook_child_process.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies suspicious child processes of Microsoft Outlook. These child processes are often associated with spear phishing activity.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_pdf_reader.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_pdf_reader.json index 24a744ce30832..adf1a76bfb901 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_pdf_reader.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_pdf_reader.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies suspicious child processes of PDF reader applications. These child processes are often launched via exploitation of PDF applications or social engineering.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json index 529f2199e46dc..1104159350655 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies unusual instances of rundll32.exe making outbound network connections. This may indicate adversarial activity and may identify malicious DLLs.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json index 69a25b3b24bac..854ecc40d76ab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies network activity from unexpected system applications. This may indicate adversarial activity as these applications are often leveraged by adversaries to execute code and evade detection.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_net_com_assemblies.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_net_com_assemblies.json index cae5d1b7e0f1f..d9dcbfe25a4c2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_net_com_assemblies.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_net_com_assemblies.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "RegSvcs.exe and RegAsm.exe are Windows command line utilities that are used to register .NET Component Object Model (COM) assemblies. Adversaries can use RegSvcs.exe and RegAsm.exe to proxy execution of code through a trusted Windows utility.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -51,5 +52,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_direct_outbound_smb_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_direct_outbound_smb_connection.json index 8a68b26abad20..e4014b22a6c09 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_direct_outbound_smb_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_direct_outbound_smb_connection.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies unexpected processes making network connections over port 445. Windows File Sharing is typically implemented over Server Message Block (SMB), which communicates between hosts using port 445. When legitimate, these network connections are established by the kernel. Processes making 445/tcp connections may be port scanners, exploits, or suspicious user-level processes moving laterally.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_external.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_external.json index 2ea75dbd758cb..e4804329c0f30 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_external.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_external.json @@ -6,6 +6,7 @@ "false_positives": [ "Telnet can be used for both benign or malicious purposes. Telnet is included by default in some Linux distributions, so its presence is not inherently suspicious. The use of Telnet to manage devices remotely has declined in recent years in favor of more secure protocols such as SSH. Telnet usage by non-automated tools or frameworks may be suspicious." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_internal.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_internal.json index 4379759608aba..30312987d166c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_internal.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_internal.json @@ -6,6 +6,7 @@ "false_positives": [ "Telnet can be used for both benign or malicious purposes. Telnet is included by default in some Linux distributions, so its presence is not inherently suspicious. The use of Telnet to manage devices remotely has declined in recent years in favor of more secure protocols such as SSH. Telnet usage by non-automated tools or frameworks may be suspicious." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json index 24104439cd0ec..3a5c4d9e69d49 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Normal use of hping is uncommon apart from security testing and research. Use by non-security engineers is very uncommon." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json index 73bf20a5a175e..63c82c5662df6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Normal use of Iodine is uncommon apart from security testing and research. Use by non-security engineers is very uncommon." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json index 1895caf4dea81..37d5468c773bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Mknod is a Linux system program. Some normal use of this program, at varying levels of frequency, may originate from scripts, automation tools, and frameworks. Usage by web servers is more likely to be suspicious." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json index ac46bcbdbc083..bce10f640691b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json @@ -6,6 +6,7 @@ "false_positives": [ "Netcat is a dual-use tool that can be used for benign or malicious activity. Netcat is included in some Linux distributions so its presence is not necessarily suspicious. Some normal use of this program, while uncommon, may originate from scripts, automation tools, and frameworks." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -27,5 +28,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json index 2825dc28ad18f..5d9e338425bda 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Security testing tools and frameworks may run `Nmap` in the course of security auditing. Some normal use of this command may originate from security engineers and network or server administrators. Use of nmap by ordinary users is uncommon." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json index 234a09e9607b9..bd019c9a80c4c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Some normal use of this command may originate from security engineers and network or server administrators, but this is usually not routine or unannounced. Use of `Nping` by non-engineers or ordinary users is uncommon." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json index 759622804444e..f0bbc892d7d9c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json @@ -6,6 +6,7 @@ "false_positives": [ "Build systems, like Jenkins, may start processes in the `/tmp` directory. These can be exempted by name or by username." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -22,5 +23,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json index cd38aff3f2164..fac03d31b57bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Socat is a dual-use tool that can be used for benign or malicious activity. Some normal use of this program, at varying levels of frequency, may originate from scripts, automation tools, and frameworks. Usage by web servers is more likely to be suspicious." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json index 7fcb9f915c560..c1b782d612ccb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Strace is a dual-use tool that can be used for benign or malicious activity. Some normal use of this command may originate from developers or SREs engaged in debugging or system call tracing." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -25,5 +26,5 @@ "Linux" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_adobe_hijack_persistence.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_adobe_hijack_persistence.json index 3392a1bff23b8..a4c62b98fb060 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_adobe_hijack_persistence.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_adobe_hijack_persistence.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Detects writing executable files that will be automatically launched by Adobe on launch.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_kernel_module_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_kernel_module_activity.json index e76379d171bf7..e3dedeef07eb5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_kernel_module_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_kernel_module_activity.json @@ -6,6 +6,7 @@ "false_positives": [ "Security tools and device drivers may run these programs in order to load legitimate kernel modules. Use of these programs by ordinary users is uncommon." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -42,5 +43,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json index b9e7f941ee5df..8b81789f6aa8f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json @@ -6,6 +6,7 @@ "false_positives": [ "Legitimate scheduled tasks may be created during installation of new software." ], + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -39,5 +40,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_shell_activity_by_web_server.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_shell_activity_by_web_server.json index 0cf6fcdb3875a..2aaf0012acabf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_shell_activity_by_web_server.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_shell_activity_by_web_server.json @@ -6,6 +6,7 @@ "false_positives": [ "Network monitoring or management products may have a web server component that runs shell commands as part of normal behavior." ], + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -42,5 +43,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_system_shells_via_services.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_system_shells_via_services.json index 59715dae441f4..32d78480325e6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_system_shells_via_services.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_system_shells_via_services.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Windows services typically run as SYSTEM and can be used as a privilege escalation opportunity. Malware or penetration testers may run a shell as a service to gain SYSTEM permissions.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_account_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_account_creation.json index 7465751d5cd49..3f2e00f0976de 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_account_creation.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_account_creation.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies attempts to create new local users. This is sometimes done by attackers to increase access to a system or domain.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json index 9550eea6ca6aa..bb0856c0452d5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "An adversary may add the setgid bit to a file or directory in order to run a file with the privileges of the owning group. An adversary can take advantage of this to either do a shell escape or exploit a vulnerability in an application with the setgid bit to get code running in a different user\u2019s context. Additionally, adversaries can use this mechanism on their own malware to make sure they're able to execute in elevated contexts in the future.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -52,5 +53,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json index 343426953add6..4cf60d2c9d0de 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "An adversary may add the setuid bit to a file or directory in order to run a file with the privileges of the owning user. An adversary can take advantage of this to either do a shell escape or exploit a vulnerability in an application with the setuid bit to get code running in a different user\u2019s context. Additionally, adversaries can use this mechanism on their own malware to make sure they're able to execute in elevated contexts in the future.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -52,5 +53,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_sudoers_file_mod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_sudoers_file_mod.json index 44b50c74bafe6..73a804fcbda8f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_sudoers_file_mod.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_sudoers_file_mod.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "A sudoers file specifies the commands that users or groups can run and from which terminals. Adversaries can take advantage of these configurations to execute commands as other users or spawn processes with higher privileges.", + "from": "now-9m", "index": [ "auditbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_event_viewer.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_event_viewer.json index 50692dae3856f..740ff47e5abe5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_event_viewer.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_event_viewer.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies User Account Control (UAC) bypass via eventvwr.exe. Attackers bypass UAC to stealthily execute code with elevated permissions.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json index 8f938c0ceee6d..c6c5cbce2c095 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json @@ -3,6 +3,7 @@ "Elastic" ], "description": "Identifies Windows programs run from unexpected parent processes. This could indicate masquerading or other strange activity on a system.", + "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" @@ -36,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } From ddf99b64db371f22f6752adc50648fcf2ff413fb Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 26 Aug 2020 09:09:40 +0200 Subject: [PATCH 14/56] [Lens] Fix rollup related bugs (#75314) Co-authored-by: Marta Bondyra --- .../datapanel.test.tsx | 19 +- .../indexpattern_datasource/datapanel.tsx | 8 +- .../dimension_panel/dimension_panel.test.tsx | 2 + .../fields_accordion.tsx | 12 +- .../indexpattern.test.ts | 2 + .../indexpattern_suggestions.test.tsx | 7 + .../layerpanel.test.tsx | 3 + .../indexpattern_datasource/loader.test.ts | 15 +- .../public/indexpattern_datasource/loader.ts | 8 + .../public/indexpattern_datasource/mocks.ts | 2 + .../definitions/date_histogram.test.tsx | 43 +- .../operations/definitions/date_histogram.tsx | 33 +- .../operations/definitions/index.ts | 2 +- .../operations/definitions/metrics.tsx | 2 +- .../operations/definitions/terms.test.tsx | 5 +- .../operations/definitions/terms.tsx | 2 +- .../operations/operations.test.ts | 1 + .../state_helpers.test.ts | 1 + .../indexpattern_datasource/to_expression.ts | 6 +- .../public/indexpattern_datasource/types.ts | 1 + x-pack/test/functional/apps/lens/index.ts | 3 + x-pack/test/functional/apps/lens/rollup.ts | 75 + .../es_archives/lens/rollup/config/data.json | 65 + .../lens/rollup/config/mappings.json | 1294 +++++++++++++++++ .../es_archives/lens/rollup/data/data.json | 59 + .../lens/rollup/data/mappings.json | 129 ++ 26 files changed, 1765 insertions(+), 34 deletions(-) create mode 100644 x-pack/test/functional/apps/lens/rollup.ts create mode 100644 x-pack/test/functional/es_archives/lens/rollup/config/data.json create mode 100644 x-pack/test/functional/es_archives/lens/rollup/config/mappings.json create mode 100644 x-pack/test/functional/es_archives/lens/rollup/data/data.json create mode 100644 x-pack/test/functional/es_archives/lens/rollup/data/mappings.json diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index 8291b673cd17a..f17bf172b0fb1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -84,6 +84,7 @@ const initialState: IndexPatternPrivateState = { id: '1', title: 'idx1', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -134,6 +135,7 @@ const initialState: IndexPatternPrivateState = { id: '2', title: 'idx2', timeFieldName: 'timestamp', + hasRestrictions: true, fields: [ { name: 'timestamp', @@ -191,6 +193,7 @@ const initialState: IndexPatternPrivateState = { id: '3', title: 'idx3', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -322,8 +325,20 @@ describe('IndexPattern Data Panel', () => { isFirstExistenceFetch: false, currentIndexPatternId: 'a', indexPatterns: { - a: { id: 'a', title: 'aaa', timeFieldName: 'atime', fields: [] }, - b: { id: 'b', title: 'bbb', timeFieldName: 'btime', fields: [] }, + a: { + id: 'a', + title: 'aaa', + timeFieldName: 'atime', + fields: [], + hasRestrictions: false, + }, + b: { + id: 'b', + title: 'bbb', + timeFieldName: 'btime', + fields: [], + hasRestrictions: false, + }, }, layers: { 1: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 0777b9b9d8e57..f7adf91e307da 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -126,6 +126,7 @@ export function IndexPatternDataPanel({ title: indexPatterns[id].title, timeFieldName: indexPatterns[id].timeFieldName, fields: indexPatterns[id].fields, + hasRestrictions: indexPatterns[id].hasRestrictions, })); const dslQuery = buildSafeEsQuery( @@ -422,6 +423,8 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ ] ); + const fieldInfoUnavailable = existenceFetchFailed || currentIndexPattern.hasRestrictions; + return ( - {!existenceFetchFailed && ( + {!fieldInfoUnavailable && ( { foo: { id: 'foo', title: 'Foo pattern', + hasRestrictions: false, fields: [ { aggregatable: true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx index af2ed97ad8125..30a92c21ff661 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx @@ -47,6 +47,7 @@ export interface FieldsAccordionProps { renderCallout: JSX.Element; exists: boolean; showExistenceFetchError?: boolean; + hideDetails?: boolean; } export const InnerFieldsAccordion = function InnerFieldsAccordion({ @@ -61,13 +62,20 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({ fieldProps, renderCallout, exists, + hideDetails, showExistenceFetchError, }: FieldsAccordionProps) { const renderField = useCallback( (field: IndexPatternField) => ( - + ), - [fieldProps, exists] + [fieldProps, exists, hideDetails] ); return ( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 0ba7b7df97853..900cd02622aaf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -21,6 +21,7 @@ const expectedIndexPatterns = { id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -70,6 +71,7 @@ const expectedIndexPatterns = { id: '2', title: 'my-fake-restricted-pattern', timeFieldName: 'timestamp', + hasRestrictions: true, fields: [ { name: 'timestamp', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 5489dcffc52c4..663d7c18bb370 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -20,6 +20,7 @@ const expectedIndexPatterns = { id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -68,6 +69,7 @@ const expectedIndexPatterns = { 2: { id: '2', title: 'my-fake-restricted-pattern', + hasRestrictions: true, timeFieldName: 'timestamp', fields: [ { @@ -322,6 +324,7 @@ describe('IndexPattern Data Source suggestions', () => { 1: { id: '1', title: 'no timefield', + hasRestrictions: false, fields: [ { name: 'bytes', @@ -532,6 +535,7 @@ describe('IndexPattern Data Source suggestions', () => { 1: { id: '1', title: 'no timefield', + hasRestrictions: false, fields: [ { name: 'bytes', @@ -1350,6 +1354,7 @@ describe('IndexPattern Data Source suggestions', () => { 1: { id: '1', title: 'my-fake-index-pattern', + hasRestrictions: false, fields: [ { name: 'field1', @@ -1493,6 +1498,7 @@ describe('IndexPattern Data Source suggestions', () => { 1: { id: '1', title: 'my-fake-index-pattern', + hasRestrictions: false, fields: [ { name: 'field1', @@ -1555,6 +1561,7 @@ describe('IndexPattern Data Source suggestions', () => { 1: { id: '1', title: 'my-fake-index-pattern', + hasRestrictions: false, fields: [ { name: 'field1', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx index 738cdd611a7ba..92e35b257f24a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx @@ -62,6 +62,7 @@ const initialState: IndexPatternPrivateState = { id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -103,6 +104,7 @@ const initialState: IndexPatternPrivateState = { '2': { id: '2', title: 'my-fake-restricted-pattern', + hasRestrictions: true, timeFieldName: 'timestamp', fields: [ { @@ -160,6 +162,7 @@ const initialState: IndexPatternPrivateState = { id: '3', title: 'my-compatible-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index d80bf779a5d17..660be9514a92f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -40,6 +40,7 @@ const indexPattern1 = ({ id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -105,6 +106,7 @@ const indexPattern2 = ({ id: '2', title: 'my-fake-restricted-pattern', timeFieldName: 'timestamp', + hasRestrictions: true, fields: [ { name: 'timestamp', @@ -733,9 +735,9 @@ describe('loader', () => { dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, fetchJson, indexPatterns: [ - { id: '1', title: '1', fields: [] }, - { id: '2', title: '1', fields: [] }, - { id: '3', title: '1', fields: [] }, + { id: '1', title: '1', fields: [], hasRestrictions: false }, + { id: '2', title: '1', fields: [], hasRestrictions: false }, + { id: '3', title: '1', fields: [], hasRestrictions: false }, ], setState, dslQuery, @@ -783,9 +785,9 @@ describe('loader', () => { dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, fetchJson, indexPatterns: [ - { id: '1', title: '1', fields: [] }, - { id: '2', title: '1', fields: [] }, - { id: 'c', title: '1', fields: [] }, + { id: '1', title: '1', fields: [], hasRestrictions: false }, + { id: '2', title: '1', fields: [], hasRestrictions: false }, + { id: 'c', title: '1', fields: [], hasRestrictions: false }, ], setState, dslQuery, @@ -817,6 +819,7 @@ describe('loader', () => { { id: '1', title: '1', + hasRestrictions: false, fields: [{ name: 'field1' }, { name: 'field2' }] as IndexPatternField[], }, ], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index 24906790a9fc9..585a1281cbf51 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -91,6 +91,7 @@ export async function loadIndexPatterns({ timeFieldName, fieldFormatMap, fields: newFields, + hasRestrictions: !!typeMeta?.aggs, }; return { @@ -334,6 +335,7 @@ export async function syncExistingFields({ title: string; fields: IndexPatternField[]; timeFieldName?: string | null; + hasRestrictions: boolean; }>; fetchJson: HttpSetup['post']; setState: SetState; @@ -343,6 +345,12 @@ export async function syncExistingFields({ showNoDataPopover: () => void; }) { const existenceRequests = indexPatterns.map((pattern) => { + if (pattern.hasRestrictions) { + return { + indexPatternTitle: pattern.title, + existingFieldNames: pattern.fields.map((field) => field.name), + }; + } const body: Record = { dslQuery, fromDate: dateRange.fromDate, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts index 869eee67d381d..31e6240993d36 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts @@ -11,6 +11,7 @@ export const createMockedIndexPattern = (): IndexPattern => ({ id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -70,6 +71,7 @@ export const createMockedRestrictedIndexPattern = () => ({ id: '2', title: 'my-fake-restricted-pattern', timeFieldName: 'timestamp', + hasRestrictions: true, fields: [ { name: 'timestamp', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx index 48a6079c58ac0..ac6bf63c37110 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx @@ -55,6 +55,7 @@ describe('date_histogram', () => { id: '1', title: 'Mock Indexpattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', @@ -69,6 +70,7 @@ describe('date_histogram', () => { 2: { id: '2', title: 'Mock Indexpattern 2', + hasRestrictions: false, fields: [ { name: 'other_timestamp', @@ -229,13 +231,50 @@ describe('date_histogram', () => { it('should reflect params correctly', () => { const esAggsConfig = dateHistogramOperation.toEsAggsConfig( state.layers.first.columns.col1 as DateHistogramIndexPatternColumn, - 'col1' + 'col1', + state.indexPatterns['1'] ); expect(esAggsConfig).toEqual( expect.objectContaining({ params: expect.objectContaining({ interval: '42w', field: 'timestamp', + useNormalizedEsInterval: true, + }), + }) + ); + }); + + it('should not use normalized es interval for rollups', () => { + const esAggsConfig = dateHistogramOperation.toEsAggsConfig( + state.layers.first.columns.col1 as DateHistogramIndexPatternColumn, + 'col1', + { + ...state.indexPatterns['1'], + fields: [ + { + name: 'timestamp', + displayName: 'timestamp', + aggregatable: true, + searchable: true, + type: 'date', + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + time_zone: 'UTC', + calendar_interval: '42w', + }, + }, + }, + ], + } + ); + expect(esAggsConfig).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + interval: '42w', + field: 'timestamp', + useNormalizedEsInterval: false, }), }) ); @@ -300,6 +339,7 @@ describe('date_histogram', () => { { title: '', id: '', + hasRestrictions: true, fields: [ { name: 'dateField', @@ -343,6 +383,7 @@ describe('date_histogram', () => { { title: '', id: '', + hasRestrictions: false, fields: [ { name: 'dateField', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index 2236bc576e2b6..57454291d43c5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -119,21 +119,24 @@ export const dateHistogramOperation: OperationDefinition ({ - id: columnId, - enabled: true, - type: 'date_histogram', - schema: 'segment', - params: { - field: column.sourceField, - time_zone: column.params.timeZone, - useNormalizedEsInterval: true, - interval: column.params.interval, - drop_partials: false, - min_doc_count: 0, - extended_bounds: {}, - }, - }), + toEsAggsConfig: (column, columnId, indexPattern) => { + const usedField = indexPattern.fields.find((field) => field.name === column.sourceField); + return { + id: columnId, + enabled: true, + type: 'date_histogram', + schema: 'segment', + params: { + field: column.sourceField, + time_zone: column.params.timeZone, + useNormalizedEsInterval: !usedField || !usedField.aggregationRestrictions?.date_histogram, + interval: column.params.interval, + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, + }, + }; + }, paramEditor: ({ state, setState, currentColumn: currentColumn, layerId, dateRange, data }) => { const field = currentColumn && diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index ef12fca690f0c..8ef53c1e0b425 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -84,7 +84,7 @@ interface BaseOperationDefinitionProps { * Function turning a column into an agg config passed to the `esaggs` function * together with the agg configs returned from other columns. */ - toEsAggsConfig: (column: C, columnId: string) => unknown; + toEsAggsConfig: (column: C, columnId: string, indexPattern: IndexPattern) => unknown; /** * Returns true if the `column` can also be used on `newIndexPattern`. * If this function returns false, the column is removed when switching index pattern diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index e6c8a5f6ac852..4c37d95f6b050 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -68,7 +68,7 @@ function buildMetricOperation>({ sourceField: field.name, }; }, - toEsAggsConfig: (column, columnId) => ({ + toEsAggsConfig: (column, columnId, _indexPattern) => ({ id: columnId, enabled: true, type: column.operationType, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx index 05bb2ef673888..2972ed2d0231b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx @@ -13,7 +13,7 @@ import { dataPluginMock } from '../../../../../../../src/plugins/data/public/moc import { createMockedIndexPattern } from '../../mocks'; import { TermsIndexPatternColumn } from './terms'; import { termsOperation } from './index'; -import { IndexPatternPrivateState } from '../../types'; +import { IndexPatternPrivateState, IndexPattern } from '../../types'; const defaultProps = { storage: {} as IStorageWrapper, @@ -69,7 +69,8 @@ describe('terms', () => { it('should reflect params correctly', () => { const esAggsConfig = termsOperation.toEsAggsConfig( state.layers.first.columns.col1 as TermsIndexPatternColumn, - 'col1' + 'col1', + {} as IndexPattern ); expect(esAggsConfig).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx index ac1ff9da2fea0..c1b19fd5549e7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.tsx @@ -95,7 +95,7 @@ export const termsOperation: OperationDefinition = { }, }; }, - toEsAggsConfig: (column, columnId) => ({ + toEsAggsConfig: (column, columnId, _indexPattern) => ({ id: columnId, enabled: true, type: 'terms', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index 3fce2562f528e..4ac3fc89500f9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -16,6 +16,7 @@ const expectedIndexPatterns = { id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', + hasRestrictions: false, fields: [ { name: 'timestamp', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts index d7fd0d3661c86..7b6eb11efc494 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts @@ -570,6 +570,7 @@ describe('state_helpers', () => { const indexPattern: IndexPattern = { id: 'test', title: '', + hasRestrictions: true, fields: [ { name: 'fieldA', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index 9473a1523b8ca..1b87c48dc7193 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -21,7 +21,11 @@ function getExpressionForLayer( } function getEsAggsConfig(column: C, columnId: string) { - return operationDefinitionMap[column.operationType].toEsAggsConfig(column, columnId); + return operationDefinitionMap[column.operationType].toEsAggsConfig( + column, + columnId, + indexPattern + ); } const columnEntries = columnOrder.map((colId) => [colId, columns[colId]] as const); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index 95cc47e68f8a1..c101f1354b703 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -19,6 +19,7 @@ export interface IndexPattern { params: unknown; } >; + hasRestrictions: boolean; } export interface IndexPatternField { diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index f2dcf28c01743..d1ecf8fa0973a 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -31,6 +31,9 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./dashboard')); loadTestFile(require.resolve('./persistent_context')); loadTestFile(require.resolve('./lens_reporting')); + + // has to be last one in the suite because it overrides saved objects + loadTestFile(require.resolve('./rollup')); }); }); } diff --git a/x-pack/test/functional/apps/lens/rollup.ts b/x-pack/test/functional/apps/lens/rollup.ts new file mode 100644 index 0000000000000..f6882c8aed214 --- /dev/null +++ b/x-pack/test/functional/apps/lens/rollup.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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens']); + const find = getService('find'); + const listingTable = getService('listingTable'); + const esArchiver = getService('esArchiver'); + + describe('lens rollup tests', () => { + before(async () => { + await esArchiver.loadIfNeeded('lens/rollup/data'); + await esArchiver.loadIfNeeded('lens/rollup/config'); + }); + + after(async () => { + await esArchiver.unload('lens/rollup/data'); + await esArchiver.unload('lens/rollup/config'); + }); + + it('should allow creation of lens xy chart', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'sum', + field: 'bytes', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_splitDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'geo.src', + }); + expect(await find.allByCssSelector('.echLegendItem')).to.have.length(2); + + await PageObjects.lens.save('Afancilenstest'); + + // Ensure the visualization shows up in the visualize list, and takes + // us back to the visualization as we configured it. + await PageObjects.visualize.gotoVisualizationLandingPage(); + await listingTable.searchForItemWithName('Afancilenstest'); + await PageObjects.lens.clickVisualizeListItemTitle('Afancilenstest'); + await PageObjects.lens.goToTimeRange(); + + expect(await PageObjects.lens.getTitle()).to.eql('Afancilenstest'); + + // .echLegendItem__title is the only viable way of getting the xy chart's + // legend item(s), so we're using a class selector here. + expect(await find.allByCssSelector('.echLegendItem')).to.have.length(2); + }); + + it('should allow seamless transition to and from table view', async () => { + await PageObjects.lens.switchToVisualization('lnsMetric'); + await PageObjects.lens.assertMetric('Sum of bytes', '16,788'); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + expect(await PageObjects.lens.getDatatableHeaderText()).to.eql('Sum of bytes'); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('16,788'); + }); + }); +} diff --git a/x-pack/test/functional/es_archives/lens/rollup/config/data.json b/x-pack/test/functional/es_archives/lens/rollup/config/data.json new file mode 100644 index 0000000000000..268b98542fe11 --- /dev/null +++ b/x-pack/test/functional/es_archives/lens/rollup/config/data.json @@ -0,0 +1,65 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana_1", + "source": { + "migrationVersion": { + "space": "6.6.0" + }, + "references": [], + "space": { + "_reserved": true, + "description": "This is the default space!", + "disabledFeatures": [], + "name": "Default" + }, + "type": "space" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:lens-rolled-up-data", + "index": ".kibana_1", + "source": { + "index-pattern" : { + "title" : "lens_rolled_up_data", + "timeFieldName" : "@timestamp", + "fields" : "[{\"count\":0,\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"count\":0,\"name\":\"_score\",\"type\":\"number\",\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"count\":0,\"name\":\"@timestamp\",\"type\":\"date\",\"esTypes\":[\"date\"],\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"count\":0,\"name\":\"bytes\",\"type\":\"number\",\"esTypes\":[\"float\"],\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"count\":0,\"name\":\"geo.src\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "type" : "rollup", + "typeMeta" : "{\"params\":{\"rollup_index\":\"lens_rolled_up_data\"},\"aggs\":{\"date_histogram\":{\"@timestamp\":{\"agg\":\"date_histogram\",\"fixed_interval\":\"60m\",\"time_zone\":\"UTC\"}},\"sum\":{\"bytes\":{\"agg\":\"sum\"}},\"max\":{\"bytes\":{\"agg\":\"max\"}},\"terms\":{\"geo.src\":{\"agg\":\"terms\"}}}}" + }, + "type" : "index-pattern", + "references" : [ ], + "migrationVersion" : { + "index-pattern" : "7.6.0" + }, + "updated_at" : "2020-08-19T08:39:09.998Z" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "config:8.0.0", + "index": ".kibana_1", + "source": { + "config": { + "accessibility:disableAnimations": true, + "buildNum": 9007199254740991, + "dateFormat:tz": "UTC", + "defaultIndex": "logstash-*" + }, + "references": [], + "type": "config", + "updated_at": "2019-09-04T18:47:24.761Z" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/lens/rollup/config/mappings.json b/x-pack/test/functional/es_archives/lens/rollup/config/mappings.json new file mode 100644 index 0000000000000..f2a29f022ff5e --- /dev/null +++ b/x-pack/test/functional/es_archives/lens/rollup/config/mappings.json @@ -0,0 +1,1294 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + } + }, + "index": ".kibana_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "apm-telemetry": "07ee1939fa4302c62ddc052ec03fed90", + "canvas-element": "7390014e1091044523666d97247392fc", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "config": "87aca8fdb053154f11383fce3dbf3edf", + "dashboard": "d00f614b29a80360e1190193fd333bab", + "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "index-pattern": "66eccb05066c5a89924f48a9e9736499", + "infrastructure-ui-source": "ddc0ecb18383f6b26101a2fadb2dab0c", + "inventory-view": "84b320fd67209906333ffce261128462", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "lens": "21c3ea0763beb1ecb0162529706b88c5", + "map": "23d7aa4a720d4938ccde3983f87bd58d", + "maps-telemetry": "a4229f8b16a6820c6d724b7e0c1f729d", + "metrics-explorer-view": "53c5365793677328df0ccb6138bf3cdd", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "namespace": "2f4316de49999235636386fe51dc06c1", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "181661168bbadd1eff5902361e2a0d5c", + "server": "ec97f1c5da1a19609a60874e5af1100c", + "siem-ui-timeline": "1f6f0860ad7bc0dba3e42467ca40470d", + "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", + "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", + "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", + "telemetry": "e1c8bc94e443aefd9458932cc0697a4d", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "type": "2f4316de49999235636386fe51dc06c1", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "upgrade-assistant-reindex-operation": "a53a20fe086b72c9a86da3cc12dad8a6", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "url": "c7f66a0df8b1b52f17c28c4adb111105", + "visualization": "52d7a13ad68a150c4525b292d23e12cc" + } + }, + "dynamic": "strict", + "properties": { + "action": { + "properties": { + "actionTypeId": { + "type": "keyword" + }, + "config": { + "enabled": false, + "type": "object" + }, + "description": { + "type": "text" + }, + "secrets": { + "type": "binary" + } + } + }, + "action_task_params": { + "properties": { + "actionId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alert": { + "properties": { + "actions": { + "properties": { + "actionRef": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + }, + "type": "nested" + }, + "alertTypeId": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + }, + "apiKey": { + "type": "binary" + }, + "apiKeyOwner": { + "type": "keyword" + }, + "createdBy": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "interval": { + "type": "keyword" + }, + "scheduledTaskId": { + "type": "keyword" + }, + "updatedBy": { + "type": "keyword" + } + } + }, + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "dotnet": { + "null_value": 0, + "type": "long" + }, + "go": { + "null_value": 0, + "type": "long" + }, + "java": { + "null_value": 0, + "type": "long" + }, + "js-base": { + "null_value": 0, + "type": "long" + }, + "nodejs": { + "null_value": 0, + "type": "long" + }, + "python": { + "null_value": 0, + "type": "long" + }, + "ruby": { + "null_value": 0, + "type": "long" + }, + "rum-js": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "content": { + "type": "text" + }, + "help": { + "type": "text" + }, + "image": { + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "file-upload-telemetry": { + "properties": { + "filesUploadedTotalCount": { + "type": "long" + } + } + }, + "gis-map": { + "properties": { + "bounds": { + "strategy": "recursive", + "type": "geo_shape" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "properties": { + "description": { + "type": "text" + }, + "fields": { + "properties": { + "container": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "pod": { + "type": "keyword" + }, + "tiebreaker": { + "type": "keyword" + }, + "timestamp": { + "type": "keyword" + } + } + }, + "logAlias": { + "type": "keyword" + }, + "logColumns": { + "properties": { + "fieldColumn": { + "properties": { + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "messageColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + }, + "timestampColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + } + }, + "type": "nested" + }, + "metricAlias": { + "type": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "inventory-view": { + "properties": { + "autoBounds": { + "type": "boolean" + }, + "autoReload": { + "type": "boolean" + }, + "boundsOverride": { + "properties": { + "max": { + "type": "integer" + }, + "min": { + "type": "integer" + } + } + }, + "customOptions": { + "properties": { + "field": { + "type": "keyword" + }, + "text": { + "type": "keyword" + } + }, + "type": "nested" + }, + "filterQuery": { + "properties": { + "expression": { + "type": "keyword" + }, + "kind": { + "type": "keyword" + } + } + }, + "groupBy": { + "properties": { + "field": { + "type": "keyword" + }, + "label": { + "type": "keyword" + } + }, + "type": "nested" + }, + "metric": { + "properties": { + "type": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "nodeType": { + "type": "keyword" + }, + "time": { + "type": "integer" + }, + "view": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "lens": { + "properties": { + "expression": { + "index": false, + "type": "keyword" + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "properties": { + "attributesPerMap": { + "properties": { + "dataSourcesCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + }, + "emsVectorLayersCount": { + "dynamic": "true", + "type": "object" + }, + "layerTypesCount": { + "dynamic": "true", + "type": "object" + }, + "layersCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + } + } + }, + "mapsTotalCount": { + "type": "long" + }, + "timeCaptured": { + "type": "date" + } + } + }, + "metrics-explorer-view": { + "properties": { + "chartOptions": { + "properties": { + "stack": { + "type": "boolean" + }, + "type": { + "type": "keyword" + }, + "yAxisMode": { + "type": "keyword" + } + } + }, + "currentTimerange": { + "properties": { + "from": { + "type": "keyword" + }, + "interval": { + "type": "keyword" + }, + "to": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "options": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "filterQuery": { + "type": "keyword" + }, + "groupBy": { + "type": "keyword" + }, + "limit": { + "type": "integer" + }, + "metrics": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "label": { + "type": "keyword" + } + }, + "type": "nested" + } + } + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "space": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "visualization": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "ml-telemetry": { + "properties": { + "file_data_visualizer": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "enabled": false, + "type": "object" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "index": false, + "type": "keyword" + } + } + }, + "timefilter": { + "enabled": false, + "type": "object" + }, + "title": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "siem-ui-timeline": { + "properties": { + "columns": { + "properties": { + "aggregatable": { + "type": "boolean" + }, + "category": { + "type": "keyword" + }, + "columnHeaderType": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "example": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "indexes": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "placeholder": { + "type": "text" + }, + "searchable": { + "type": "boolean" + }, + "type": { + "type": "keyword" + } + } + }, + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "dataProviders": { + "properties": { + "and": { + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + } + } + }, + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + } + } + }, + "dateRange": { + "properties": { + "end": { + "type": "date" + }, + "start": { + "type": "date" + } + } + }, + "description": { + "type": "text" + }, + "favorite": { + "properties": { + "favoriteDate": { + "type": "date" + }, + "fullName": { + "type": "text" + }, + "keySearch": { + "type": "text" + }, + "userName": { + "type": "text" + } + } + }, + "kqlMode": { + "type": "keyword" + }, + "kqlQuery": { + "properties": { + "filterQuery": { + "properties": { + "kuery": { + "properties": { + "expression": { + "type": "text" + }, + "kind": { + "type": "keyword" + } + } + }, + "serializedQuery": { + "type": "text" + } + } + } + } + }, + "sort": { + "properties": { + "columnId": { + "type": "keyword" + }, + "sortDirection": { + "type": "keyword" + } + } + }, + "title": { + "type": "text" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-note": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-pinned-event": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "imageUrl": { + "index": false, + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "dynamic": "true", + "properties": { + "indexName": { + "type": "keyword" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "null_value": true, + "type": "boolean" + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "null_value": 0, + "type": "long" + }, + "indices": { + "null_value": 0, + "type": "long" + }, + "overview": { + "null_value": 0, + "type": "long" + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "null_value": 0, + "type": "long" + }, + "open": { + "null_value": 0, + "type": "long" + }, + "start": { + "null_value": 0, + "type": "long" + }, + "stop": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchRefName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/lens/rollup/data/data.json b/x-pack/test/functional/es_archives/lens/rollup/data/data.json new file mode 100644 index 0000000000000..36dc10c05f0b9 --- /dev/null +++ b/x-pack/test/functional/es_archives/lens/rollup/data/data.json @@ -0,0 +1,59 @@ +{ + "type": "doc", + "value": { + "index": "lens_rolled_up_data", + "id": "lens_rolled_up_data$vuSq1a9Ph2Nq-2yfGpE34g", + "source": { + "@timestamp.date_histogram.time_zone": "UTC", + "@timestamp.date_histogram.timestamp": 1442710800000, + "geo.src.terms.value": "CN", + "bytes.max.value": 5678.0, + "_rollup.version": 2, + "bytes.sum.value": 5678.0, + "@timestamp.date_histogram.interval": "60m", + "geo.src.terms._count": 1, + "@timestamp.date_histogram._count": 1, + "_rollup.id": "lens_rolled_up_data" + } + } +} + +{ + "type": "doc", + "value": { + "index": "lens_rolled_up_data", + "id": "lens_rolled_up_data$QFyUWoecErSYPMrIb6CgZA", + "source": { + "@timestamp.date_histogram.time_zone": "UTC", + "@timestamp.date_histogram.timestamp": 1442710800000, + "geo.src.terms.value": "US", + "bytes.max.value": 1234.0, + "_rollup.version": 2, + "bytes.sum.value": 1234.0, + "@timestamp.date_histogram.interval": "60m", + "geo.src.terms._count": 1, + "@timestamp.date_histogram._count": 1, + "_rollup.id": "lens_rolled_up_data" + } + } +} + +{ + "type": "doc", + "value": { + "index": "lens_rolled_up_data", + "id": "lens_rolled_up_data$cKCjv1OPjYiyv5WPPblohw", + "source": { + "@timestamp.date_histogram.time_zone": "UTC", + "@timestamp.date_histogram.timestamp": 1442714400000, + "geo.src.terms.value": "CN", + "bytes.max.value": 9876.0, + "_rollup.version": 2, + "bytes.sum.value": 9876.0, + "@timestamp.date_histogram.interval": "60m", + "geo.src.terms._count": 1, + "@timestamp.date_histogram._count": 1, + "_rollup.id": "lens_rolled_up_data" + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/lens/rollup/data/mappings.json b/x-pack/test/functional/es_archives/lens/rollup/data/mappings.json new file mode 100644 index 0000000000000..0e47a632bbf3f --- /dev/null +++ b/x-pack/test/functional/es_archives/lens/rollup/data/mappings.json @@ -0,0 +1,129 @@ +{ + "type": "index", + "value": { + "index": "lens_rolled_up_data", + "mappings": { + "_meta": { + "_rollup": { + "lens_rolled_up_data": { + "cron": "0 * * * * ?", + "rollup_index": "lens_rolled_up_data", + "groups": { + "date_histogram": { + "fixed_interval": "60m", + "field": "@timestamp", + "time_zone": "UTC" + }, + "terms": { + "fields": ["geo.src", "ip"] + } + }, + "id": "lens_rolled_up_data", + "metrics": [ + { + "field": "bytes", + "metrics": ["sum", "max"] + } + ], + "index_pattern": "lens_raw", + "timeout": "20s", + "page_size": 1000 + } + }, + "rollup-version": "8.0.0" + }, + "dynamic_templates": [ + { + "strings": { + "match_mapping_type": "string", + "mapping": { + "type": "keyword" + } + } + }, + { + "date_histograms": { + "path_match": "*.date_histogram.timestamp", + "mapping": { + "type": "date" + } + } + } + ], + "properties": { + "@timestamp": { + "properties": { + "date_histogram": { + "properties": { + "_count": { + "type": "long" + }, + "interval": { + "type": "keyword" + }, + "time_zone": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + } + } + } + } + }, + "_rollup": { + "properties": { + "id": { + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "bytes": { + "properties": { + "max": { + "properties": { + "value": { + "type": "float" + } + } + }, + "sum": { + "properties": { + "value": { + "type": "float" + } + } + } + } + }, + "geo": { + "properties": { + "src": { + "properties": { + "terms": { + "properties": { + "_count": { + "type": "long" + }, + "value": { + "type": "keyword" + } + } + } + } + } + } + } + } + }, + "settings": { + "index": { + "number_of_shards": "1", + "number_of_replicas": "0" + } + } + } +} From 686cde88afa4c303ff92906ae6c100d130967812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Wed, 26 Aug 2020 10:38:54 +0200 Subject: [PATCH 15/56] [Logs UI] View log details for anomaly log examples (#75425) Co-authored-by: Elastic Machine --- .../logging/log_entry_flyout/index.tsx | 2 +- .../log_entry_flyout/log_entry_flyout.tsx | 36 ++-- .../logs/log_entry_rate/page_providers.tsx | 23 ++- .../log_entry_rate/page_results_content.tsx | 183 +++++++++++------- .../sections/anomalies/log_entry_example.tsx | 27 ++- .../pages/logs/stream/page_logs_content.tsx | 21 +- 6 files changed, 184 insertions(+), 108 deletions(-) diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/index.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/index.tsx index 521fbf209870c..f11d6cdb8d26d 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/index.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/index.tsx @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { LogEntryFlyout } from './log_entry_flyout'; +export * from './log_entry_flyout'; diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx index 57f27ee76184b..76ffada510e51 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx @@ -26,12 +26,10 @@ import { InfraLoadingPanel } from '../../loading'; import { LogEntryActionsMenu } from './log_entry_actions_menu'; import { LogEntriesItem, LogEntriesItemField } from '../../../../common/http_api'; -interface Props { +export interface LogEntryFlyoutProps { flyoutItem: LogEntriesItem | null; setFlyoutVisibility: (visible: boolean) => void; - setFilter: (filter: string) => void; - setTarget: (timeKey: TimeKey, flyoutItemId: string) => void; - + setFilter: (filter: string, flyoutItemId: string, timeKey?: TimeKey) => void; loading: boolean; } @@ -40,27 +38,27 @@ export const LogEntryFlyout = ({ loading, setFlyoutVisibility, setFilter, - setTarget, -}: Props) => { +}: LogEntryFlyoutProps) => { const createFilterHandler = useCallback( (field: LogEntriesItemField) => () => { + if (!flyoutItem) { + return; + } + const filter = `${field.field}:"${field.value}"`; - setFilter(filter); + const timestampMoment = moment(flyoutItem.key.time); + let target; - if (flyoutItem && flyoutItem.key) { - const timestampMoment = moment(flyoutItem.key.time); - if (timestampMoment.isValid()) { - setTarget( - { - time: timestampMoment.valueOf(), - tiebreaker: flyoutItem.key.tiebreaker, - }, - flyoutItem.id - ); - } + if (timestampMoment.isValid()) { + target = { + time: timestampMoment.valueOf(), + tiebreaker: flyoutItem.key.tiebreaker, + }; } + + setFilter(filter, flyoutItem.id, target); }, - [flyoutItem, setFilter, setTarget] + [flyoutItem, setFilter] ); const closeFlyout = useCallback(() => setFlyoutVisibility(false), [setFlyoutVisibility]); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx index e986fa37c2b2c..4ad654614237d 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx @@ -10,6 +10,7 @@ import { LogEntryCategoriesModuleProvider } from '../../../containers/logs/log_a import { LogEntryRateModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { useActiveKibanaSpace } from '../../../hooks/use_kibana_space'; +import { LogFlyout } from '../../../containers/logs/log_flyout'; export const LogEntryRatePageProviders: React.FunctionComponent = ({ children }) => { const { sourceId, sourceConfiguration } = useLogSourceContext(); @@ -23,20 +24,22 @@ export const LogEntryRatePageProviders: React.FunctionComponent = ({ children }) } return ( - - + - {children} - - + + {children} + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index 65cc4a6c4a704..de72ac5c5a574 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -7,7 +7,9 @@ import datemath from '@elastic/datemath'; import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from '@elastic/eui'; import moment from 'moment'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { encode, RisonValue } from 'rison-node'; +import { stringify } from 'query-string'; +import React, { useCallback, useEffect, useMemo, useState, useContext } from 'react'; import { euiStyled, useTrackPageview } from '../../../../../observability/public'; import { TimeRange } from '../../../../common/http_api/shared/time_range'; import { bucketSpan } from '../../../../common/log_analysis'; @@ -29,6 +31,9 @@ import { StringTimeRange, useLogAnalysisResultsUrlState, } from './use_log_entry_rate_results_url_state'; +import { LogEntryFlyout, LogEntryFlyoutProps } from '../../../components/logging/log_entry_flyout'; +import { LogFlyout } from '../../../containers/logs/log_flyout'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; export const SORT_DEFAULTS = { direction: 'desc' as const, @@ -42,6 +47,7 @@ export const PAGINATION_DEFAULTS = { export const LogEntryRateResultsContent: React.FunctionComponent = () => { useTrackPageview({ app: 'infra_logs', path: 'log_entry_rate_results' }); useTrackPageview({ app: 'infra_logs', path: 'log_entry_rate_results', delay: 15000 }); + const navigateToApp = useKibana().services.application?.navigateToApp; const { sourceId } = useLogSourceContext(); @@ -79,6 +85,30 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { lastChangedTime: Date.now(), })); + const linkToLogStream = useCallback( + (filter, id, timeKey) => { + const params = { + logPosition: encode({ + end: moment(queryTimeRange.value.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + position: timeKey as RisonValue, + start: moment(queryTimeRange.value.startTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + streamLive: false, + }), + flyoutOptions: encode({ + surroundingLogsId: id, + }), + logFilter: encode({ + expression: filter, + kind: 'kuery', + }), + }; + + // eslint-disable-next-line no-unused-expressions + navigateToApp?.('logs', { path: `/stream?${stringify(params)}` }); + }, + [queryTimeRange, navigateToApp] + ); + const bucketDuration = useMemo( () => getBucketDuration(queryTimeRange.value.startTime, queryTimeRange.value.endTime), [queryTimeRange.value.endTime, queryTimeRange.value.startTime] @@ -115,6 +145,10 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { filteredDatasets: selectedDatasets, }); + const { flyoutVisible, setFlyoutVisibility, flyoutItem, isLoading: isFlyoutLoading } = useContext( + LogFlyout.Context + ); + const handleQueryTimeRangeChange = useCallback( ({ start: startTime, end: endTime }: { start: string; end: string }) => { setQueryTimeRange({ @@ -198,75 +232,86 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { ); return ( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - + + + + + + + + + + {flyoutVisible ? ( + + ) : null} + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx index fece2522de574..a543f95bf4ffb 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useCallback, useState } from 'react'; +import React, { useMemo, useCallback, useState, useContext } from 'react'; import moment from 'moment'; import { encode } from 'rison-node'; import { i18n } from '@kbn/i18n'; @@ -37,6 +37,7 @@ import { } from '../../../../../utils/source_configuration'; import { localizedDate } from '../../../../../../common/formatters/datetime'; import { LogEntryAnomaly } from '../../../../../../common/http_api'; +import { LogFlyout } from '../../../../../containers/logs/log_flyout'; export const exampleMessageScale = 'medium' as const; export const exampleTimestampFormat = 'time' as const; @@ -45,6 +46,13 @@ const MENU_LABEL = i18n.translate('xpack.infra.logAnomalies.logEntryExamplesMenu defaultMessage: 'View actions for log entry', }); +const VIEW_DETAILS_LABEL = i18n.translate( + 'xpack.infra.logs.analysis.logEntryExamplesViewDetailsLabel', + { + defaultMessage: 'View details', + } +); + const VIEW_IN_STREAM_LABEL = i18n.translate( 'xpack.infra.logs.analysis.logEntryExamplesViewInStreamLabel', { @@ -80,6 +88,8 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ const setItemIsHovered = useCallback(() => setIsHovered(true), []); const setItemIsNotHovered = useCallback(() => setIsHovered(false), []); + const { setFlyoutVisibility, setFlyoutId } = useContext(LogFlyout.Context); + // handle special cases for the dataset value const humanFriendlyDataset = getFriendlyNameForPartitionId(dataset); @@ -116,6 +126,13 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ } return [ + { + label: VIEW_DETAILS_LABEL, + onClick: () => { + setFlyoutId(id); + setFlyoutVisibility(true); + }, + }, { label: VIEW_IN_STREAM_LABEL, onClick: viewInStreamLinkProps.onClick, @@ -127,7 +144,13 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ href: viewAnomalyInMachineLearningLinkProps.href, }, ]; - }, [viewInStreamLinkProps, viewAnomalyInMachineLearningLinkProps]); + }, [ + id, + setFlyoutId, + setFlyoutVisibility, + viewInStreamLinkProps, + viewAnomalyInMachineLearningLinkProps, + ]); return ( { const [, { setContextEntry }] = useContext(ViewLogInContext.Context); + const setFilter = useCallback( + (filter, flyoutItemId, timeKey) => { + applyLogFilterQuery(filter); + if (timeKey) { + jumpToTargetPosition(timeKey); + } + setSurroundingLogsId(flyoutItemId); + stopLiveStreaming(); + }, + [applyLogFilterQuery, jumpToTargetPosition, setSurroundingLogsId, stopLiveStreaming] + ); + return ( <> @@ -65,12 +77,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { {flyoutVisible ? ( { - jumpToTargetPosition(timeKey); - setSurroundingLogsId(flyoutItemId); - stopLiveStreaming(); - }} + setFilter={setFilter} setFlyoutVisibility={setFlyoutVisibility} flyoutItem={flyoutItem} loading={isLoading} From 4efaba3298f79ceda4586fcb891d84987fc95980 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Wed, 26 Aug 2020 11:48:27 +0300 Subject: [PATCH 16/56] Reset chrome fields while switching an app (#73064) * Reset chrome help extension while switching an app * Reset other chrome fields * Set docTitle in saved objects app * Add unit tests Co-authored-by: Elastic Machine --- src/core/public/chrome/chrome_service.test.ts | 53 +++++++++++++++++++ src/core/public/chrome/chrome_service.tsx | 8 +++ .../management_section/mount_section.tsx | 7 +++ 3 files changed, 68 insertions(+) diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 8dc81dceaccd6..0150554a60906 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -405,6 +405,59 @@ describe('start', () => { `); }); }); + + describe('erase chrome fields', () => { + it('while switching an app', async () => { + const startDeps = defaultStartDeps([new FakeApp('alpha')]); + const { navigateToApp } = startDeps.application; + const { chrome, service } = await start({ startDeps }); + + const helpExtensionPromise = chrome.getHelpExtension$().pipe(toArray()).toPromise(); + const breadcrumbsPromise = chrome.getBreadcrumbs$().pipe(toArray()).toPromise(); + const badgePromise = chrome.getBadge$().pipe(toArray()).toPromise(); + const docTitleResetSpy = jest.spyOn(chrome.docTitle, 'reset'); + + const promises = Promise.all([helpExtensionPromise, breadcrumbsPromise, badgePromise]); + + chrome.setHelpExtension({ appName: 'App name' }); + chrome.setBreadcrumbs([{ text: 'App breadcrumb' }]); + chrome.setBadge({ text: 'App badge', tooltip: 'App tooltip' }); + + navigateToApp('alpha'); + + service.stop(); + + expect(docTitleResetSpy).toBeCalledTimes(1); + await expect(promises).resolves.toMatchInlineSnapshot(` + Array [ + Array [ + undefined, + Object { + "appName": "App name", + }, + undefined, + ], + Array [ + Array [], + Array [ + Object { + "text": "App breadcrumb", + }, + ], + Array [], + ], + Array [ + undefined, + Object { + "text": "App badge", + "tooltip": "App tooltip", + }, + undefined, + ], + ] + `); + }); + }); }); describe('stop', () => { diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index d29120e6ee9ac..ef9a682d609ec 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -157,6 +157,14 @@ export class ChromeService { const recentlyAccessed = await this.recentlyAccessed.start({ http }); const docTitle = this.docTitle.start({ document: window.document }); + // erase chrome fields from a previous app while switching to a next app + application.currentAppId$.subscribe(() => { + helpExtension$.next(undefined); + breadcrumbs$.next([]); + badge$.next(undefined); + docTitle.reset(); + }); + const setIsNavDrawerLocked = (isLocked: boolean) => { isNavDrawerLocked$.next(isLocked); localStorage.setItem(IS_LOCKED_KEY, `${isLocked}`); diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index 9cfe99fd3bbf8..4339c2fa13c0f 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -21,6 +21,7 @@ import React, { lazy, Suspense } from 'react'; import ReactDOM from 'react-dom'; import { Router, Switch, Route } from 'react-router-dom'; import { I18nProvider } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { EuiLoadingSpinner } from '@elastic/eui'; import { CoreSetup } from 'src/core/public'; import { ManagementAppMountParams } from '../../../management/public'; @@ -36,6 +37,10 @@ interface MountParams { let allowedObjectTypes: string[] | undefined; +const title = i18n.translate('savedObjectsManagement.objects.savedObjectsTitle', { + defaultMessage: 'Saved Objects', +}); + const SavedObjectsEditionPage = lazy(() => import('./saved_objects_edition_page')); const SavedObjectsTablePage = lazy(() => import('./saved_objects_table_page')); export const mountManagementSection = async ({ @@ -49,6 +54,8 @@ export const mountManagementSection = async ({ allowedObjectTypes = await getAllowedTypes(coreStart.http); } + coreStart.chrome.docTitle.change(title); + const capabilities = coreStart.application.capabilities; const RedirectToHomeIfUnauthorized: React.FunctionComponent = ({ children }) => { From 789b67fb5f6e6293dba812bfef3f08333f4a798e Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Wed, 26 Aug 2020 10:59:44 +0200 Subject: [PATCH 17/56] [APM] Improvements for breakdown data gaps (#75534) Closes #69704, #73387, #43780. --- .../shared/charts/CustomPlot/StaticPlot.js | 43 +- .../__snapshots__/CustomPlot.test.js.snap | 372 +++--- .../ErroneousTransactionsRateChart/index.tsx | 2 +- .../plugins/apm/public/utils/testHelpers.tsx | 15 +- x-pack/plugins/apm/server/index.ts | 2 + .../plugins/apm/server/lib/helpers/metrics.ts | 10 +- .../java/gc/fetch_and_transform_gc_metrics.ts | 8 +- .../metrics/fetch_and_transform_metrics.ts | 8 +- .../lib/transaction_groups/get_error_rate.ts | 9 +- .../lib/transactions/breakdown/index.ts | 8 +- .../expectation/error_rate.json | 1010 ++++++++++++++++- 11 files changed, 1242 insertions(+), 245 deletions(-) diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js index d489970b55f29..e49899da85e0d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js @@ -71,11 +71,29 @@ class StaticPlot extends PureComponent { const data = serie.data.map((value) => { return 'y' in value && isValidCoordinateValue(value.y) ? value - : { - ...value, - y: undefined, - }; + : { ...value, y: undefined }; }); + + // make sure individual markers are displayed in cases + // where there are gaps + + const markersForGaps = serie.data.map((value, index) => { + const prevHasData = getNull(serie.data[index - 1] ?? {}); + const nextHasData = getNull(serie.data[index + 1] ?? {}); + const thisHasData = getNull(value); + + const isGap = !prevHasData && !nextHasData && thisHasData; + + if (!isGap) { + return { + ...value, + y: undefined, + }; + } + + return value; + }); + return [ , + , ]; } @@ -132,7 +165,7 @@ class StaticPlot extends PureComponent { curve={'curveMonotoneX'} data={serie.data} color={serie.color} - size={0.5} + size={1} /> ); default: diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap index 8101b01a83b08..f413610ebd984 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap @@ -460,7 +460,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -477,7 +477,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -494,7 +494,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -511,7 +511,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -528,7 +528,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -545,7 +545,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -562,7 +562,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -579,7 +579,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -596,7 +596,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -613,7 +613,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -630,7 +630,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -647,7 +647,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -664,7 +664,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -681,7 +681,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -698,7 +698,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -715,7 +715,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -732,7 +732,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -749,7 +749,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -766,7 +766,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -783,7 +783,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -800,7 +800,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -817,7 +817,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -834,7 +834,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -851,7 +851,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -868,7 +868,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -885,7 +885,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -902,7 +902,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -919,7 +919,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -936,7 +936,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -953,7 +953,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -970,7 +970,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -1013,7 +1013,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1030,7 +1030,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1047,7 +1047,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1064,7 +1064,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1081,7 +1081,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1098,7 +1098,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1115,7 +1115,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1132,7 +1132,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1149,7 +1149,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1166,7 +1166,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1183,7 +1183,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1200,7 +1200,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1217,7 +1217,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1234,7 +1234,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1251,7 +1251,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1268,7 +1268,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1285,7 +1285,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1302,7 +1302,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1319,7 +1319,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1336,7 +1336,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1353,7 +1353,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1370,7 +1370,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1387,7 +1387,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1404,7 +1404,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1421,7 +1421,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1438,7 +1438,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1455,7 +1455,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1472,7 +1472,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1489,7 +1489,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1506,7 +1506,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1523,7 +1523,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -1566,7 +1566,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1583,7 +1583,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1600,7 +1600,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1617,7 +1617,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1634,7 +1634,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1651,7 +1651,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1668,7 +1668,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1685,7 +1685,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1702,7 +1702,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1719,7 +1719,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1736,7 +1736,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1753,7 +1753,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1770,7 +1770,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1787,7 +1787,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1804,7 +1804,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1821,7 +1821,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1838,7 +1838,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1855,7 +1855,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1872,7 +1872,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1889,7 +1889,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1906,7 +1906,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1923,7 +1923,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1940,7 +1940,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1957,7 +1957,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1974,7 +1974,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -1991,7 +1991,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -2008,7 +2008,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -2025,7 +2025,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -2042,7 +2042,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -2059,7 +2059,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -2076,7 +2076,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -3396,7 +3396,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3413,7 +3413,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3430,7 +3430,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3447,7 +3447,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3464,7 +3464,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3481,7 +3481,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3498,7 +3498,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3515,7 +3515,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3532,7 +3532,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3549,7 +3549,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3566,7 +3566,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3583,7 +3583,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3600,7 +3600,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3617,7 +3617,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3634,7 +3634,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3651,7 +3651,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3668,7 +3668,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3685,7 +3685,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3702,7 +3702,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3719,7 +3719,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3736,7 +3736,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3753,7 +3753,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3770,7 +3770,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3787,7 +3787,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3804,7 +3804,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3821,7 +3821,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3838,7 +3838,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3855,7 +3855,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3872,7 +3872,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3889,7 +3889,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3906,7 +3906,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#da8b45", @@ -3949,7 +3949,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -3966,7 +3966,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -3983,7 +3983,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4000,7 +4000,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4017,7 +4017,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4034,7 +4034,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4051,7 +4051,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4068,7 +4068,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4085,7 +4085,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4102,7 +4102,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4119,7 +4119,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4136,7 +4136,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4153,7 +4153,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4170,7 +4170,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4187,7 +4187,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4204,7 +4204,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4221,7 +4221,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4238,7 +4238,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4255,7 +4255,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4272,7 +4272,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4289,7 +4289,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4306,7 +4306,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4323,7 +4323,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4340,7 +4340,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4357,7 +4357,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4374,7 +4374,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4391,7 +4391,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4408,7 +4408,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4425,7 +4425,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4442,7 +4442,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4459,7 +4459,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#d6bf57", @@ -4502,7 +4502,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4519,7 +4519,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4536,7 +4536,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4553,7 +4553,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4570,7 +4570,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4587,7 +4587,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4604,7 +4604,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4621,7 +4621,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4638,7 +4638,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4655,7 +4655,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4672,7 +4672,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4689,7 +4689,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4706,7 +4706,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4723,7 +4723,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4740,7 +4740,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4757,7 +4757,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4774,7 +4774,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4791,7 +4791,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4808,7 +4808,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4825,7 +4825,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4842,7 +4842,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4859,7 +4859,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4876,7 +4876,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4893,7 +4893,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4910,7 +4910,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4927,7 +4927,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4944,7 +4944,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4961,7 +4961,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4978,7 +4978,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -4995,7 +4995,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", @@ -5012,7 +5012,7 @@ Array [ onContextMenu={[Function]} onMouseOut={[Function]} onMouseOver={[Function]} - r={0.5} + r={1} style={ Object { "fill": "#6092c0", diff --git a/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx index 8214c081e6ce1..3b6d1684e08e1 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx @@ -88,7 +88,7 @@ export function ErroneousTransactionsRateChart() { }, { data: errorRates, - type: 'line', + type: 'linemark', color: theme.euiColorVis7, hideLegend: true, title: i18n.translate('xpack.apm.errorRateChart.rateLabel', { diff --git a/x-pack/plugins/apm/public/utils/testHelpers.tsx b/x-pack/plugins/apm/public/utils/testHelpers.tsx index 217e6a30a33b4..a750a9ea7af67 100644 --- a/x-pack/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/plugins/apm/public/utils/testHelpers.tsx @@ -151,7 +151,20 @@ export async function inspectSearchParams( end: 1528977600000, apmEventClient: { search: spy } as any, internalClient: { search: spy } as any, - config: new Proxy({}, { get: () => 'myIndex' }) as APMConfig, + config: new Proxy( + {}, + { + get: (_, key) => { + switch (key) { + default: + return 'myIndex'; + + case 'xpack.apm.metricsInterval': + return 30; + } + }, + } + ) as APMConfig, uiFiltersES: [{ term: { 'my.custom.ui.filter': 'foo-bar' } }], indices: { /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index fa4b8b821f9f8..29b2a77df348e 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -31,6 +31,7 @@ export const config = { maxTraceItems: schema.number({ defaultValue: 1000 }), }), telemetryCollectionEnabled: schema.boolean({ defaultValue: true }), + metricsInterval: schema.number({ defaultValue: 30 }), }), }; @@ -68,6 +69,7 @@ export function mergeConfigs( 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern, 'xpack.apm.telemetryCollectionEnabled': apmConfig.telemetryCollectionEnabled, + 'xpack.apm.metricsInterval': apmConfig.metricsInterval, }; } diff --git a/x-pack/plugins/apm/server/lib/helpers/metrics.ts b/x-pack/plugins/apm/server/lib/helpers/metrics.ts index c57769e9e15da..9f5b5cdf47552 100644 --- a/x-pack/plugins/apm/server/lib/helpers/metrics.ts +++ b/x-pack/plugins/apm/server/lib/helpers/metrics.ts @@ -6,13 +6,17 @@ import { getBucketSize } from './get_bucket_size'; -export function getMetricsDateHistogramParams(start: number, end: number) { +export function getMetricsDateHistogramParams( + start: number, + end: number, + metricsInterval: number +) { const { bucketSize } = getBucketSize(start, end, 'auto'); return { field: '@timestamp', - // ensure minimum bucket size of 30s since this is the default resolution for metric data - fixed_interval: `${Math.max(bucketSize, 30)}s`, + // ensure minimum bucket size of configured interval since this is the default resolution for metric data + fixed_interval: `${Math.max(bucketSize, metricsInterval)}s`, min_doc_count: 0, extended_bounds: { min: start, max: end }, diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts index e5c573ba1ec02..551384da2cca7 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts @@ -42,7 +42,7 @@ export async function fetchAndTransformGcMetrics({ chartBase: ChartBase; fieldName: typeof METRIC_JAVA_GC_COUNT | typeof METRIC_JAVA_GC_TIME; }) { - const { start, end, apmEventClient } = setup; + const { start, end, apmEventClient, config } = setup; const { bucketSize } = getBucketSize(start, end, 'auto'); @@ -75,7 +75,11 @@ export async function fetchAndTransformGcMetrics({ }, aggs: { over_time: { - date_histogram: getMetricsDateHistogramParams(start, end), + date_histogram: getMetricsDateHistogramParams( + start, + end, + config['xpack.apm.metricsInterval'] + ), aggs: { // get the max value max: { diff --git a/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts b/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts index f6e201b395c37..a42a10d6518a0 100644 --- a/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts +++ b/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts @@ -65,7 +65,7 @@ export async function fetchAndTransformMetrics({ aggs: T; additionalFilters?: Filter[]; }) { - const { start, end, apmEventClient } = setup; + const { start, end, apmEventClient, config } = setup; const projection = getMetricsProjection({ setup, @@ -83,7 +83,11 @@ export async function fetchAndTransformMetrics({ }, aggs: { timeseriesData: { - date_histogram: getMetricsDateHistogramParams(start, end), + date_histogram: getMetricsDateHistogramParams( + start, + end, + config['xpack.apm.metricsInterval'] + ), aggs, }, ...aggs, diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts index d4e0bd1d54da1..ec2d8144cf3ff 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -12,12 +12,12 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../common/processor_event'; import { rangeFilter } from '../../../common/utils/range_filter'; -import { getMetricsDateHistogramParams } from '../helpers/metrics'; import { Setup, SetupTimeRange, SetupUIFilters, } from '../helpers/setup_request'; +import { getBucketSize } from '../helpers/get_bucket_size'; export async function getErrorRate({ serviceName, @@ -57,7 +57,12 @@ export async function getErrorRate({ query: { bool: { filter } }, aggs: { total_transactions: { - date_histogram: getMetricsDateHistogramParams(start, end), + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize(start, end, 'auto').intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, aggs: { erroneous_transactions: { filter: { range: { [HTTP_RESPONSE_STATUS_CODE]: { gte: 400 } } }, diff --git a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts index 7248399d1f93f..fbdddea32deb4 100644 --- a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts @@ -36,7 +36,7 @@ export async function getTransactionBreakdown({ transactionName?: string; transactionType: string; }) { - const { uiFiltersES, apmEventClient, start, end } = setup; + const { uiFiltersES, apmEventClient, start, end, config } = setup; const subAggs = { sum_all_self_times: { @@ -104,7 +104,11 @@ export async function getTransactionBreakdown({ aggs: { ...subAggs, by_date: { - date_histogram: getMetricsDateHistogramParams(start, end), + date_histogram: getMetricsDateHistogramParams( + start, + end, + config['xpack.apm.metricsInterval'] + ), aggs: subAggs, }, }, diff --git a/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/error_rate.json b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/error_rate.json index 9ff45ebdbb21b..e448729f44a98 100644 --- a/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/error_rate.json +++ b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/error_rate.json @@ -1,42 +1,970 @@ { - "noHits":false, - "erroneousTransactionsRate":[ - { - "x":1593413100000, - "y":null - }, - { - "x":1593413130000, - "y":null - }, - { - "x":1593413160000, - "y":null - }, - { - "x":1593413190000, - "y":null - }, - { - "x":1593413220000, - "y":null - }, - { - "x":1593413250000, - "y":0 - }, - { - "x":1593413280000, - "y":0.14102564102564102 - }, - { - "x":1593413310000, - "y":0.14634146341463414 - }, - { - "x":1593413340000, - "y":null - } - ], - "average":0.09578903481342504 -} \ No newline at end of file + "noHits": false, + "erroneousTransactionsRate": [ + { + "x": 1593413100000, + "y": null + }, + { + "x": 1593413101000, + "y": null + }, + { + "x": 1593413102000, + "y": null + }, + { + "x": 1593413103000, + "y": null + }, + { + "x": 1593413104000, + "y": null + }, + { + "x": 1593413105000, + "y": null + }, + { + "x": 1593413106000, + "y": null + }, + { + "x": 1593413107000, + "y": null + }, + { + "x": 1593413108000, + "y": null + }, + { + "x": 1593413109000, + "y": null + }, + { + "x": 1593413110000, + "y": null + }, + { + "x": 1593413111000, + "y": null + }, + { + "x": 1593413112000, + "y": null + }, + { + "x": 1593413113000, + "y": null + }, + { + "x": 1593413114000, + "y": null + }, + { + "x": 1593413115000, + "y": null + }, + { + "x": 1593413116000, + "y": null + }, + { + "x": 1593413117000, + "y": null + }, + { + "x": 1593413118000, + "y": null + }, + { + "x": 1593413119000, + "y": null + }, + { + "x": 1593413120000, + "y": null + }, + { + "x": 1593413121000, + "y": null + }, + { + "x": 1593413122000, + "y": null + }, + { + "x": 1593413123000, + "y": null + }, + { + "x": 1593413124000, + "y": null + }, + { + "x": 1593413125000, + "y": null + }, + { + "x": 1593413126000, + "y": null + }, + { + "x": 1593413127000, + "y": null + }, + { + "x": 1593413128000, + "y": null + }, + { + "x": 1593413129000, + "y": null + }, + { + "x": 1593413130000, + "y": null + }, + { + "x": 1593413131000, + "y": null + }, + { + "x": 1593413132000, + "y": null + }, + { + "x": 1593413133000, + "y": null + }, + { + "x": 1593413134000, + "y": null + }, + { + "x": 1593413135000, + "y": null + }, + { + "x": 1593413136000, + "y": null + }, + { + "x": 1593413137000, + "y": null + }, + { + "x": 1593413138000, + "y": null + }, + { + "x": 1593413139000, + "y": null + }, + { + "x": 1593413140000, + "y": null + }, + { + "x": 1593413141000, + "y": null + }, + { + "x": 1593413142000, + "y": null + }, + { + "x": 1593413143000, + "y": null + }, + { + "x": 1593413144000, + "y": null + }, + { + "x": 1593413145000, + "y": null + }, + { + "x": 1593413146000, + "y": null + }, + { + "x": 1593413147000, + "y": null + }, + { + "x": 1593413148000, + "y": null + }, + { + "x": 1593413149000, + "y": null + }, + { + "x": 1593413150000, + "y": null + }, + { + "x": 1593413151000, + "y": null + }, + { + "x": 1593413152000, + "y": null + }, + { + "x": 1593413153000, + "y": null + }, + { + "x": 1593413154000, + "y": null + }, + { + "x": 1593413155000, + "y": null + }, + { + "x": 1593413156000, + "y": null + }, + { + "x": 1593413157000, + "y": null + }, + { + "x": 1593413158000, + "y": null + }, + { + "x": 1593413159000, + "y": null + }, + { + "x": 1593413160000, + "y": null + }, + { + "x": 1593413161000, + "y": null + }, + { + "x": 1593413162000, + "y": null + }, + { + "x": 1593413163000, + "y": null + }, + { + "x": 1593413164000, + "y": null + }, + { + "x": 1593413165000, + "y": null + }, + { + "x": 1593413166000, + "y": null + }, + { + "x": 1593413167000, + "y": null + }, + { + "x": 1593413168000, + "y": null + }, + { + "x": 1593413169000, + "y": null + }, + { + "x": 1593413170000, + "y": null + }, + { + "x": 1593413171000, + "y": null + }, + { + "x": 1593413172000, + "y": null + }, + { + "x": 1593413173000, + "y": null + }, + { + "x": 1593413174000, + "y": null + }, + { + "x": 1593413175000, + "y": null + }, + { + "x": 1593413176000, + "y": null + }, + { + "x": 1593413177000, + "y": null + }, + { + "x": 1593413178000, + "y": null + }, + { + "x": 1593413179000, + "y": null + }, + { + "x": 1593413180000, + "y": null + }, + { + "x": 1593413181000, + "y": null + }, + { + "x": 1593413182000, + "y": null + }, + { + "x": 1593413183000, + "y": null + }, + { + "x": 1593413184000, + "y": null + }, + { + "x": 1593413185000, + "y": null + }, + { + "x": 1593413186000, + "y": null + }, + { + "x": 1593413187000, + "y": null + }, + { + "x": 1593413188000, + "y": null + }, + { + "x": 1593413189000, + "y": null + }, + { + "x": 1593413190000, + "y": null + }, + { + "x": 1593413191000, + "y": null + }, + { + "x": 1593413192000, + "y": null + }, + { + "x": 1593413193000, + "y": null + }, + { + "x": 1593413194000, + "y": null + }, + { + "x": 1593413195000, + "y": null + }, + { + "x": 1593413196000, + "y": null + }, + { + "x": 1593413197000, + "y": null + }, + { + "x": 1593413198000, + "y": null + }, + { + "x": 1593413199000, + "y": null + }, + { + "x": 1593413200000, + "y": null + }, + { + "x": 1593413201000, + "y": null + }, + { + "x": 1593413202000, + "y": null + }, + { + "x": 1593413203000, + "y": null + }, + { + "x": 1593413204000, + "y": null + }, + { + "x": 1593413205000, + "y": null + }, + { + "x": 1593413206000, + "y": null + }, + { + "x": 1593413207000, + "y": null + }, + { + "x": 1593413208000, + "y": null + }, + { + "x": 1593413209000, + "y": null + }, + { + "x": 1593413210000, + "y": null + }, + { + "x": 1593413211000, + "y": null + }, + { + "x": 1593413212000, + "y": null + }, + { + "x": 1593413213000, + "y": null + }, + { + "x": 1593413214000, + "y": null + }, + { + "x": 1593413215000, + "y": null + }, + { + "x": 1593413216000, + "y": null + }, + { + "x": 1593413217000, + "y": null + }, + { + "x": 1593413218000, + "y": null + }, + { + "x": 1593413219000, + "y": null + }, + { + "x": 1593413220000, + "y": null + }, + { + "x": 1593413221000, + "y": null + }, + { + "x": 1593413222000, + "y": null + }, + { + "x": 1593413223000, + "y": null + }, + { + "x": 1593413224000, + "y": null + }, + { + "x": 1593413225000, + "y": null + }, + { + "x": 1593413226000, + "y": null + }, + { + "x": 1593413227000, + "y": null + }, + { + "x": 1593413228000, + "y": null + }, + { + "x": 1593413229000, + "y": null + }, + { + "x": 1593413230000, + "y": null + }, + { + "x": 1593413231000, + "y": null + }, + { + "x": 1593413232000, + "y": null + }, + { + "x": 1593413233000, + "y": null + }, + { + "x": 1593413234000, + "y": null + }, + { + "x": 1593413235000, + "y": null + }, + { + "x": 1593413236000, + "y": null + }, + { + "x": 1593413237000, + "y": null + }, + { + "x": 1593413238000, + "y": null + }, + { + "x": 1593413239000, + "y": null + }, + { + "x": 1593413240000, + "y": null + }, + { + "x": 1593413241000, + "y": null + }, + { + "x": 1593413242000, + "y": null + }, + { + "x": 1593413243000, + "y": null + }, + { + "x": 1593413244000, + "y": null + }, + { + "x": 1593413245000, + "y": null + }, + { + "x": 1593413246000, + "y": null + }, + { + "x": 1593413247000, + "y": null + }, + { + "x": 1593413248000, + "y": null + }, + { + "x": 1593413249000, + "y": null + }, + { + "x": 1593413250000, + "y": null + }, + { + "x": 1593413251000, + "y": null + }, + { + "x": 1593413252000, + "y": null + }, + { + "x": 1593413253000, + "y": null + }, + { + "x": 1593413254000, + "y": null + }, + { + "x": 1593413255000, + "y": null + }, + { + "x": 1593413256000, + "y": null + }, + { + "x": 1593413257000, + "y": null + }, + { + "x": 1593413258000, + "y": null + }, + { + "x": 1593413259000, + "y": null + }, + { + "x": 1593413260000, + "y": null + }, + { + "x": 1593413261000, + "y": null + }, + { + "x": 1593413262000, + "y": null + }, + { + "x": 1593413263000, + "y": null + }, + { + "x": 1593413264000, + "y": null + }, + { + "x": 1593413265000, + "y": null + }, + { + "x": 1593413266000, + "y": null + }, + { + "x": 1593413267000, + "y": null + }, + { + "x": 1593413268000, + "y": null + }, + { + "x": 1593413269000, + "y": null + }, + { + "x": 1593413270000, + "y": null + }, + { + "x": 1593413271000, + "y": null + }, + { + "x": 1593413272000, + "y": 0 + }, + { + "x": 1593413273000, + "y": 0 + }, + { + "x": 1593413274000, + "y": null + }, + { + "x": 1593413275000, + "y": null + }, + { + "x": 1593413276000, + "y": null + }, + { + "x": 1593413277000, + "y": 0 + }, + { + "x": 1593413278000, + "y": null + }, + { + "x": 1593413279000, + "y": null + }, + { + "x": 1593413280000, + "y": null + }, + { + "x": 1593413281000, + "y": 0 + }, + { + "x": 1593413282000, + "y": null + }, + { + "x": 1593413283000, + "y": null + }, + { + "x": 1593413284000, + "y": 0 + }, + { + "x": 1593413285000, + "y": 0 + }, + { + "x": 1593413286000, + "y": 0.125 + }, + { + "x": 1593413287000, + "y": 0.5 + }, + { + "x": 1593413288000, + "y": 0 + }, + { + "x": 1593413289000, + "y": 0.5 + }, + { + "x": 1593413290000, + "y": 0 + }, + { + "x": 1593413291000, + "y": 0 + }, + { + "x": 1593413292000, + "y": 0.5 + }, + { + "x": 1593413293000, + "y": 0 + }, + { + "x": 1593413294000, + "y": 0 + }, + { + "x": 1593413295000, + "y": 0 + }, + { + "x": 1593413296000, + "y": 0 + }, + { + "x": 1593413297000, + "y": 0 + }, + { + "x": 1593413298000, + "y": 0 + }, + { + "x": 1593413299000, + "y": 0.5 + }, + { + "x": 1593413300000, + "y": 0.3333333333333333 + }, + { + "x": 1593413301000, + "y": 0.14285714285714285 + }, + { + "x": 1593413302000, + "y": 0 + }, + { + "x": 1593413303000, + "y": 0 + }, + { + "x": 1593413304000, + "y": 0 + }, + { + "x": 1593413305000, + "y": 0.6666666666666666 + }, + { + "x": 1593413306000, + "y": 0 + }, + { + "x": 1593413307000, + "y": 0 + }, + { + "x": 1593413308000, + "y": 0.3333333333333333 + }, + { + "x": 1593413309000, + "y": 0.3333333333333333 + }, + { + "x": 1593413310000, + "y": 0.3333333333333333 + }, + { + "x": 1593413311000, + "y": 0.5 + }, + { + "x": 1593413312000, + "y": 0 + }, + { + "x": 1593413313000, + "y": 0 + }, + { + "x": 1593413314000, + "y": 0 + }, + { + "x": 1593413315000, + "y": 0.5 + }, + { + "x": 1593413316000, + "y": 0 + }, + { + "x": 1593413317000, + "y": 0 + }, + { + "x": 1593413318000, + "y": 0 + }, + { + "x": 1593413319000, + "y": 0 + }, + { + "x": 1593413320000, + "y": 0.3333333333333333 + }, + { + "x": 1593413321000, + "y": 0 + }, + { + "x": 1593413322000, + "y": 0.5 + }, + { + "x": 1593413323000, + "y": null + }, + { + "x": 1593413324000, + "y": null + }, + { + "x": 1593413325000, + "y": null + }, + { + "x": 1593413326000, + "y": null + }, + { + "x": 1593413327000, + "y": null + }, + { + "x": 1593413328000, + "y": null + }, + { + "x": 1593413329000, + "y": null + }, + { + "x": 1593413330000, + "y": null + }, + { + "x": 1593413331000, + "y": null + }, + { + "x": 1593413332000, + "y": null + }, + { + "x": 1593413333000, + "y": null + }, + { + "x": 1593413334000, + "y": null + }, + { + "x": 1593413335000, + "y": null + }, + { + "x": 1593413336000, + "y": null + }, + { + "x": 1593413337000, + "y": null + }, + { + "x": 1593413338000, + "y": null + }, + { + "x": 1593413339000, + "y": null + }, + { + "x": 1593413340000, + "y": null + } + ], + "average": 0.14188815060908083 +} From 86d7050822865eb96b2fe8faf9997a71b71aaedd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Wed, 26 Aug 2020 12:51:22 +0100 Subject: [PATCH 18/56] [Telemetry] Add Application Usage Schema (#75283) Co-authored-by: Elastic Machine --- .telemetryrc.json | 1 - .../src/tools/__fixture__/mock_schema.json | 16 + ...exed_interface_with_not_matching_schema.ts | 54 + .../__fixture__/parsed_working_collector.ts | 22 + .../extract_collectors.test.ts.snap | 54 + .../tools/check_collector__integrity.test.ts | 15 + .../src/tools/extract_collectors.test.ts | 2 +- .../src/tools/serializer.ts | 5 + .../kbn-telemetry-tools/src/tools/utils.ts | 37 +- ...exed_interface_with_not_matching_schema.ts | 48 + .../telemetry_collectors/working_collector.ts | 9 + .../collectors/application_usage/schema.ts | 99 ++ .../telemetry_application_usage_collector.ts | 168 +-- src/plugins/telemetry/schema/oss_plugins.json | 1208 +++++++++++++++++ 14 files changed, 1650 insertions(+), 88 deletions(-) create mode 100644 packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_indexed_interface_with_not_matching_schema.ts create mode 100644 src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts diff --git a/.telemetryrc.json b/.telemetryrc.json index 2f57566159a70..818f9805628e1 100644 --- a/.telemetryrc.json +++ b/.telemetryrc.json @@ -7,7 +7,6 @@ "src/plugins/testbed/", "src/plugins/kibana_utils/", "src/plugins/kibana_usage_collection/server/collectors/kibana/kibana_usage_collector.ts", - "src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts", "src/plugins/kibana_usage_collection/server/collectors/management/telemetry_management_collector.ts", "src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts", "src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts" diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json b/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json index e87699825b4e1..2e69d3625d7ff 100644 --- a/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/mock_schema.json @@ -5,6 +5,22 @@ "flat": { "type": "keyword" }, + "my_index_signature_prop": { + "properties": { + "avg": { + "type": "number" + }, + "count": { + "type": "number" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + } + } + }, "my_str": { "type": "text" }, diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_indexed_interface_with_not_matching_schema.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_indexed_interface_with_not_matching_schema.ts new file mode 100644 index 0000000000000..83866a2b6afec --- /dev/null +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_indexed_interface_with_not_matching_schema.ts @@ -0,0 +1,54 @@ +/* + * 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 { SyntaxKind } from 'typescript'; +import { ParsedUsageCollection } from '../ts_parser'; + +export const parsedIndexedInterfaceWithNoMatchingSchema: ParsedUsageCollection = [ + 'src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts', + { + collectorName: 'indexed_interface_with_not_matching_schema', + schema: { + value: { + something: { + count_1: { + type: 'number', + }, + }, + }, + }, + fetch: { + typeName: 'Usage', + typeDescriptor: { + '': { + '@@INDEX@@': { + count_1: { + kind: SyntaxKind.NumberKeyword, + type: 'NumberKeyword', + }, + count_2: { + kind: SyntaxKind.NumberKeyword, + type: 'NumberKeyword', + }, + }, + }, + }, + }, + }, +]; diff --git a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts index 803bc7f13f59e..b238c5aa346ad 100644 --- a/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts +++ b/packages/kbn-telemetry-tools/src/tools/__fixture__/parsed_working_collector.ts @@ -32,6 +32,20 @@ export const parsedWorkingCollector: ParsedUsageCollection = [ my_str: { type: 'text', }, + my_index_signature_prop: { + avg: { + type: 'number', + }, + count: { + type: 'number', + }, + max: { + type: 'number', + }, + min: { + type: 'number', + }, + }, my_objects: { total: { type: 'number', @@ -60,6 +74,14 @@ export const parsedWorkingCollector: ParsedUsageCollection = [ kind: SyntaxKind.StringKeyword, type: 'StringKeyword', }, + my_index_signature_prop: { + '': { + '@@INDEX@@': { + kind: SyntaxKind.NumberKeyword, + type: 'NumberKeyword', + }, + }, + }, my_objects: { total: { kind: SyntaxKind.NumberKeyword, diff --git a/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap b/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap index fc933b6c7fd35..bf1d5ffc1101e 100644 --- a/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap +++ b/packages/kbn-telemetry-tools/src/tools/__snapshots__/extract_collectors.test.ts.snap @@ -90,6 +90,38 @@ Array [ }, }, ], + Array [ + "src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts", + Object { + "collectorName": "indexed_interface_with_not_matching_schema", + "fetch": Object { + "typeDescriptor": Object { + "": Object { + "@@INDEX@@": Object { + "count_1": Object { + "kind": 140, + "type": "NumberKeyword", + }, + "count_2": Object { + "kind": 140, + "type": "NumberKeyword", + }, + }, + }, + }, + "typeName": "Usage", + }, + "schema": Object { + "value": Object { + "something": Object { + "count_1": Object { + "type": "long", + }, + }, + }, + }, + }, + ], Array [ "src/fixtures/telemetry_collectors/nested_collector.ts", Object { @@ -132,6 +164,14 @@ Array [ "type": "BooleanKeyword", }, }, + "my_index_signature_prop": Object { + "": Object { + "@@INDEX@@": Object { + "kind": 140, + "type": "NumberKeyword", + }, + }, + }, "my_objects": Object { "total": Object { "kind": 140, @@ -166,6 +206,20 @@ Array [ "type": "boolean", }, }, + "my_index_signature_prop": Object { + "avg": Object { + "type": "number", + }, + "count": Object { + "type": "number", + }, + "max": Object { + "type": "number", + }, + "min": Object { + "type": "number", + }, + }, "my_objects": Object { "total": Object { "type": "number", diff --git a/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts b/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts index dbdda3f38afd5..a101210185a63 100644 --- a/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts +++ b/packages/kbn-telemetry-tools/src/tools/check_collector__integrity.test.ts @@ -20,6 +20,7 @@ import { cloneDeep } from 'lodash'; import * as ts from 'typescript'; import { parsedWorkingCollector } from './__fixture__/parsed_working_collector'; +import { parsedIndexedInterfaceWithNoMatchingSchema } from './__fixture__/parsed_indexed_interface_with_not_matching_schema'; import { checkCompatibleTypeDescriptor, checkMatchingMapping } from './check_collector_integrity'; import * as path from 'path'; import { readFile } from 'fs'; @@ -82,6 +83,20 @@ describe('checkCompatibleTypeDescriptor', () => { expect(incompatibles).toHaveLength(0); }); + it('returns diff on indexed interface with no matching schema', () => { + const incompatibles = checkCompatibleTypeDescriptor([ + parsedIndexedInterfaceWithNoMatchingSchema, + ]); + expect(incompatibles).toHaveLength(1); + const { diff, message } = incompatibles[0]; + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(diff).toEqual({ '.@@INDEX@@.count_2.kind': 'number' }); + expect(message).toHaveLength(1); + expect(message).toEqual([ + 'incompatible Type key (Usage..@@INDEX@@.count_2): expected (undefined) got ("number").', + ]); + }); + describe('Interface Change', () => { it('returns diff on incompatible type descriptor with mapping', () => { const malformedParsedCollector = cloneDeep(parsedWorkingCollector); diff --git a/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts b/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts index 1b4ed21a1635c..0517cb9034d0a 100644 --- a/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts +++ b/packages/kbn-telemetry-tools/src/tools/extract_collectors.test.ts @@ -34,7 +34,7 @@ describe('extractCollectors', () => { const programPaths = await getProgramPaths(configs[0]); const results = [...extractCollectors(programPaths, tsConfig)]; - expect(results).toHaveLength(6); + expect(results).toHaveLength(7); expect(results).toMatchSnapshot(); }); }); diff --git a/packages/kbn-telemetry-tools/src/tools/serializer.ts b/packages/kbn-telemetry-tools/src/tools/serializer.ts index bce5dd7f58643..f945402ec5fc2 100644 --- a/packages/kbn-telemetry-tools/src/tools/serializer.ts +++ b/packages/kbn-telemetry-tools/src/tools/serializer.ts @@ -84,6 +84,11 @@ export function getDescriptor(node: ts.Node, program: ts.Program): Descriptor | }, {} as any); } + // If it's defined as signature { [key: string]: OtherInterface } + if (ts.isIndexSignatureDeclaration(node) && node.type) { + return { '@@INDEX@@': getDescriptor(node.type, program) }; + } + if (ts.SyntaxKind.FirstNode === node.kind) { return getDescriptor((node as any).right, program); } diff --git a/packages/kbn-telemetry-tools/src/tools/utils.ts b/packages/kbn-telemetry-tools/src/tools/utils.ts index 212b06a4c9895..c1424785b22a5 100644 --- a/packages/kbn-telemetry-tools/src/tools/utils.ts +++ b/packages/kbn-telemetry-tools/src/tools/utils.ts @@ -98,6 +98,14 @@ export function getVariableValue(node: ts.Node): string | Record { return serializeObject(node); } + if (ts.isIdentifier(node)) { + const declaration = getIdentifierDeclaration(node); + if (ts.isVariableDeclaration(declaration) && declaration.initializer) { + return getVariableValue(declaration.initializer); + } + // TODO: If this is another imported value from another file, we'll need to go fetch it like in getPropertyValue + } + throw Error(`Unsuppored Node: cannot get value of node (${node.getText()}) of kind ${node.kind}`); } @@ -112,10 +120,11 @@ export function serializeObject(node: ts.Node) { if (typeof propertyName === 'undefined') { throw new Error(`Unable to get property name ${property.getText()}`); } + const cleanPropertyName = propertyName.replace(/["']/g, ''); if (ts.isPropertyAssignment(property)) { - value[propertyName] = getVariableValue(property.initializer); + value[cleanPropertyName] = getVariableValue(property.initializer); } else { - value[propertyName] = getVariableValue(property); + value[cleanPropertyName] = getVariableValue(property); } } @@ -222,9 +231,29 @@ export const flattenKeys = (obj: any, keyPath: any[] = []): any => { }; export function difference(actual: any, expected: any) { - function changes(obj: any, base: any) { + function changes(obj: { [key: string]: any }, base: { [key: string]: any }) { return transform(obj, function (result, value, key) { - if (key && !isEqual(value, base[key])) { + if (key && /@@INDEX@@/.test(`${key}`)) { + // The type definition is an Index Signature, fuzzy searching for similar keys + const regexp = new RegExp(`${key}`.replace(/@@INDEX@@/g, '(.+)?')); + const keysInBase = Object.keys(base) + .map((k) => { + const match = k.match(regexp); + return match && match[0]; + }) + .filter((s): s is string => !!s); + + if (keysInBase.length === 0) { + // Mark this key as wrong because we couldn't find any matching keys + result[key] = value; + } + + keysInBase.forEach((k) => { + if (!isEqual(value, base[k])) { + result[k] = isObject(value) && isObject(base[k]) ? changes(value, base[k]) : value; + } + }); + } else if (key && !isEqual(value, base[key])) { result[key] = isObject(value) && isObject(base[key]) ? changes(value, base[key]) : value; } }); diff --git a/src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts b/src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts new file mode 100644 index 0000000000000..0ec8d2e15c34a --- /dev/null +++ b/src/fixtures/telemetry_collectors/indexed_interface_with_not_matching_schema.ts @@ -0,0 +1,48 @@ +/* + * 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 { CollectorSet } from '../../plugins/usage_collection/server/collector'; +import { loggerMock } from '../../core/server/logging/logger.mock'; + +const { makeUsageCollector } = new CollectorSet({ + logger: loggerMock.create(), + maximumWaitTimeForAllCollectorsInS: 0, +}); + +interface Usage { + [key: string]: { + count_1?: number; + count_2?: number; + }; +} + +export const myCollector = makeUsageCollector({ + type: 'indexed_interface_with_not_matching_schema', + isReady: () => true, + fetch() { + if (Math.random()) { + return { something: { count_1: 1 } }; + } + return { something: { count_2: 2 } }; + }, + schema: { + something: { + count_1: { type: 'long' }, // Intentionally missing count_2 + }, + }, +}); diff --git a/src/fixtures/telemetry_collectors/working_collector.ts b/src/fixtures/telemetry_collectors/working_collector.ts index d58a89db97d74..bdf10b5e54919 100644 --- a/src/fixtures/telemetry_collectors/working_collector.ts +++ b/src/fixtures/telemetry_collectors/working_collector.ts @@ -35,6 +35,9 @@ interface Usage { my_objects: MyObject; my_array?: MyObject[]; my_str_array?: string[]; + my_index_signature_prop?: { + [key: string]: number; + }; } const SOME_NUMBER: number = 123; @@ -93,5 +96,11 @@ export const myCollector = makeUsageCollector({ type: { type: 'boolean' }, }, my_str_array: { type: 'keyword' }, + my_index_signature_prop: { + count: { type: 'number' }, + avg: { type: 'number' }, + max: { type: 'number' }, + min: { type: 'number' }, + }, }, }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts new file mode 100644 index 0000000000000..6efe872553583 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -0,0 +1,99 @@ +/* + * 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 { MakeSchemaFrom } from 'src/plugins/usage_collection/server'; +import { ApplicationUsageTelemetryReport } from './telemetry_application_usage_collector'; + +const commonSchema: MakeSchemaFrom = { + clicks_total: { + type: 'long', + }, + clicks_7_days: { + type: 'long', + }, + clicks_30_days: { + type: 'long', + }, + clicks_90_days: { + type: 'long', + }, + minutes_on_screen_total: { + type: 'float', + }, + minutes_on_screen_7_days: { + type: 'float', + }, + minutes_on_screen_30_days: { + type: 'float', + }, + minutes_on_screen_90_days: { + type: 'float', + }, +}; + +// These keys obtained by searching for `/application\w*\.register\(/` and checking the value of the attr `id`. +// TODO: Find a way to update these keys automatically. +export const applicationUsageSchema = { + // OSS + dashboards: commonSchema, + dev_tools: commonSchema, + discover: commonSchema, + home: commonSchema, + kibana: commonSchema, // It's a forward app so we'll likely never report it + management: commonSchema, + short_url_redirect: commonSchema, // It's a forward app so we'll likely never report it + timelion: commonSchema, + visualize: commonSchema, + + // X-Pack + apm: commonSchema, + csm: commonSchema, + canvas: commonSchema, + dashboard_mode: commonSchema, // It's a forward app so we'll likely never report it + appSearch: commonSchema, + workplaceSearch: commonSchema, + graph: commonSchema, + logs: commonSchema, + metrics: commonSchema, + infra: commonSchema, // It's a forward app so we'll likely never report it + ingestManager: commonSchema, + lens: commonSchema, + maps: commonSchema, + ml: commonSchema, + monitoring: commonSchema, + 'observability-overview': commonSchema, + security_account: commonSchema, + security_access_agreement: commonSchema, + security_capture_url: commonSchema, // It's a forward app so we'll likely never report it + security_logged_out: commonSchema, + security_login: commonSchema, + security_logout: commonSchema, + security_overwritten_session: commonSchema, + securitySolution: commonSchema, // It's a forward app so we'll likely never report it + 'securitySolution:overview': commonSchema, + 'securitySolution:detections': commonSchema, + 'securitySolution:hosts': commonSchema, + 'securitySolution:network': commonSchema, + 'securitySolution:timelines': commonSchema, + 'securitySolution:case': commonSchema, + 'securitySolution:administration': commonSchema, + siem: commonSchema, + space_selector: commonSchema, + uptime: commonSchema, +}; diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts index 1f22ab0100101..69137681e0597 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts @@ -26,6 +26,7 @@ import { ApplicationUsageTransactional, registerMappings, } from './saved_objects_types'; +import { applicationUsageSchema } from './schema'; /** * Roll indices every 24h @@ -40,7 +41,7 @@ export const ROLL_INDICES_START = 5 * 60 * 1000; export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals'; export const SAVED_OBJECTS_TRANSACTIONAL_TYPE = 'application_usage_transactional'; -interface ApplicationUsageTelemetryReport { +export interface ApplicationUsageTelemetryReport { [appId: string]: { clicks_total: number; clicks_7_days: number; @@ -60,93 +61,96 @@ export function registerApplicationUsageCollector( ) { registerMappings(registerType); - const collector = usageCollection.makeUsageCollector({ - type: 'application_usage', - isReady: () => typeof getSavedObjectsClient() !== 'undefined', - fetch: async () => { - const savedObjectsClient = getSavedObjectsClient(); - if (typeof savedObjectsClient === 'undefined') { - return; - } - const [rawApplicationUsageTotals, rawApplicationUsageTransactional] = await Promise.all([ - findAll(savedObjectsClient, { type: SAVED_OBJECTS_TOTAL_TYPE }), - findAll(savedObjectsClient, { - type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, - }), - ]); - - const applicationUsageFromTotals = rawApplicationUsageTotals.reduce( - (acc, { attributes: { appId, minutesOnScreen, numberOfClicks } }) => { - const existing = acc[appId] || { clicks_total: 0, minutes_on_screen_total: 0 }; - return { - ...acc, - [appId]: { - clicks_total: numberOfClicks + existing.clicks_total, + const collector = usageCollection.makeUsageCollector( + { + type: 'application_usage', + isReady: () => typeof getSavedObjectsClient() !== 'undefined', + schema: applicationUsageSchema, + fetch: async () => { + const savedObjectsClient = getSavedObjectsClient(); + if (typeof savedObjectsClient === 'undefined') { + return; + } + const [rawApplicationUsageTotals, rawApplicationUsageTransactional] = await Promise.all([ + findAll(savedObjectsClient, { type: SAVED_OBJECTS_TOTAL_TYPE }), + findAll(savedObjectsClient, { + type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, + }), + ]); + + const applicationUsageFromTotals = rawApplicationUsageTotals.reduce( + (acc, { attributes: { appId, minutesOnScreen, numberOfClicks } }) => { + const existing = acc[appId] || { clicks_total: 0, minutes_on_screen_total: 0 }; + return { + ...acc, + [appId]: { + clicks_total: numberOfClicks + existing.clicks_total, + clicks_7_days: 0, + clicks_30_days: 0, + clicks_90_days: 0, + minutes_on_screen_total: minutesOnScreen + existing.minutes_on_screen_total, + minutes_on_screen_7_days: 0, + minutes_on_screen_30_days: 0, + minutes_on_screen_90_days: 0, + }, + }; + }, + {} as ApplicationUsageTelemetryReport + ); + const nowMinus7 = moment().subtract(7, 'days'); + const nowMinus30 = moment().subtract(30, 'days'); + const nowMinus90 = moment().subtract(90, 'days'); + + const applicationUsage = rawApplicationUsageTransactional.reduce( + (acc, { attributes: { appId, minutesOnScreen, numberOfClicks, timestamp } }) => { + const existing = acc[appId] || { + clicks_total: 0, clicks_7_days: 0, clicks_30_days: 0, clicks_90_days: 0, - minutes_on_screen_total: minutesOnScreen + existing.minutes_on_screen_total, + minutes_on_screen_total: 0, minutes_on_screen_7_days: 0, minutes_on_screen_30_days: 0, minutes_on_screen_90_days: 0, - }, - }; - }, - {} as ApplicationUsageTelemetryReport - ); - const nowMinus7 = moment().subtract(7, 'days'); - const nowMinus30 = moment().subtract(30, 'days'); - const nowMinus90 = moment().subtract(90, 'days'); - - const applicationUsage = rawApplicationUsageTransactional.reduce( - (acc, { attributes: { appId, minutesOnScreen, numberOfClicks, timestamp } }) => { - const existing = acc[appId] || { - clicks_total: 0, - clicks_7_days: 0, - clicks_30_days: 0, - clicks_90_days: 0, - minutes_on_screen_total: 0, - minutes_on_screen_7_days: 0, - minutes_on_screen_30_days: 0, - minutes_on_screen_90_days: 0, - }; - - const timeOfEntry = moment(timestamp as string); - const isInLast7Days = timeOfEntry.isSameOrAfter(nowMinus7); - const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30); - const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90); - - const last7Days = { - clicks_7_days: existing.clicks_7_days + numberOfClicks, - minutes_on_screen_7_days: existing.minutes_on_screen_7_days + minutesOnScreen, - }; - const last30Days = { - clicks_30_days: existing.clicks_30_days + numberOfClicks, - minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen, - }; - const last90Days = { - clicks_90_days: existing.clicks_90_days + numberOfClicks, - minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen, - }; - - return { - ...acc, - [appId]: { - ...existing, - clicks_total: existing.clicks_total + numberOfClicks, - minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen, - ...(isInLast7Days ? last7Days : {}), - ...(isInLast30Days ? last30Days : {}), - ...(isInLast90Days ? last90Days : {}), - }, - }; - }, - applicationUsageFromTotals - ); - - return applicationUsage; - }, - }); + }; + + const timeOfEntry = moment(timestamp as string); + const isInLast7Days = timeOfEntry.isSameOrAfter(nowMinus7); + const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30); + const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90); + + const last7Days = { + clicks_7_days: existing.clicks_7_days + numberOfClicks, + minutes_on_screen_7_days: existing.minutes_on_screen_7_days + minutesOnScreen, + }; + const last30Days = { + clicks_30_days: existing.clicks_30_days + numberOfClicks, + minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen, + }; + const last90Days = { + clicks_90_days: existing.clicks_90_days + numberOfClicks, + minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen, + }; + + return { + ...acc, + [appId]: { + ...existing, + clicks_total: existing.clicks_total + numberOfClicks, + minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen, + ...(isInLast7Days ? last7Days : {}), + ...(isInLast30Days ? last30Days : {}), + ...(isInLast90Days ? last90Days : {}), + }, + }; + }, + applicationUsageFromTotals + ); + + return applicationUsage; + }, + } + ); usageCollection.registerCollector(collector); diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index c306446b9780d..acd575badbe5b 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -48,6 +48,1214 @@ } } }, + "application_usage": { + "properties": { + "dashboards": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "dev_tools": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "discover": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "home": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "kibana": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "management": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "short_url_redirect": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "timelion": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "visualize": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "apm": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "csm": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "canvas": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "dashboard_mode": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "appSearch": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "workplaceSearch": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "graph": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "logs": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "metrics": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "infra": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "ingestManager": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "lens": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "maps": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "ml": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "monitoring": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "observability-overview": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_account": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_access_agreement": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_capture_url": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_logged_out": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_login": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_logout": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "security_overwritten_session": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:overview": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:detections": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:hosts": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:network": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:timelines": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:case": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "securitySolution:administration": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "siem": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "space_selector": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, + "uptime": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + } + } + }, "csp": { "properties": { "strict": { From 63265b6f57e421c335945aa4e948bd3334876222 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 26 Aug 2020 08:50:52 -0400 Subject: [PATCH 19/56] Compute AAD to encrypty/decrypt SO only if needed (#75818) --- .../server/crypto/encrypted_saved_objects_service.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 99361107047c2..82d6bb9be15f6 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -198,12 +198,15 @@ export class EncryptedSavedObjectsService { if (typeDefinition === undefined) { return attributes; } + let encryptionAAD: string | undefined; - const encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); const encryptedAttributes: Record = {}; for (const attributeName of typeDefinition.attributesToEncrypt) { const attributeValue = attributes[attributeName]; if (attributeValue != null) { + if (!encryptionAAD) { + encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); + } try { encryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!; } catch (err) { @@ -376,8 +379,7 @@ export class EncryptedSavedObjectsService { if (typeDefinition === undefined) { return attributes; } - - const encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); + let encryptionAAD: string | undefined; const decryptedAttributes: Record = {}; for (const attributeName of typeDefinition.attributesToEncrypt) { const attributeValue = attributes[attributeName]; @@ -393,7 +395,9 @@ export class EncryptedSavedObjectsService { )}` ); } - + if (!encryptionAAD) { + encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); + } try { decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!; } catch (err) { From 4042f82035f7dd776f54c3452be51c1fc7365786 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Wed, 26 Aug 2020 09:25:45 -0400 Subject: [PATCH 20/56] [Security Solution][Resolver] Support kuery filter (#74695) * Adding kql filter * Adding filter support for the backend and tests * Moving the filter to the body * switching events and alerts api to post * Removing unused import * Adding tests for events api results being in descending order * Switching frontend to use post for related events --- .../common/endpoint/generate_data.test.ts | 10 + .../common/endpoint/generate_data.ts | 32 +- .../common/endpoint/schema/resolver.ts | 10 + .../resolver/data_access_layer/factory.ts | 2 +- .../server/endpoint/routes/resolver.ts | 4 +- .../server/endpoint/routes/resolver/alerts.ts | 9 +- .../server/endpoint/routes/resolver/events.ts | 9 +- .../routes/resolver/queries/alerts.ts | 14 +- .../routes/resolver/queries/events.ts | 15 +- .../resolver/utils/alerts_query_handler.ts | 32 +- .../resolver/utils/events_query_handler.ts | 33 +- .../endpoint/routes/resolver/utils/fetch.ts | 70 ++-- .../routes/resolver/utils/pagination.test.ts | 14 + .../routes/resolver/utils/pagination.ts | 18 +- .../apis/resolver/alerts.ts | 159 ++++++++ .../apis/resolver/common.ts | 222 ++++++++++ .../apis/resolver/events.ts | 213 ++++++++++ .../apis/resolver/index.ts | 2 + .../apis/resolver/tree.ts | 386 +----------------- 19 files changed, 808 insertions(+), 446 deletions(-) create mode 100644 x-pack/test/security_solution_endpoint_api_int/apis/resolver/alerts.ts create mode 100644 x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts create mode 100644 x-pack/test/security_solution_endpoint_api_int/apis/resolver/events.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts index 46fc002e76e7f..be3a1e82356c8 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts @@ -169,6 +169,7 @@ describe('data generator', () => { const childrenPerNode = 3; const generations = 3; const relatedAlerts = 4; + beforeEach(() => { tree = generator.generateTree({ alwaysGenMaxChildrenPerNode: true, @@ -182,6 +183,7 @@ describe('data generator', () => { { category: RelatedEventCategory.File, count: 2 }, { category: RelatedEventCategory.Network, count: 1 }, ], + relatedEventsOrdered: true, relatedAlerts, ancestryArraySize: ANCESTRY_LIMIT, }); @@ -212,6 +214,14 @@ describe('data generator', () => { } }; + it('creates related events in ascending order', () => { + // the order should not change since it should already be in ascending order + const relatedEventsAsc = _.cloneDeep(tree.origin.relatedEvents).sort( + (event1, event2) => event1['@timestamp'] - event2['@timestamp'] + ); + expect(tree.origin.relatedEvents).toStrictEqual(relatedEventsAsc); + }); + it('has ancestry array defined', () => { expect(tree.origin.lifecycle[0].process.Ext!.ancestry!.length).toBe(ANCESTRY_LIMIT); for (const event of tree.allEvents) { diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 7340b1c021eba..0955f196df176 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -302,6 +302,12 @@ export interface TreeOptions { generations?: number; children?: number; relatedEvents?: RelatedEventInfo[] | number; + /** + * If true then the related events will be created with timestamps that preserve the + * generation order, meaning the first event will always have a timestamp number less + * than the next related event + */ + relatedEventsOrdered?: boolean; relatedAlerts?: number; percentWithRelated?: number; percentTerminated?: number; @@ -322,6 +328,7 @@ export function getTreeOptionsWithDef(options?: TreeOptions): TreeOptionDefaults generations: options?.generations ?? 2, children: options?.children ?? 2, relatedEvents: options?.relatedEvents ?? 5, + relatedEventsOrdered: options?.relatedEventsOrdered ?? false, relatedAlerts: options?.relatedAlerts ?? 3, percentWithRelated: options?.percentWithRelated ?? 30, percentTerminated: options?.percentTerminated ?? 100, @@ -809,7 +816,8 @@ export class EndpointDocGenerator { for (const relatedEvent of this.relatedEventsGenerator( node, opts.relatedEvents, - secBeforeEvent + secBeforeEvent, + opts.relatedEventsOrdered )) { eventList.push(relatedEvent); } @@ -877,6 +885,8 @@ export class EndpointDocGenerator { addRelatedAlerts(ancestor, numAlertsPerNode, processDuration, events); } } + timestamp = timestamp + 1000; + events.push( this.generateAlert( timestamp, @@ -961,7 +971,12 @@ export class EndpointDocGenerator { }); } if (this.randomN(100) < opts.percentWithRelated) { - yield* this.relatedEventsGenerator(child, opts.relatedEvents, processDuration); + yield* this.relatedEventsGenerator( + child, + opts.relatedEvents, + processDuration, + opts.relatedEventsOrdered + ); yield* this.relatedAlertsGenerator(child, opts.relatedAlerts, processDuration); } } @@ -973,13 +988,17 @@ export class EndpointDocGenerator { * @param relatedEvents - can be an array of RelatedEventInfo objects describing the related events that should be generated for each process node * or a number which defines the number of related events and will default to random categories * @param processDuration - maximum number of seconds after process event that related event timestamp can be + * @param ordered - if true the events will have an increasing timestamp, otherwise their timestamp will be random but + * guaranteed to be greater than or equal to the originating event */ public *relatedEventsGenerator( node: Event, relatedEvents: RelatedEventInfo[] | number = 10, - processDuration: number = 6 * 3600 + processDuration: number = 6 * 3600, + ordered: boolean = false ) { let relatedEventsInfo: RelatedEventInfo[]; + let ts = node['@timestamp'] + 1; if (typeof relatedEvents === 'number') { relatedEventsInfo = [{ category: RelatedEventCategory.Random, count: relatedEvents }]; } else { @@ -995,7 +1014,12 @@ export class EndpointDocGenerator { eventInfo = OTHER_EVENT_CATEGORIES[event.category]; } - const ts = node['@timestamp'] + this.randomN(processDuration) * 1000; + if (ordered) { + ts += this.randomN(processDuration) * 1000; + } else { + ts = node['@timestamp'] + this.randomN(processDuration) * 1000; + } + yield this.generateEvent({ timestamp: ts, entityID: node.process.entity_id, diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts index f3e67f84b2880..311aa0c04c9ab 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -33,6 +33,11 @@ export const validateEvents = { afterEvent: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), + body: schema.nullable( + schema.object({ + filter: schema.maybe(schema.string()), + }) + ), }; /** @@ -45,6 +50,11 @@ export const validateAlerts = { afterAlert: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), + body: schema.nullable( + schema.object({ + filter: schema.maybe(schema.string()), + }) + ), }; /** diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts index 016ebfa0faee4..dee53a624baff 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts @@ -25,7 +25,7 @@ export function dataAccessLayerFactory( * Used to get non-process related events for a node. */ async relatedEvents(entityID: string): Promise { - return context.services.http.get(`/api/endpoint/resolver/${entityID}/events`, { + return context.services.http.post(`/api/endpoint/resolver/${entityID}/events`, { query: { events: 100 }, }); }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts index 5c92b23d594de..3ec968e4a0e1a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts @@ -24,7 +24,7 @@ import { handleEntities } from './resolver/entity'; export function registerResolverRoutes(router: IRouter, endpointAppContext: EndpointAppContext) { const log = endpointAppContext.logFactory.get('resolver'); - router.get( + router.post( { path: '/api/endpoint/resolver/{id}/events', validate: validateEvents, @@ -33,7 +33,7 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp handleEvents(log, endpointAppContext) ); - router.get( + router.post( { path: '/api/endpoint/resolver/{id}/alerts', validate: validateAlerts, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts index 830d92ef2efc0..8e641194ab899 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts @@ -14,11 +14,16 @@ import { EndpointAppContext } from '../../types'; export function handleAlerts( log: Logger, endpointAppContext: EndpointAppContext -): RequestHandler, TypeOf> { +): RequestHandler< + TypeOf, + TypeOf, + TypeOf +> { return async (context, req, res) => { const { params: { id }, query: { alerts, afterAlert, legacyEndpointID: endpointID }, + body, } = req; try { const client = context.core.elasticsearch.legacy.client; @@ -26,7 +31,7 @@ export function handleAlerts( const fetcher = new Fetcher(client, id, eventsIndexPattern, alertsIndexPattern, endpointID); return res.ok({ - body: await fetcher.alerts(alerts, afterAlert), + body: await fetcher.alerts(alerts, afterAlert, body?.filter), }); } catch (err) { log.warn(err); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts index 9e5c6be43f728..80d21ae118284 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts @@ -14,11 +14,16 @@ import { EndpointAppContext } from '../../types'; export function handleEvents( log: Logger, endpointAppContext: EndpointAppContext -): RequestHandler, TypeOf> { +): RequestHandler< + TypeOf, + TypeOf, + TypeOf +> { return async (context, req, res) => { const { params: { id }, query: { events, afterEvent, legacyEndpointID: endpointID }, + body, } = req; try { const client = context.core.elasticsearch.legacy.client; @@ -26,7 +31,7 @@ export function handleEvents( const fetcher = new Fetcher(client, id, eventsIndexPattern, alertsIndexPattern, endpointID); return res.ok({ - body: await fetcher.events(events, afterEvent), + body: await fetcher.events(events, afterEvent, body?.filter), }); } catch (err) { log.warn(err); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts index feb4a404b2359..54c6cf432aa89 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { SearchResponse } from 'elasticsearch'; +import { esKuery } from '../../../../../../../../src/plugins/data/server'; import { ResolverEvent } from '../../../../../common/endpoint/types'; import { ResolverQuery } from './base'; import { PaginationBuilder } from '../utils/pagination'; @@ -13,12 +14,17 @@ import { JsonObject } from '../../../../../../../../src/plugins/kibana_utils/com * Builds a query for retrieving alerts for a node. */ export class AlertsQuery extends ResolverQuery { + private readonly kqlQuery: JsonObject[] = []; constructor( private readonly pagination: PaginationBuilder, indexPattern: string | string[], - endpointID?: string + endpointID?: string, + kql?: string ) { super(indexPattern, endpointID); + if (kql) { + this.kqlQuery.push(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(kql))); + } } protected legacyQuery(endpointID: string, uniquePIDs: string[]): JsonObject { @@ -26,6 +32,7 @@ export class AlertsQuery extends ResolverQuery { query: { bool: { filter: [ + ...this.kqlQuery, { terms: { 'endgame.unique_pid': uniquePIDs }, }, @@ -38,7 +45,7 @@ export class AlertsQuery extends ResolverQuery { ], }, }, - ...this.pagination.buildQueryFields('endgame.serial_event_id'), + ...this.pagination.buildQueryFields('endgame.serial_event_id', 'asc'), }; } @@ -47,6 +54,7 @@ export class AlertsQuery extends ResolverQuery { query: { bool: { filter: [ + ...this.kqlQuery, { terms: { 'process.entity_id': entityIDs }, }, @@ -56,7 +64,7 @@ export class AlertsQuery extends ResolverQuery { ], }, }, - ...this.pagination.buildQueryFields('event.id'), + ...this.pagination.buildQueryFields('event.id', 'asc'), }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/events.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/events.ts index abc86826e77dd..0969a3c360e4a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/events.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/events.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { SearchResponse } from 'elasticsearch'; +import { esKuery } from '../../../../../../../../src/plugins/data/server'; import { ResolverEvent } from '../../../../../common/endpoint/types'; import { ResolverQuery } from './base'; import { PaginationBuilder } from '../utils/pagination'; @@ -13,12 +14,18 @@ import { JsonObject } from '../../../../../../../../src/plugins/kibana_utils/com * Builds a query for retrieving related events for a node. */ export class EventsQuery extends ResolverQuery { + private readonly kqlQuery: JsonObject[] = []; + constructor( private readonly pagination: PaginationBuilder, indexPattern: string | string[], - endpointID?: string + endpointID?: string, + kql?: string ) { super(indexPattern, endpointID); + if (kql) { + this.kqlQuery.push(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(kql))); + } } protected legacyQuery(endpointID: string, uniquePIDs: string[]): JsonObject { @@ -26,6 +33,7 @@ export class EventsQuery extends ResolverQuery { query: { bool: { filter: [ + ...this.kqlQuery, { terms: { 'endgame.unique_pid': uniquePIDs }, }, @@ -45,7 +53,7 @@ export class EventsQuery extends ResolverQuery { ], }, }, - ...this.pagination.buildQueryFields('endgame.serial_event_id'), + ...this.pagination.buildQueryFields('endgame.serial_event_id', 'desc'), }; } @@ -54,6 +62,7 @@ export class EventsQuery extends ResolverQuery { query: { bool: { filter: [ + ...this.kqlQuery, { terms: { 'process.entity_id': entityIDs }, }, @@ -70,7 +79,7 @@ export class EventsQuery extends ResolverQuery { ], }, }, - ...this.pagination.buildQueryFields('event.id'), + ...this.pagination.buildQueryFields('event.id', 'desc'), }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/alerts_query_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/alerts_query_handler.ts index ae17cf4c3a562..efffbc10473d4 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/alerts_query_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/alerts_query_handler.ts @@ -13,23 +13,35 @@ import { PaginationBuilder } from './pagination'; import { QueryInfo } from '../queries/multi_searcher'; import { SingleQueryHandler } from './fetch'; +/** + * Parameters for RelatedAlertsQueryHandler + */ +export interface RelatedAlertsParams { + limit: number; + entityID: string; + indexPattern: string; + after?: string; + legacyEndpointID?: string; + filter?: string; +} + /** * Requests related alerts for the given node. */ export class RelatedAlertsQueryHandler implements SingleQueryHandler { private relatedAlerts: ResolverRelatedAlerts | undefined; private readonly query: AlertsQuery; - constructor( - private readonly limit: number, - private readonly entityID: string, - after: string | undefined, - indexPattern: string, - legacyEndpointID: string | undefined - ) { + private readonly limit: number; + private readonly entityID: string; + + constructor(options: RelatedAlertsParams) { + this.limit = options.limit; + this.entityID = options.entityID; this.query = new AlertsQuery( - PaginationBuilder.createBuilder(limit, after), - indexPattern, - legacyEndpointID + PaginationBuilder.createBuilder(this.limit, options.after), + options.indexPattern, + options.legacyEndpointID, + options.filter ); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/events_query_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/events_query_handler.ts index 849dbc25fe4db..8792f917fb4d6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/events_query_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/events_query_handler.ts @@ -13,23 +13,36 @@ import { PaginationBuilder } from './pagination'; import { QueryInfo } from '../queries/multi_searcher'; import { SingleQueryHandler } from './fetch'; +/** + * Parameters for the RelatedEventsQueryHandler + */ +export interface RelatedEventsParams { + limit: number; + entityID: string; + indexPattern: string; + after?: string; + legacyEndpointID?: string; + filter?: string; +} + /** * This retrieves the related events for the origin node of a resolver tree. */ export class RelatedEventsQueryHandler implements SingleQueryHandler { private relatedEvents: ResolverRelatedEvents | undefined; private readonly query: EventsQuery; - constructor( - private readonly limit: number, - private readonly entityID: string, - after: string | undefined, - indexPattern: string, - legacyEndpointID: string | undefined - ) { + private readonly limit: number; + private readonly entityID: string; + + constructor(options: RelatedEventsParams) { + this.limit = options.limit; + this.entityID = options.entityID; + this.query = new EventsQuery( - PaginationBuilder.createBuilder(limit, after), - indexPattern, - legacyEndpointID + PaginationBuilder.createBuilder(this.limit, options.after), + options.indexPattern, + options.legacyEndpointID, + options.filter ); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts index 43c10d552ab4e..1b88f965909eb 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts @@ -110,21 +110,21 @@ export class Fetcher { this.endpointID ); - const eventsHandler = new RelatedEventsQueryHandler( - options.events, - this.id, - options.afterEvent, - this.eventsIndexPattern, - this.endpointID - ); + const eventsHandler = new RelatedEventsQueryHandler({ + limit: options.events, + entityID: this.id, + after: options.afterEvent, + indexPattern: this.eventsIndexPattern, + legacyEndpointID: this.endpointID, + }); - const alertsHandler = new RelatedAlertsQueryHandler( - options.alerts, - this.id, - options.afterAlert, - this.alertsIndexPattern, - this.endpointID - ); + const alertsHandler = new RelatedAlertsQueryHandler({ + limit: options.alerts, + entityID: this.id, + after: options.afterAlert, + indexPattern: this.alertsIndexPattern, + legacyEndpointID: this.endpointID, + }); // we need to get the start events first because the API request defines how many nodes to return and we don't want // to count or limit ourselves based on the other lifecycle events (end, etc) @@ -228,17 +228,24 @@ export class Fetcher { /** * Retrieves the related events for the origin node. * - * @param limit the upper bound number of related events to return + * @param limit the upper bound number of related events to return. The limit is applied after the cursor is used to + * skip the previous results. * @param after a cursor to use as the starting point for retrieving related events + * @param filter a kql query for filtering the results */ - public async events(limit: number, after?: string): Promise { - const eventsHandler = new RelatedEventsQueryHandler( + public async events( + limit: number, + after?: string, + filter?: string + ): Promise { + const eventsHandler = new RelatedEventsQueryHandler({ limit, - this.id, + entityID: this.id, after, - this.eventsIndexPattern, - this.endpointID - ); + indexPattern: this.eventsIndexPattern, + legacyEndpointID: this.endpointID, + filter, + }); return eventsHandler.search(this.client); } @@ -246,17 +253,24 @@ export class Fetcher { /** * Retrieves the alerts for the origin node. * - * @param limit the upper bound number of alerts to return + * @param limit the upper bound number of alerts to return. The limit is applied after the cursor is used to + * skip the previous results. * @param after a cursor to use as the starting point for retrieving alerts + * @param filter a kql query string for filtering the results */ - public async alerts(limit: number, after?: string): Promise { - const alertsHandler = new RelatedAlertsQueryHandler( + public async alerts( + limit: number, + after?: string, + filter?: string + ): Promise { + const alertsHandler = new RelatedAlertsQueryHandler({ limit, - this.id, + entityID: this.id, after, - this.alertsIndexPattern, - this.endpointID - ); + indexPattern: this.alertsIndexPattern, + legacyEndpointID: this.endpointID, + filter, + }); return alertsHandler.search(this.client); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.test.ts index 4daa45aec2a74..8e567bfb59c65 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.test.ts @@ -42,5 +42,19 @@ describe('Pagination', () => { const fields = builder.buildQueryFields(''); expect(fields).not.toHaveProperty('search_after'); }); + + it('creates the sort field in ascending order', () => { + const builder = PaginationBuilder.createBuilder(100); + expect(builder.buildQueryFields('a').sort).toContainEqual({ '@timestamp': 'asc' }); + expect(builder.buildQueryFields('', 'asc').sort).toContainEqual({ '@timestamp': 'asc' }); + }); + + it('creates the sort field in descending order', () => { + const builder = PaginationBuilder.createBuilder(100); + expect(builder.buildQueryFields('a', 'desc').sort).toStrictEqual([ + { '@timestamp': 'desc' }, + { a: 'asc' }, + ]); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts index f6ff4451b5d8e..4a6c65e55a6b6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts @@ -16,6 +16,11 @@ interface PaginationCursor { eventID: string; } +/** + * The sort direction for the timestamp field + */ +export type TimeSortDirection = 'asc' | 'desc'; + /** * Defines the sorting fields for queries that leverage pagination */ @@ -158,10 +163,14 @@ export class PaginationBuilder { * Helper for creates an object for adding the pagination fields to a query * * @param tiebreaker a unique field to use as the tiebreaker for the search_after + * @param timeSort is the timestamp sort direction * @returns an object containing the pagination information */ - buildQueryFieldsAsInterface(tiebreaker: string): PaginationFields { - const sort: SortFields = [{ '@timestamp': 'asc' }, { [tiebreaker]: 'asc' }]; + buildQueryFieldsAsInterface( + tiebreaker: string, + timeSort: TimeSortDirection = 'asc' + ): PaginationFields { + const sort: SortFields = [{ '@timestamp': timeSort }, { [tiebreaker]: 'asc' }]; let searchAfter: SearchAfterFields | undefined; if (this.timestamp && this.eventID) { searchAfter = [this.timestamp, this.eventID]; @@ -174,11 +183,12 @@ export class PaginationBuilder { * Creates an object for adding the pagination fields to a query * * @param tiebreaker a unique field to use as the tiebreaker for the search_after + * @param timeSort is the timestamp sort direction * @returns an object containing the pagination information */ - buildQueryFields(tiebreaker: string): JsonObject { + buildQueryFields(tiebreaker: string, timeSort: TimeSortDirection = 'asc'): JsonObject { const fields: JsonObject = {}; - const pagination = this.buildQueryFieldsAsInterface(tiebreaker); + const pagination = this.buildQueryFieldsAsInterface(tiebreaker, timeSort); fields.sort = pagination.sort; fields.size = pagination.size; if (pagination.searchAfter) { diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/alerts.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/alerts.ts new file mode 100644 index 0000000000000..82d844aae8016 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/alerts.ts @@ -0,0 +1,159 @@ +/* + * 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 { eventId } from '../../../../plugins/security_solution/common/endpoint/models/event'; +import { ResolverRelatedAlerts } from '../../../../plugins/security_solution/common/endpoint/types'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { + Tree, + RelatedEventCategory, +} from '../../../../plugins/security_solution/common/endpoint/generate_data'; +import { Options, GeneratedTrees } from '../../services/resolver'; +import { compareArrays } from './common'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const resolver = getService('resolverGenerator'); + + const relatedEventsToGen = [ + { category: RelatedEventCategory.Driver, count: 2 }, + { category: RelatedEventCategory.File, count: 1 }, + { category: RelatedEventCategory.Registry, count: 1 }, + ]; + const relatedAlerts = 4; + let resolverTrees: GeneratedTrees; + let tree: Tree; + const treeOptions: Options = { + ancestors: 5, + relatedEvents: relatedEventsToGen, + relatedAlerts, + children: 3, + generations: 2, + percentTerminated: 100, + percentWithRelated: 100, + numTrees: 1, + alwaysGenMaxChildrenPerNode: true, + ancestryArraySize: 2, + }; + + describe('related alerts route', () => { + before(async () => { + resolverTrees = await resolver.createTrees(treeOptions); + // we only requested a single alert so there's only 1 tree + tree = resolverTrees.trees[0]; + }); + after(async () => { + await resolver.deleteData(resolverTrees); + }); + + it('should not find any alerts', async () => { + const { body }: { body: ResolverRelatedAlerts } = await supertest + .post(`/api/endpoint/resolver/5555/alerts`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.nextAlert).to.eql(null); + expect(body.alerts).to.be.empty(); + }); + + it('should return details for the root node', async () => { + const { body }: { body: ResolverRelatedAlerts } = await supertest + .post(`/api/endpoint/resolver/${tree.origin.id}/alerts`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.alerts.length).to.eql(4); + compareArrays(tree.origin.relatedAlerts, body.alerts, true); + expect(body.nextAlert).to.eql(null); + }); + + it('should allow alerts to be filtered', async () => { + const filter = `not event.id:"${tree.origin.relatedAlerts[0].event.id}"`; + const { body }: { body: ResolverRelatedAlerts } = await supertest + .post(`/api/endpoint/resolver/${tree.origin.id}/alerts`) + .set('kbn-xsrf', 'xxx') + .send({ + filter, + }) + .expect(200); + expect(body.alerts.length).to.eql(3); + compareArrays(tree.origin.relatedAlerts, body.alerts); + expect(body.nextAlert).to.eql(null); + + // should not find the alert that we excluded in the filter + expect( + body.alerts.find((bodyAlert) => { + return eventId(bodyAlert) === tree.origin.relatedAlerts[0].event.id; + }) + ).to.not.be.ok(); + }); + + it('should return paginated results for the root node', async () => { + let { body }: { body: ResolverRelatedAlerts } = await supertest + .post(`/api/endpoint/resolver/${tree.origin.id}/alerts?alerts=2`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.alerts.length).to.eql(2); + compareArrays(tree.origin.relatedAlerts, body.alerts); + expect(body.nextAlert).not.to.eql(null); + + ({ body } = await supertest + .post( + `/api/endpoint/resolver/${tree.origin.id}/alerts?alerts=2&afterAlert=${body.nextAlert}` + ) + .set('kbn-xsrf', 'xxx') + .expect(200)); + expect(body.alerts.length).to.eql(2); + compareArrays(tree.origin.relatedAlerts, body.alerts); + expect(body.nextAlert).to.not.eql(null); + + ({ body } = await supertest + .post( + `/api/endpoint/resolver/${tree.origin.id}/alerts?alerts=2&afterAlert=${body.nextAlert}` + ) + .set('kbn-xsrf', 'xxx') + .expect(200)); + expect(body.alerts).to.be.empty(); + expect(body.nextAlert).to.eql(null); + }); + + it('should return the first page of information when the cursor is invalid', async () => { + const { body }: { body: ResolverRelatedAlerts } = await supertest + .post(`/api/endpoint/resolver/${tree.origin.id}/alerts?afterAlert=blah`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.alerts.length).to.eql(4); + compareArrays(tree.origin.relatedAlerts, body.alerts, true); + expect(body.nextAlert).to.eql(null); + }); + + it('should sort the alerts in ascending order', async () => { + const { body }: { body: ResolverRelatedAlerts } = await supertest + .post(`/api/endpoint/resolver/${tree.origin.id}/alerts`) + .set('kbn-xsrf', 'xxx') + .expect(200); + const sortedAsc = [...tree.origin.relatedAlerts].sort((event1, event2) => { + // this sorts the events by timestamp in ascending order + const diff = event1['@timestamp'] - event2['@timestamp']; + // if the timestamps are the same, fallback to the event.id sorted in + // ascending order + if (diff === 0) { + if (event1.event.id < event2.event.id) { + return -1; + } + if (event1.event.id > event2.event.id) { + return 1; + } + return 0; + } + return diff; + }); + + expect(body.alerts.length).to.eql(4); + for (let i = 0; i < body.alerts.length; i++) { + expect(eventId(body.alerts[i])).to.equal(sortedAsc[i].event.id); + } + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts new file mode 100644 index 0000000000000..92d14fb94a2d8 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts @@ -0,0 +1,222 @@ +/* + * 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 expect from '@kbn/expect'; +import { + ResolverChildNode, + ResolverLifecycleNode, + ResolverEvent, + ResolverNodeStats, +} from '../../../../plugins/security_solution/common/endpoint/types'; +import { + parentEntityId, + eventId, +} from '../../../../plugins/security_solution/common/endpoint/models/event'; +import { + Event, + Tree, + TreeNode, + RelatedEventInfo, + categoryMapping, +} from '../../../../plugins/security_solution/common/endpoint/generate_data'; + +/** + * Check that the given lifecycle is in the resolver tree's corresponding map + * + * @param node a lifecycle node containing the start and end events for a node + * @param nodeMap a map of entity_ids to nodes to look for the passed in `node` + */ +const expectLifecycleNodeInMap = (node: ResolverLifecycleNode, nodeMap: Map) => { + const genNode = nodeMap.get(node.entityID); + expect(genNode).to.be.ok(); + compareArrays(genNode!.lifecycle, node.lifecycle, true); +}; + +/** + * Verify that all the ancestor nodes are valid and optionally have parents. + * + * @param ancestors an array of ancestors + * @param tree the generated resolver tree as the source of truth + * @param verifyLastParent a boolean indicating whether to check the last ancestor. If the ancestors array intentionally + * does not contain all the ancestors, the last one will not have the parent + */ +export const verifyAncestry = ( + ancestors: ResolverLifecycleNode[], + tree: Tree, + verifyLastParent: boolean +) => { + // group the ancestors by their entity_id mapped to a lifecycle node + const groupedAncestors = _.groupBy(ancestors, (ancestor) => ancestor.entityID); + // group by parent entity_id + const groupedAncestorsParent = _.groupBy(ancestors, (ancestor) => + parentEntityId(ancestor.lifecycle[0]) + ); + // make sure there aren't any nodes with the same entity_id + expect(Object.keys(groupedAncestors).length).to.eql(ancestors.length); + // make sure there aren't any nodes with the same parent entity_id + expect(Object.keys(groupedAncestorsParent).length).to.eql(ancestors.length); + + // make sure each of the ancestors' lifecycle events are in the generated tree + for (const node of ancestors) { + expectLifecycleNodeInMap(node, tree.ancestry); + } + + // start at the origin which is always the first element of the array and make sure we have a connection + // using parent id between each of the nodes + let foundParents = 0; + let node = ancestors[0]; + for (let i = 0; i < ancestors.length; i++) { + const parentID = parentEntityId(node.lifecycle[0]); + if (parentID !== undefined) { + const nextNode = groupedAncestors[parentID]; + if (!nextNode) { + break; + } + // the grouped nodes should only have a single entry since each entity is unique + node = nextNode[0]; + } + foundParents++; + } + + if (verifyLastParent) { + expect(foundParents).to.eql(ancestors.length); + } else { + // if we only retrieved a portion of all the ancestors then the most distant grandparent's parent will not necessarily + // be in the results + expect(foundParents).to.eql(ancestors.length - 1); + } +}; + +/** + * Retrieves the most distant ancestor in the given array. + * + * @param ancestors an array of ancestor nodes + */ +export const retrieveDistantAncestor = (ancestors: ResolverLifecycleNode[]) => { + // group the ancestors by their entity_id mapped to a lifecycle node + const groupedAncestors = _.groupBy(ancestors, (ancestor) => ancestor.entityID); + let node = ancestors[0]; + for (let i = 0; i < ancestors.length; i++) { + const parentID = parentEntityId(node.lifecycle[0]); + if (parentID !== undefined) { + const nextNode = groupedAncestors[parentID]; + if (nextNode) { + node = nextNode[0]; + } else { + return node; + } + } + } + return node; +}; + +/** + * Verify that the children nodes are correct + * + * @param children the children nodes + * @param tree the generated resolver tree as the source of truth + * @param numberOfParents an optional number to compare that are a certain number of parents in the children array + * @param childrenPerParent an optional number to compare that there are a certain number of children for each parent + */ +export const verifyChildren = ( + children: ResolverChildNode[], + tree: Tree, + numberOfParents?: number, + childrenPerParent?: number +) => { + // group the children by their entity_id mapped to a child node + const groupedChildren = _.groupBy(children, (child) => child.entityID); + // make sure each child is unique + expect(Object.keys(groupedChildren).length).to.eql(children.length); + if (numberOfParents !== undefined) { + const groupParent = _.groupBy(children, (child) => parentEntityId(child.lifecycle[0])); + expect(Object.keys(groupParent).length).to.eql(numberOfParents); + if (childrenPerParent !== undefined) { + Object.values(groupParent).forEach((childNodes) => + expect(childNodes.length).to.be(childrenPerParent) + ); + } + } + + children.forEach((child) => { + expectLifecycleNodeInMap(child, tree.children); + }); +}; + +/** + * Compare an array of events returned from an API with an array of events generated + * + * @param expected an array to use as the source of truth + * @param toTest the array to test against the source of truth + * @param lengthCheck an optional flag to check that the arrays are the same length + */ +export const compareArrays = ( + expected: Event[], + toTest: ResolverEvent[], + lengthCheck: boolean = false +) => { + if (lengthCheck) { + expect(expected.length).to.eql(toTest.length); + } + + toTest.forEach((toTestEvent) => { + expect( + expected.find((arrEvent) => { + // we're only checking that the event ids are the same here. The reason we can't check the entire document + // is because ingest pipelines are used to add fields to the document when it is received by elasticsearch, + // therefore it will not be the same as the document created by the generator + return eventId(toTestEvent) === eventId(arrEvent); + }) + ).to.be.ok(); + }); +}; + +/** + * Verifies that the stats received from ES for a node reflect the categories of events that the generator created. + * + * @param relatedEvents the related events received for a particular node + * @param categories the related event info used when generating the resolver tree + */ +export const verifyStats = ( + stats: ResolverNodeStats | undefined, + categories: RelatedEventInfo[], + relatedAlerts: number +) => { + expect(stats).to.not.be(undefined); + let totalExpEvents = 0; + for (const cat of categories) { + const ecsCategories = categoryMapping[cat.category]; + if (Array.isArray(ecsCategories)) { + // if there are multiple ecs categories used to define a related event, the count for all of them should be the same + // and they should equal what is defined in the categories used to generate the related events + for (const ecsCat of ecsCategories) { + expect(stats?.events.byCategory[ecsCat]).to.be(cat.count); + } + } else { + expect(stats?.events.byCategory[ecsCategories]).to.be(cat.count); + } + + totalExpEvents += cat.count; + } + expect(stats?.events.total).to.be(totalExpEvents); + expect(stats?.totalAlerts); +}; + +/** + * A helper function for verifying the stats information an array of nodes. + * + * @param nodes an array of lifecycle nodes that should have a stats field defined + * @param categories the related event info used when generating the resolver tree + */ +export const verifyLifecycleStats = ( + nodes: ResolverLifecycleNode[], + categories: RelatedEventInfo[], + relatedAlerts: number +) => { + for (const node of nodes) { + verifyStats(node.stats, categories, relatedAlerts); + } +}; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/events.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/events.ts new file mode 100644 index 0000000000000..c0e4e466c7b62 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/events.ts @@ -0,0 +1,213 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { eventId } from '../../../../plugins/security_solution/common/endpoint/models/event'; +import { ResolverRelatedEvents } from '../../../../plugins/security_solution/common/endpoint/types'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { + Tree, + RelatedEventCategory, +} from '../../../../plugins/security_solution/common/endpoint/generate_data'; +import { Options, GeneratedTrees } from '../../services/resolver'; +import { compareArrays } from './common'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const resolver = getService('resolverGenerator'); + const esArchiver = getService('esArchiver'); + + const relatedEventsToGen = [ + { category: RelatedEventCategory.Driver, count: 2 }, + { category: RelatedEventCategory.File, count: 1 }, + { category: RelatedEventCategory.Registry, count: 1 }, + ]; + const relatedAlerts = 4; + let resolverTrees: GeneratedTrees; + let tree: Tree; + const treeOptions: Options = { + ancestors: 5, + relatedEvents: relatedEventsToGen, + relatedEventsOrdered: true, + relatedAlerts, + children: 3, + generations: 2, + percentTerminated: 100, + percentWithRelated: 100, + numTrees: 1, + alwaysGenMaxChildrenPerNode: true, + ancestryArraySize: 2, + }; + + describe('related events route', () => { + before(async () => { + await esArchiver.load('endpoint/resolver/api_feature'); + resolverTrees = await resolver.createTrees(treeOptions); + // we only requested a single alert so there's only 1 tree + tree = resolverTrees.trees[0]; + }); + after(async () => { + await resolver.deleteData(resolverTrees); + await esArchiver.unload('endpoint/resolver/api_feature'); + }); + + describe('legacy events', () => { + const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; + const entityID = '94042'; + const cursor = 'eyJ0aW1lc3RhbXAiOjE1ODE0NTYyNTUwMDAsImV2ZW50SUQiOiI5NDA0MyJ9'; + + it('should return details for the root node', async () => { + const { body }: { body: ResolverRelatedEvents } = await supertest + .post(`/api/endpoint/resolver/${entityID}/events?legacyEndpointID=${endpointID}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.events.length).to.eql(1); + expect(body.entityID).to.eql(entityID); + expect(body.nextEvent).to.eql(null); + }); + + it('returns no values when there is no more data', async () => { + const { body }: { body: ResolverRelatedEvents } = await supertest + // after is set to the document id of the last event so there shouldn't be any more after it + .post( + `/api/endpoint/resolver/${entityID}/events?legacyEndpointID=${endpointID}&afterEvent=${cursor}` + ) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.events).be.empty(); + expect(body.entityID).to.eql(entityID); + expect(body.nextEvent).to.eql(null); + }); + + it('should return the first page of information when the cursor is invalid', async () => { + const { body }: { body: ResolverRelatedEvents } = await supertest + .post( + `/api/endpoint/resolver/${entityID}/events?legacyEndpointID=${endpointID}&afterEvent=blah` + ) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.entityID).to.eql(entityID); + expect(body.nextEvent).to.eql(null); + }); + + it('should return no results for an invalid endpoint ID', async () => { + const { body }: { body: ResolverRelatedEvents } = await supertest + .post(`/api/endpoint/resolver/${entityID}/events?legacyEndpointID=foo`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.nextEvent).to.eql(null); + expect(body.entityID).to.eql(entityID); + expect(body.events).to.be.empty(); + }); + + it('should error on invalid pagination values', async () => { + await supertest + .post(`/api/endpoint/resolver/${entityID}/events?events=0`) + .set('kbn-xsrf', 'xxx') + .expect(400); + await supertest + .post(`/api/endpoint/resolver/${entityID}/events?events=20000`) + .set('kbn-xsrf', 'xxx') + .expect(400); + await supertest + .post(`/api/endpoint/resolver/${entityID}/events?events=-1`) + .set('kbn-xsrf', 'xxx') + .expect(400); + }); + }); + + describe('endpoint events', () => { + it('should not find any events', async () => { + const { body }: { body: ResolverRelatedEvents } = await supertest + .post(`/api/endpoint/resolver/5555/events`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.nextEvent).to.eql(null); + expect(body.events).to.be.empty(); + }); + + it('should return details for the root node', async () => { + const { body }: { body: ResolverRelatedEvents } = await supertest + .post(`/api/endpoint/resolver/${tree.origin.id}/events`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.events.length).to.eql(4); + compareArrays(tree.origin.relatedEvents, body.events, true); + expect(body.nextEvent).to.eql(null); + }); + + it('should allow for the events to be filtered', async () => { + const filter = `event.category:"${RelatedEventCategory.Driver}"`; + const { body }: { body: ResolverRelatedEvents } = await supertest + .post(`/api/endpoint/resolver/${tree.origin.id}/events`) + .set('kbn-xsrf', 'xxx') + .send({ + filter, + }) + .expect(200); + expect(body.events.length).to.eql(2); + compareArrays(tree.origin.relatedEvents, body.events); + expect(body.nextEvent).to.eql(null); + for (const event of body.events) { + expect(event.event?.category).to.be(RelatedEventCategory.Driver); + } + }); + + it('should return paginated results for the root node', async () => { + let { body }: { body: ResolverRelatedEvents } = await supertest + .post(`/api/endpoint/resolver/${tree.origin.id}/events?events=2`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.events.length).to.eql(2); + compareArrays(tree.origin.relatedEvents, body.events); + expect(body.nextEvent).not.to.eql(null); + + ({ body } = await supertest + .post( + `/api/endpoint/resolver/${tree.origin.id}/events?events=2&afterEvent=${body.nextEvent}` + ) + .set('kbn-xsrf', 'xxx') + .expect(200)); + expect(body.events.length).to.eql(2); + compareArrays(tree.origin.relatedEvents, body.events); + expect(body.nextEvent).to.not.eql(null); + + ({ body } = await supertest + .post( + `/api/endpoint/resolver/${tree.origin.id}/events?events=2&afterEvent=${body.nextEvent}` + ) + .set('kbn-xsrf', 'xxx') + .expect(200)); + expect(body.events).to.be.empty(); + expect(body.nextEvent).to.eql(null); + }); + + it('should return the first page of information when the cursor is invalid', async () => { + const { body }: { body: ResolverRelatedEvents } = await supertest + .post(`/api/endpoint/resolver/${tree.origin.id}/events?afterEvent=blah`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.events.length).to.eql(4); + compareArrays(tree.origin.relatedEvents, body.events, true); + expect(body.nextEvent).to.eql(null); + }); + + it('should sort the events in descending order', async () => { + const { body }: { body: ResolverRelatedEvents } = await supertest + .post(`/api/endpoint/resolver/${tree.origin.id}/events`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.events.length).to.eql(4); + // these events are created in the order they are defined in the array so the newest one is + // the last element in the array so let's reverse it + const relatedEvents = tree.origin.relatedEvents.reverse(); + for (let i = 0; i < body.events.length; i++) { + expect(body.events[i].event?.category).to.equal(relatedEvents[i].event.category); + expect(eventId(body.events[i])).to.equal(relatedEvents[i].event.id); + } + }); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts index dc9a1fab9ec02..fc603af3619a4 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts @@ -12,5 +12,7 @@ export default function (providerContext: FtrProviderContext) { loadTestFile(require.resolve('./entity_id')); loadTestFile(require.resolve('./children')); loadTestFile(require.resolve('./tree')); + loadTestFile(require.resolve('./alerts')); + loadTestFile(require.resolve('./events')); }); } diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts index f4836379ca273..957d559087f5e 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts @@ -3,232 +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 _ from 'lodash'; import expect from '@kbn/expect'; import { - ResolverChildNode, - ResolverLifecycleNode, ResolverAncestry, - ResolverEvent, - ResolverRelatedEvents, ResolverChildren, ResolverTree, LegacyEndpointEvent, - ResolverNodeStats, - ResolverRelatedAlerts, } from '../../../../plugins/security_solution/common/endpoint/types'; -import { - parentEntityId, - eventId, -} from '../../../../plugins/security_solution/common/endpoint/models/event'; +import { parentEntityId } from '../../../../plugins/security_solution/common/endpoint/models/event'; import { FtrProviderContext } from '../../ftr_provider_context'; import { - Event, Tree, - TreeNode, RelatedEventCategory, - RelatedEventInfo, - categoryMapping, } from '../../../../plugins/security_solution/common/endpoint/generate_data'; import { Options, GeneratedTrees } from '../../services/resolver'; - -/** - * Check that the given lifecycle is in the resolver tree's corresponding map - * - * @param node a lifecycle node containing the start and end events for a node - * @param nodeMap a map of entity_ids to nodes to look for the passed in `node` - */ -const expectLifecycleNodeInMap = (node: ResolverLifecycleNode, nodeMap: Map) => { - const genNode = nodeMap.get(node.entityID); - expect(genNode).to.be.ok(); - compareArrays(genNode!.lifecycle, node.lifecycle, true); -}; - -/** - * Verify that all the ancestor nodes are valid and optionally have parents. - * - * @param ancestors an array of ancestors - * @param tree the generated resolver tree as the source of truth - * @param verifyLastParent a boolean indicating whether to check the last ancestor. If the ancestors array intentionally - * does not contain all the ancestors, the last one will not have the parent - */ -const verifyAncestry = ( - ancestors: ResolverLifecycleNode[], - tree: Tree, - verifyLastParent: boolean -) => { - // group the ancestors by their entity_id mapped to a lifecycle node - const groupedAncestors = _.groupBy(ancestors, (ancestor) => ancestor.entityID); - // group by parent entity_id - const groupedAncestorsParent = _.groupBy(ancestors, (ancestor) => - parentEntityId(ancestor.lifecycle[0]) - ); - // make sure there aren't any nodes with the same entity_id - expect(Object.keys(groupedAncestors).length).to.eql(ancestors.length); - // make sure there aren't any nodes with the same parent entity_id - expect(Object.keys(groupedAncestorsParent).length).to.eql(ancestors.length); - - // make sure each of the ancestors' lifecycle events are in the generated tree - for (const node of ancestors) { - expectLifecycleNodeInMap(node, tree.ancestry); - } - - // start at the origin which is always the first element of the array and make sure we have a connection - // using parent id between each of the nodes - let foundParents = 0; - let node = ancestors[0]; - for (let i = 0; i < ancestors.length; i++) { - const parentID = parentEntityId(node.lifecycle[0]); - if (parentID !== undefined) { - const nextNode = groupedAncestors[parentID]; - if (!nextNode) { - break; - } - // the grouped nodes should only have a single entry since each entity is unique - node = nextNode[0]; - } - foundParents++; - } - - if (verifyLastParent) { - expect(foundParents).to.eql(ancestors.length); - } else { - // if we only retrieved a portion of all the ancestors then the most distant grandparent's parent will not necessarily - // be in the results - expect(foundParents).to.eql(ancestors.length - 1); - } -}; - -/** - * Retrieves the most distant ancestor in the given array. - * - * @param ancestors an array of ancestor nodes - */ -const retrieveDistantAncestor = (ancestors: ResolverLifecycleNode[]) => { - // group the ancestors by their entity_id mapped to a lifecycle node - const groupedAncestors = _.groupBy(ancestors, (ancestor) => ancestor.entityID); - let node = ancestors[0]; - for (let i = 0; i < ancestors.length; i++) { - const parentID = parentEntityId(node.lifecycle[0]); - if (parentID !== undefined) { - const nextNode = groupedAncestors[parentID]; - if (nextNode) { - node = nextNode[0]; - } else { - return node; - } - } - } - return node; -}; - -/** - * Verify that the children nodes are correct - * - * @param children the children nodes - * @param tree the generated resolver tree as the source of truth - * @param numberOfParents an optional number to compare that are a certain number of parents in the children array - * @param childrenPerParent an optional number to compare that there are a certain number of children for each parent - */ -const verifyChildren = ( - children: ResolverChildNode[], - tree: Tree, - numberOfParents?: number, - childrenPerParent?: number -) => { - // group the children by their entity_id mapped to a child node - const groupedChildren = _.groupBy(children, (child) => child.entityID); - // make sure each child is unique - expect(Object.keys(groupedChildren).length).to.eql(children.length); - if (numberOfParents !== undefined) { - const groupParent = _.groupBy(children, (child) => parentEntityId(child.lifecycle[0])); - expect(Object.keys(groupParent).length).to.eql(numberOfParents); - if (childrenPerParent !== undefined) { - Object.values(groupParent).forEach((childNodes) => - expect(childNodes.length).to.be(childrenPerParent) - ); - } - } - - children.forEach((child) => { - expectLifecycleNodeInMap(child, tree.children); - }); -}; - -/** - * Compare an array of events returned from an API with an array of events generated - * - * @param expected an array to use as the source of truth - * @param toTest the array to test against the source of truth - * @param lengthCheck an optional flag to check that the arrays are the same length - */ -const compareArrays = ( - expected: Event[], - toTest: ResolverEvent[], - lengthCheck: boolean = false -) => { - if (lengthCheck) { - expect(expected.length).to.eql(toTest.length); - } - - toTest.forEach((toTestEvent) => { - expect( - expected.find((arrEvent) => { - // we're only checking that the event ids are the same here. The reason we can't check the entire document - // is because ingest pipelines are used to add fields to the document when it is received by elasticsearch, - // therefore it will not be the same as the document created by the generator - return eventId(toTestEvent) === eventId(arrEvent); - }) - ).to.be.ok(); - }); -}; - -/** - * Verifies that the stats received from ES for a node reflect the categories of events that the generator created. - * - * @param relatedEvents the related events received for a particular node - * @param categories the related event info used when generating the resolver tree - */ -const verifyStats = ( - stats: ResolverNodeStats | undefined, - categories: RelatedEventInfo[], - relatedAlerts: number -) => { - expect(stats).to.not.be(undefined); - let totalExpEvents = 0; - for (const cat of categories) { - const ecsCategories = categoryMapping[cat.category]; - if (Array.isArray(ecsCategories)) { - // if there are multiple ecs categories used to define a related event, the count for all of them should be the same - // and they should equal what is defined in the categories used to generate the related events - for (const ecsCat of ecsCategories) { - expect(stats?.events.byCategory[ecsCat]).to.be(cat.count); - } - } else { - expect(stats?.events.byCategory[ecsCategories]).to.be(cat.count); - } - - totalExpEvents += cat.count; - } - expect(stats?.events.total).to.be(totalExpEvents); - expect(stats?.totalAlerts); -}; - -/** - * A helper function for verifying the stats information an array of nodes. - * - * @param nodes an array of lifecycle nodes that should have a stats field defined - * @param categories the related event info used when generating the resolver tree - */ -const verifyLifecycleStats = ( - nodes: ResolverLifecycleNode[], - categories: RelatedEventInfo[], - relatedAlerts: number -) => { - for (const node of nodes) { - verifyStats(node.stats, categories, relatedAlerts); - } -}; +import { + compareArrays, + verifyAncestry, + retrieveDistantAncestor, + verifyChildren, + verifyLifecycleStats, + verifyStats, +} from './common'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -269,170 +65,6 @@ export default function ({ getService }: FtrProviderContext) { await esArchiver.unload('endpoint/resolver/api_feature'); }); - describe('related alerts route', () => { - describe('endpoint events', () => { - it('should not find any alerts', async () => { - const { body }: { body: ResolverRelatedAlerts } = await supertest - .get(`/api/endpoint/resolver/5555/alerts`) - .expect(200); - expect(body.nextAlert).to.eql(null); - expect(body.alerts).to.be.empty(); - }); - - it('should return details for the root node', async () => { - const { body }: { body: ResolverRelatedAlerts } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/alerts`) - .expect(200); - expect(body.alerts.length).to.eql(4); - compareArrays(tree.origin.relatedAlerts, body.alerts, true); - expect(body.nextAlert).to.eql(null); - }); - - it('should return paginated results for the root node', async () => { - let { body }: { body: ResolverRelatedAlerts } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/alerts?alerts=2`) - .expect(200); - expect(body.alerts.length).to.eql(2); - compareArrays(tree.origin.relatedAlerts, body.alerts); - expect(body.nextAlert).not.to.eql(null); - - ({ body } = await supertest - .get( - `/api/endpoint/resolver/${tree.origin.id}/alerts?alerts=2&afterAlert=${body.nextAlert}` - ) - .expect(200)); - expect(body.alerts.length).to.eql(2); - compareArrays(tree.origin.relatedAlerts, body.alerts); - expect(body.nextAlert).to.not.eql(null); - - ({ body } = await supertest - .get( - `/api/endpoint/resolver/${tree.origin.id}/alerts?alerts=2&afterAlert=${body.nextAlert}` - ) - .expect(200)); - expect(body.alerts).to.be.empty(); - expect(body.nextAlert).to.eql(null); - }); - - it('should return the first page of information when the cursor is invalid', async () => { - const { body }: { body: ResolverRelatedAlerts } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/alerts?afterAlert=blah`) - .expect(200); - expect(body.alerts.length).to.eql(4); - compareArrays(tree.origin.relatedAlerts, body.alerts, true); - expect(body.nextAlert).to.eql(null); - }); - }); - }); - - describe('related events route', () => { - describe('legacy events', () => { - const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; - const entityID = '94042'; - const cursor = 'eyJ0aW1lc3RhbXAiOjE1ODE0NTYyNTUwMDAsImV2ZW50SUQiOiI5NDA0MyJ9'; - - it('should return details for the root node', async () => { - const { body }: { body: ResolverRelatedEvents } = await supertest - .get(`/api/endpoint/resolver/${entityID}/events?legacyEndpointID=${endpointID}`) - .expect(200); - expect(body.events.length).to.eql(1); - expect(body.entityID).to.eql(entityID); - expect(body.nextEvent).to.eql(null); - }); - - it('returns no values when there is no more data', async () => { - const { body }: { body: ResolverRelatedEvents } = await supertest - // after is set to the document id of the last event so there shouldn't be any more after it - .get( - `/api/endpoint/resolver/${entityID}/events?legacyEndpointID=${endpointID}&afterEvent=${cursor}` - ) - .expect(200); - expect(body.events).be.empty(); - expect(body.entityID).to.eql(entityID); - expect(body.nextEvent).to.eql(null); - }); - - it('should return the first page of information when the cursor is invalid', async () => { - const { body }: { body: ResolverRelatedEvents } = await supertest - .get( - `/api/endpoint/resolver/${entityID}/events?legacyEndpointID=${endpointID}&afterEvent=blah` - ) - .expect(200); - expect(body.entityID).to.eql(entityID); - expect(body.nextEvent).to.eql(null); - }); - - it('should return no results for an invalid endpoint ID', async () => { - const { body }: { body: ResolverRelatedEvents } = await supertest - .get(`/api/endpoint/resolver/${entityID}/events?legacyEndpointID=foo`) - .expect(200); - expect(body.nextEvent).to.eql(null); - expect(body.entityID).to.eql(entityID); - expect(body.events).to.be.empty(); - }); - - it('should error on invalid pagination values', async () => { - await supertest.get(`/api/endpoint/resolver/${entityID}/events?events=0`).expect(400); - await supertest.get(`/api/endpoint/resolver/${entityID}/events?events=20000`).expect(400); - await supertest.get(`/api/endpoint/resolver/${entityID}/events?events=-1`).expect(400); - }); - }); - - describe('endpoint events', () => { - it('should not find any events', async () => { - const { body }: { body: ResolverRelatedEvents } = await supertest - .get(`/api/endpoint/resolver/5555/events`) - .expect(200); - expect(body.nextEvent).to.eql(null); - expect(body.events).to.be.empty(); - }); - - it('should return details for the root node', async () => { - const { body }: { body: ResolverRelatedEvents } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/events`) - .expect(200); - expect(body.events.length).to.eql(4); - compareArrays(tree.origin.relatedEvents, body.events, true); - expect(body.nextEvent).to.eql(null); - }); - - it('should return paginated results for the root node', async () => { - let { body }: { body: ResolverRelatedEvents } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/events?events=2`) - .expect(200); - expect(body.events.length).to.eql(2); - compareArrays(tree.origin.relatedEvents, body.events); - expect(body.nextEvent).not.to.eql(null); - - ({ body } = await supertest - .get( - `/api/endpoint/resolver/${tree.origin.id}/events?events=2&afterEvent=${body.nextEvent}` - ) - .expect(200)); - expect(body.events.length).to.eql(2); - compareArrays(tree.origin.relatedEvents, body.events); - expect(body.nextEvent).to.not.eql(null); - - ({ body } = await supertest - .get( - `/api/endpoint/resolver/${tree.origin.id}/events?events=2&afterEvent=${body.nextEvent}` - ) - .expect(200)); - expect(body.events).to.be.empty(); - expect(body.nextEvent).to.eql(null); - }); - - it('should return the first page of information when the cursor is invalid', async () => { - const { body }: { body: ResolverRelatedEvents } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/events?afterEvent=blah`) - .expect(200); - expect(body.events.length).to.eql(4); - compareArrays(tree.origin.relatedEvents, body.events, true); - expect(body.nextEvent).to.eql(null); - }); - }); - }); - describe('ancestry events route', () => { describe('legacy events', () => { const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; From 3541edbb5d86dc58824e7abe82a5b326b1b745a9 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Wed, 26 Aug 2020 08:30:47 -0500 Subject: [PATCH 21/56] Minor developer guide doc changes (#75763) --- docs/developer/best-practices/index.asciidoc | 2 +- docs/developer/best-practices/stability.asciidoc | 10 +++++----- .../developer/getting-started/building-kibana.asciidoc | 4 ++-- docs/developer/getting-started/index.asciidoc | 4 ++-- .../getting-started/running-kibana-advanced.asciidoc | 6 +++--- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/developer/best-practices/index.asciidoc b/docs/developer/best-practices/index.asciidoc index 63a44b54d454f..42cee6ef0e58a 100644 --- a/docs/developer/best-practices/index.asciidoc +++ b/docs/developer/best-practices/index.asciidoc @@ -48,7 +48,7 @@ guidelines] * Write all new code on {kib-repo}blob/{branch}/src/core/README.md[the platform], and following -{kib-repo}blob/{branch}/src/core/CONVENTIONS.md[conventions] +{kib-repo}blob/{branch}/src/core/CONVENTIONS.md[conventions]. * _Always_ use the `SavedObjectClient` for reading and writing Saved Objects. * Add `README`s to all your plugins and services. diff --git a/docs/developer/best-practices/stability.asciidoc b/docs/developer/best-practices/stability.asciidoc index f4b7ae1229909..348412e593d9e 100644 --- a/docs/developer/best-practices/stability.asciidoc +++ b/docs/developer/best-practices/stability.asciidoc @@ -52,15 +52,15 @@ storeinSessions?) [discrete] === Browser coverage -Refer to the list of browsers and OS {kib} supports +Refer to the list of browsers and OS {kib} supports: https://www.elastic.co/support/matrix Does the feature work efficiently on the list of supported browsers? [discrete] -=== Upgrade Scenarios - Migration scenarios- +=== Upgrade and Migration scenarios -Does the feature affect old -indices, saved objects ? - Has the feature been tested with {kib} -aliases - Read/Write privileges of the indices before and after the +* Does the feature affect old indices or saved objects? +* Has the feature been tested with {kib} aliases? +* Read/Write privileges of the indices before and after the upgrade? diff --git a/docs/developer/getting-started/building-kibana.asciidoc b/docs/developer/getting-started/building-kibana.asciidoc index 72054b1628fc2..04771b34bf69f 100644 --- a/docs/developer/getting-started/building-kibana.asciidoc +++ b/docs/developer/getting-started/building-kibana.asciidoc @@ -1,7 +1,7 @@ [[building-kibana]] == Building a {kib} distributable -The following commands will build a {kib} production distributable. +The following command will build a {kib} production distributable: [source,bash] ---- @@ -36,4 +36,4 @@ To specify a package to build you can add `rpm` or `deb` as an argument. yarn build --rpm ---- -Distributable packages can be found in `target/` after the build completes. \ No newline at end of file +Distributable packages can be found in `target/` after the build completes. diff --git a/docs/developer/getting-started/index.asciidoc b/docs/developer/getting-started/index.asciidoc index eaa35eece5a2c..10e603a8da8bb 100644 --- a/docs/developer/getting-started/index.asciidoc +++ b/docs/developer/getting-started/index.asciidoc @@ -49,7 +49,7 @@ ____ (You can also run `yarn kbn` to see the other available commands. For more info about this tool, see -{kib-repo}tree/{branch}/packages/kbn-pm[{kib-repo}tree/{branch}packages/kbn-pm].) +{kib-repo}tree/{branch}/packages/kbn-pm[{kib-repo}tree/{branch}/packages/kbn-pm].) When switching branches which use different versions of npm packages you may need to run: @@ -137,4 +137,4 @@ include::debugging.asciidoc[leveloffset=+1] include::building-kibana.asciidoc[leveloffset=+1] -include::development-plugin-resources.asciidoc[leveloffset=+1] \ No newline at end of file +include::development-plugin-resources.asciidoc[leveloffset=+1] diff --git a/docs/developer/getting-started/running-kibana-advanced.asciidoc b/docs/developer/getting-started/running-kibana-advanced.asciidoc index c3b7847b0f8ba..44897184f88f2 100644 --- a/docs/developer/getting-started/running-kibana-advanced.asciidoc +++ b/docs/developer/getting-started/running-kibana-advanced.asciidoc @@ -73,8 +73,8 @@ settings]. [discrete] === Potential Optimization Pitfalls -* Webpack is trying to include a file in the bundle that I deleted and -is now complaining about it is missing +* Webpack is trying to include a file in the bundle that was deleted and +is now complaining about it being missing * A module id that used to resolve to a single file now resolves to a directory, but webpack isn’t adapting * (if you discover other scenarios, please send a PR!) @@ -84,4 +84,4 @@ directory, but webpack isn’t adapting {kib} includes self-signed certificates that can be used for development purposes in the browser and for communicating with -{es}: `yarn start --ssl` & `yarn es snapshot --ssl`. \ No newline at end of file +{es}: `yarn start --ssl` & `yarn es snapshot --ssl`. From 4f2d4f8b018950481f1841792a3b7243152ae39b Mon Sep 17 00:00:00 2001 From: Bhavya RM Date: Wed, 26 Aug 2020 09:59:41 -0400 Subject: [PATCH 22/56] adding test user to pew pew maps test + adding a role for connections index pattern (#75920) --- x-pack/test/functional/apps/maps/es_pew_pew_source.js | 6 ++++++ x-pack/test/functional/config.js | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/x-pack/test/functional/apps/maps/es_pew_pew_source.js b/x-pack/test/functional/apps/maps/es_pew_pew_source.js index 382bde510170f..b0f98f807fd44 100644 --- a/x-pack/test/functional/apps/maps/es_pew_pew_source.js +++ b/x-pack/test/functional/apps/maps/es_pew_pew_source.js @@ -9,14 +9,20 @@ import expect from '@kbn/expect'; export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); const inspector = getService('inspector'); + const security = getService('security'); const VECTOR_SOURCE_ID = '67c1de2c-2fc5-4425-8983-094b589afe61'; describe('point to point source', () => { before(async () => { + await security.testUser.setRoles(['global_maps_all', 'geoconnections_data_reader']); await PageObjects.maps.loadSavedMap('pew pew demo'); }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should request source clusters for destination locations', async () => { await inspector.open(); await inspector.openInspectorRequestsView(); diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index fdd694e73394e..003d842cc3d6f 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -273,6 +273,17 @@ export default async function ({ readConfigFile }) { }, }, + geoconnections_data_reader: { + elasticsearch: { + indices: [ + { + names: ['connections*'], + privileges: ['read', 'view_index_metadata'], + }, + ], + }, + }, + global_devtools_read: { kibana: [ { From 4e1b1b5d9e3ab98e177c5b8c763d08918784aad7 Mon Sep 17 00:00:00 2001 From: Bhavya RM Date: Wed, 26 Aug 2020 10:02:10 -0400 Subject: [PATCH 23/56] adding test user to auto fit to bounds test (#75914) --- x-pack/test/functional/apps/maps/auto_fit_to_bounds.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/maps/auto_fit_to_bounds.js b/x-pack/test/functional/apps/maps/auto_fit_to_bounds.js index d3d4fe054ec34..0e8775fa611b5 100644 --- a/x-pack/test/functional/apps/maps/auto_fit_to_bounds.js +++ b/x-pack/test/functional/apps/maps/auto_fit_to_bounds.js @@ -6,17 +6,23 @@ import expect from '@kbn/expect'; -export default function ({ getPageObjects }) { +export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); + const security = getService('security'); describe('auto fit map to bounds', () => { describe('initial location', () => { before(async () => { + await security.testUser.setRoles(['global_maps_all', 'test_logstash_reader']); await PageObjects.maps.loadSavedMap( 'document example - auto fit to bounds for initial location' ); }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + it('should automatically fit to bounds on initial map load', async () => { const hits = await PageObjects.maps.getHits(); expect(hits).to.equal('6'); From b9c820120202dc44296e080550e87c93bd37dd55 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 26 Aug 2020 10:16:17 -0400 Subject: [PATCH 24/56] [Security Solution][Exceptions] - Improve UX for missing exception list associated with rule (#75898) ## Summary **Current behavior:** - **Scenario 1:** User is in the exceptions viewer flow, they select to edit an exception item, but the list the item is associated with has since been deleted (let's say by another user) - a user is able to open modal to edit exception item and on save, an error toaster shows but no information is given to the user to indicate the issue. - **Scenario 2:** User exports rules from space 'X' and imports into space 'Y'. The exception lists associated with their newly imported rules do not exist in space 'Y' - a user goes to add an exception item and gets a modal with an error, unable to add any exceptions. - **Workaround:** current workaround exists only via API - user would need to remove the exception list from their rule via API **New behavior:** - **Scenario 1:** User is still able to oped edit modal, but on save they see an error explaining that the associated exception list does not exist and prompts them to remove the exception list --> now they're able to add exceptions to their rule - **Scenario 2:** User navigates to exceptions after importing their rule, tries to add exception, modal pops up with error informing them that they need to remove association to missing exception list, button prompts them to do so --> now can continue adding exceptions to rule --- .../exceptions/add_exception_modal/index.tsx | 90 +++++++--- .../edit_exception_modal/index.test.tsx | 5 + .../exceptions/edit_exception_modal/index.tsx | 91 +++++++--- .../exceptions/error_callout.test.tsx | 160 +++++++++++++++++ .../components/exceptions/error_callout.tsx | 169 ++++++++++++++++++ .../components/exceptions/translations.ts | 49 +++++ .../exceptions/use_add_exception.test.tsx | 44 +++++ .../exceptions/use_add_exception.tsx | 8 +- ...tch_or_create_rule_exception_list.test.tsx | 2 +- ...se_fetch_or_create_rule_exception_list.tsx | 8 +- .../components/exceptions/viewer/index.tsx | 2 + .../use_dissasociate_exception_list.test.tsx | 52 ++++++ .../rules/use_dissasociate_exception_list.tsx | 80 +++++++++ 13 files changed, 706 insertions(+), 54 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 03051ead357c9..21f82c6ab4c98 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -18,7 +18,6 @@ import { EuiCheckbox, EuiSpacer, EuiFormRow, - EuiCallOut, EuiText, } from '@elastic/eui'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; @@ -28,6 +27,7 @@ import { ExceptionListType, } from '../../../../../public/lists_plugin_deps'; import * as i18n from './translations'; +import * as sharedI18n from '../translations'; import { TimelineNonEcsData, Ecs } from '../../../../graphql/types'; import { useAppToasts } from '../../../hooks/use_app_toasts'; import { useKibana } from '../../../lib/kibana'; @@ -35,6 +35,7 @@ import { ExceptionBuilderComponent } from '../builder'; import { Loader } from '../../loader'; import { useAddOrUpdateException } from '../use_add_exception'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; +import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; import { useFetchOrCreateRuleExceptionList } from '../use_fetch_or_create_rule_exception_list'; import { AddExceptionComments } from '../add_exception_comments'; import { @@ -46,6 +47,7 @@ import { entryHasNonEcsType, getMappedNonEcsValue, } from '../helpers'; +import { ErrorInfo, ErrorCallout } from '../error_callout'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; export interface AddExceptionModalBaseProps { @@ -107,13 +109,14 @@ export const AddExceptionModal = memo(function AddExceptionModal({ }: AddExceptionModalProps) { const { http } = useKibana().services; const [comment, setComment] = useState(''); + const { rule: maybeRule } = useRuleAsync(ruleId); const [shouldCloseAlert, setShouldCloseAlert] = useState(false); const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState< Array >([]); - const [fetchOrCreateListError, setFetchOrCreateListError] = useState(false); + const [fetchOrCreateListError, setFetchOrCreateListError] = useState(null); const { addError, addSuccess } = useAppToasts(); const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); const [ @@ -164,17 +167,41 @@ export const AddExceptionModal = memo(function AddExceptionModal({ }, [onRuleChange] ); - const onFetchOrCreateExceptionListError = useCallback( - (error: Error) => { - setFetchOrCreateListError(true); + + const handleDissasociationSuccess = useCallback( + (id: string): void => { + handleRuleChange(true); + addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); + onCancel(); + }, + [handleRuleChange, addSuccess, onCancel] + ); + + const handleDissasociationError = useCallback( + (error: Error): void => { + addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); + onCancel(); + }, + [addError, onCancel] + ); + + const handleFetchOrCreateExceptionListError = useCallback( + (error: Error, statusCode: number | null, message: string | null) => { + setFetchOrCreateListError({ + reason: error.message, + code: statusCode, + details: message, + listListId: null, + }); }, [setFetchOrCreateListError] ); + const [isLoadingExceptionList, ruleExceptionList] = useFetchOrCreateRuleExceptionList({ http, ruleId, exceptionListType, - onError: onFetchOrCreateExceptionListError, + onError: handleFetchOrCreateExceptionListError, onSuccess: handleRuleChange, }); @@ -279,7 +306,9 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ]); const isSubmitButtonDisabled = useMemo( - () => fetchOrCreateListError || exceptionItemsToAdd.every((item) => item.entries.length === 0), + () => + fetchOrCreateListError != null || + exceptionItemsToAdd.every((item) => item.entries.length === 0), [fetchOrCreateListError, exceptionItemsToAdd] ); @@ -295,19 +324,27 @@ export const AddExceptionModal = memo(function AddExceptionModal({ - {fetchOrCreateListError === true && ( - -

{i18n.ADD_EXCEPTION_FETCH_ERROR}

-
+ {fetchOrCreateListError != null && ( + + + )} - {fetchOrCreateListError === false && + {fetchOrCreateListError == null && (isLoadingExceptionList || isIndexPatternLoading || isSignalIndexLoading || isSignalIndexPatternLoading) && ( )} - {fetchOrCreateListError === false && + {fetchOrCreateListError == null && !isSignalIndexLoading && !isSignalIndexPatternLoading && !isLoadingExceptionList && @@ -377,20 +414,21 @@ export const AddExceptionModal = memo(function AddExceptionModal({ )} + {fetchOrCreateListError == null && ( + + {i18n.CANCEL} - - {i18n.CANCEL} - - - {i18n.ADD_EXCEPTION} - - + + {i18n.ADD_EXCEPTION} + + + )} ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx index 6ff218ca06059..c724e6a2c711f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx @@ -77,6 +77,7 @@ describe('When the edit exception modal is opened', () => { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> void; onConfirm: () => void; + onRuleChange?: () => void; } const Modal = styled(EuiModal)` @@ -83,14 +88,18 @@ const ModalBodySection = styled.section` export const EditExceptionModal = memo(function EditExceptionModal({ ruleName, + ruleId, ruleIndices, exceptionItem, exceptionListType, onCancel, onConfirm, + onRuleChange, }: EditExceptionModalProps) { const { http } = useKibana().services; const [comment, setComment] = useState(''); + const { rule: maybeRule } = useRuleAsync(ruleId); + const [updateError, setUpdateError] = useState(null); const [hasVersionConflict, setHasVersionConflict] = useState(false); const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); @@ -108,18 +117,44 @@ export const EditExceptionModal = memo(function EditExceptionModal({ 'rules' ); - const onError = useCallback( - (error) => { + const handleExceptionUpdateError = useCallback( + (error: Error, statusCode: number | null, message: string | null) => { if (error.message.includes('Conflict')) { setHasVersionConflict(true); } else { - addError(error, { title: i18n.EDIT_EXCEPTION_ERROR }); - onCancel(); + setUpdateError({ + reason: error.message, + code: statusCode, + details: message, + listListId: exceptionItem.list_id, + }); } }, + [setUpdateError, setHasVersionConflict, exceptionItem.list_id] + ); + + const handleDissasociationSuccess = useCallback( + (id: string): void => { + addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); + + if (onRuleChange) { + onRuleChange(); + } + + onCancel(); + }, + [addSuccess, onCancel, onRuleChange] + ); + + const handleDissasociationError = useCallback( + (error: Error): void => { + addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); + onCancel(); + }, [addError, onCancel] ); - const onSuccess = useCallback(() => { + + const handleExceptionUpdateSuccess = useCallback((): void => { addSuccess(i18n.EDIT_EXCEPTION_SUCCESS); onConfirm(); }, [addSuccess, onConfirm]); @@ -127,8 +162,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({ const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( { http, - onSuccess, - onError, + onSuccess: handleExceptionUpdateSuccess, + onError: handleExceptionUpdateError, } ); @@ -222,11 +257,9 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {ruleName} - {(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && ( )} - {!isSignalIndexLoading && !addExceptionIsLoading && !isIndexPatternLoading && ( <> @@ -280,7 +313,18 @@ export const EditExceptionModal = memo(function EditExceptionModal({ )} - + {updateError != null && ( + + + + )} {hasVersionConflict && ( @@ -288,20 +332,21 @@ export const EditExceptionModal = memo(function EditExceptionModal({ )} + {updateError == null && ( + + {i18n.CANCEL} - - {i18n.CANCEL} - - - {i18n.EDIT_EXCEPTION_SAVE_BUTTON} - - + + {i18n.EDIT_EXCEPTION_SAVE_BUTTON} + + + )} ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx new file mode 100644 index 0000000000000..c9efa5e54dccf --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx @@ -0,0 +1,160 @@ +/* + * 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 { ThemeProvider } from 'styled-components'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { getListMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; +import { useDissasociateExceptionList } from '../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list'; +import { createKibanaCoreStartMock } from '../../mock/kibana_core'; +import { ErrorCallout } from './error_callout'; +import { savedRuleMock } from '../../../detections/containers/detection_engine/rules/mock'; + +jest.mock('../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list'); + +const mockKibanaHttpService = createKibanaCoreStartMock().http; + +describe('ErrorCallout', () => { + const mockDissasociate = jest.fn(); + + beforeEach(() => { + (useDissasociateExceptionList as jest.Mock).mockReturnValue([false, mockDissasociate]); + }); + + it('it renders error details', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text() + ).toEqual('Error: error reason (500)'); + expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual( + 'Error fetching exception list' + ); + }); + + it('it invokes "onCancel" when cancel button clicked', () => { + const mockOnCancel = jest.fn(); + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="errorCalloutCancelButton"]').at(0).simulate('click'); + + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it('it does not render status code if not available', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text() + ).toEqual('Error: not found'); + expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual( + 'Error fetching exception list' + ); + expect(wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').exists()).toBeFalsy(); + }); + + it('it renders specific missing exceptions list error', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text() + ).toEqual('Error: not found (404)'); + expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual( + 'The associated exception list (some_uuid) no longer exists. Please remove the missing exception list to add additional exceptions to the detection rule.' + ); + expect(wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').exists()).toBeTruthy(); + }); + + it('it dissasociates list from rule when remove exception list clicked ', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').at(0).simulate('click'); + + expect(mockDissasociate).toHaveBeenCalledWith([]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx new file mode 100644 index 0000000000000..a2419ef16df3a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx @@ -0,0 +1,169 @@ +/* + * 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, { useMemo, useEffect, useState, useCallback } from 'react'; +import { + EuiButtonEmpty, + EuiAccordion, + EuiCodeBlock, + EuiButton, + EuiCallOut, + EuiText, + EuiSpacer, +} from '@elastic/eui'; + +import { HttpSetup } from '../../../../../../../src/core/public'; +import { List } from '../../../../common/detection_engine/schemas/types/lists'; +import { Rule } from '../../../detections/containers/detection_engine/rules/types'; +import * as i18n from './translations'; +import { useDissasociateExceptionList } from '../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list'; + +export interface ErrorInfo { + reason: string | null; + code: number | null; + details: string | null; + listListId: string | null; +} + +export interface ErrorCalloutProps { + http: HttpSetup; + rule: Rule | null; + errorInfo: ErrorInfo; + onCancel: () => void; + onSuccess: (listId: string) => void; + onError: (arg: Error) => void; +} + +const ErrorCalloutComponent = ({ + http, + rule, + errorInfo, + onCancel, + onError, + onSuccess, +}: ErrorCalloutProps): JSX.Element => { + const [listToDelete, setListToDelete] = useState(null); + const [errorTitle, setErrorTitle] = useState(''); + const [errorMessage, setErrorMessage] = useState(i18n.ADD_EXCEPTION_FETCH_ERROR); + + const handleOnSuccess = useCallback((): void => { + onSuccess(listToDelete != null ? listToDelete.id : ''); + }, [onSuccess, listToDelete]); + + const [isDissasociatingList, handleDissasociateExceptionList] = useDissasociateExceptionList({ + http, + ruleRuleId: rule != null ? rule.rule_id : '', + onSuccess: handleOnSuccess, + onError, + }); + + const canDisplay404Actions = useMemo( + (): boolean => + errorInfo.code === 404 && + rule != null && + listToDelete != null && + handleDissasociateExceptionList != null, + [errorInfo.code, listToDelete, handleDissasociateExceptionList, rule] + ); + + useEffect((): void => { + // Yes, it's redundant, unfortunately typescript wasn't picking up + // that `listToDelete` is checked in canDisplay404Actions + if (canDisplay404Actions && listToDelete != null) { + setErrorMessage(i18n.ADD_EXCEPTION_FETCH_404_ERROR(listToDelete.id)); + } + + setErrorTitle(`${errorInfo.reason}${errorInfo.code != null ? ` (${errorInfo.code})` : ''}`); + }, [errorInfo.reason, errorInfo.code, listToDelete, canDisplay404Actions]); + + const handleDissasociateList = useCallback((): void => { + // Yes, it's redundant, unfortunately typescript wasn't picking up + // that `handleDissasociateExceptionList` and `list` are checked in + // canDisplay404Actions + if ( + canDisplay404Actions && + rule != null && + listToDelete != null && + handleDissasociateExceptionList != null + ) { + const exceptionLists = (rule.exceptions_list ?? []).filter( + ({ id }) => id !== listToDelete.id + ); + + handleDissasociateExceptionList(exceptionLists); + } + }, [handleDissasociateExceptionList, listToDelete, canDisplay404Actions, rule]); + + useEffect((): void => { + if (errorInfo.code === 404 && rule != null && rule.exceptions_list != null) { + const [listFound] = rule.exceptions_list.filter( + ({ id, list_id: listId }) => + (errorInfo.details != null && errorInfo.details.includes(id)) || + errorInfo.listListId === listId + ); + setListToDelete(listFound); + } + }, [rule, errorInfo.details, errorInfo.code, errorInfo.listListId]); + + return ( + + +

{errorMessage}

+
+ + {listToDelete != null && ( + +

{i18n.MODAL_ERROR_ACCORDION_TEXT}

+ + } + > + + {JSON.stringify(listToDelete)} + +
+ )} + + + {i18n.CANCEL} + + {canDisplay404Actions && ( + + {i18n.CLEAR_EXCEPTIONS_LABEL} + + )} +
+ ); +}; + +ErrorCalloutComponent.displayName = 'ErrorCalloutComponent'; + +export const ErrorCallout = React.memo(ErrorCalloutComponent); + +ErrorCallout.displayName = 'ErrorCallout'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 13e9d0df549f8..484a3d593026e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -190,3 +190,52 @@ export const TOTAL_ITEMS_FETCH_ERROR = i18n.translate( defaultMessage: 'Error getting exception item totals', } ); + +export const CLEAR_EXCEPTIONS_LABEL = i18n.translate( + 'xpack.securitySolution.exceptions.clearExceptionsLabel', + { + defaultMessage: 'Remove Exception List', + } +); + +export const ADD_EXCEPTION_FETCH_404_ERROR = (listId: string) => + i18n.translate('xpack.securitySolution.exceptions.fetch404Error', { + values: { listId }, + defaultMessage: + 'The associated exception list ({listId}) no longer exists. Please remove the missing exception list to add additional exceptions to the detection rule.', + }); + +export const ADD_EXCEPTION_FETCH_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.fetchError', + { + defaultMessage: 'Error fetching exception list', + } +); + +export const ERROR = i18n.translate('xpack.securitySolution.exceptions.errorLabel', { + defaultMessage: 'Error', +}); + +export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.cancelLabel', { + defaultMessage: 'Cancel', +}); + +export const MODAL_ERROR_ACCORDION_TEXT = i18n.translate( + 'xpack.securitySolution.exceptions.modalErrorAccordionText', + { + defaultMessage: 'Show rule reference information:', + } +); + +export const DISSASOCIATE_LIST_SUCCESS = (id: string) => + i18n.translate('xpack.securitySolution.exceptions.dissasociateListSuccessText', { + values: { id }, + defaultMessage: 'Exception list ({id}) has successfully been removed', + }); + +export const DISSASOCIATE_EXCEPTION_LIST_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.dissasociateExceptionListError', + { + defaultMessage: 'Failed to remove exception list', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx index 6611ee2385d10..46923e07d225a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx @@ -148,6 +148,50 @@ describe('useAddOrUpdateException', () => { }); }); + it('invokes "onError" if call to add exception item fails', async () => { + const mockError = new Error('error adding item'); + + addExceptionListItem = jest + .spyOn(listsApi, 'addExceptionListItem') + .mockRejectedValue(mockError); + + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(onError).toHaveBeenCalledWith(mockError, null, null); + }); + }); + + it('invokes "onError" if call to update exception item fails', async () => { + const mockError = new Error('error updating item'); + + updateExceptionListItem = jest + .spyOn(listsApi, 'updateExceptionListItem') + .mockRejectedValue(mockError); + + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(onError).toHaveBeenCalledWith(mockError, null, null); + }); + }); + describe('when alertIdToClose is not passed in', () => { it('should not update the alert status', async () => { await act(async () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx index 9d45a411b5130..be289b0e85e66 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx @@ -42,7 +42,7 @@ export type ReturnUseAddOrUpdateException = [ export interface UseAddOrUpdateExceptionProps { http: HttpStart; - onError: (arg: Error) => void; + onError: (arg: Error, code: number | null, message: string | null) => void; onSuccess: () => void; } @@ -157,7 +157,11 @@ export const useAddOrUpdateException = ({ } catch (error) { if (isSubscribed) { setIsLoading(false); - onError(error); + if (error.body != null) { + onError(error, error.body.status_code, error.body.message); + } else { + onError(error, null, null); + } } } }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx index 39d88bd8e4724..f20a58b9ffa36 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx @@ -379,7 +379,7 @@ describe('useFetchOrCreateRuleExceptionList', () => { await waitForNextUpdate(); await waitForNextUpdate(); expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith(error); + expect(onError).toHaveBeenCalledWith(error, null, null); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx index 0d367e03a799f..944631d4e9fb5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx @@ -30,7 +30,7 @@ export interface UseFetchOrCreateRuleExceptionListProps { http: HttpStart; ruleId: Rule['id']; exceptionListType: ExceptionListSchema['type']; - onError: (arg: Error) => void; + onError: (arg: Error, code: number | null, message: string | null) => void; onSuccess?: (ruleWasChanged: boolean) => void; } @@ -179,7 +179,11 @@ export const useFetchOrCreateRuleExceptionList = ({ if (isSubscribed) { setIsLoading(false); setExceptionList(null); - onError(error); + if (error.body != null) { + onError(error, error.body.status_code, error.body.message); + } else { + onError(error, null, null); + } } } } diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index 7482068454a97..c97895cdfe236 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -322,11 +322,13 @@ const ExceptionsViewerComponent = ({ exceptionListTypeToEdit != null && ( )} diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx new file mode 100644 index 0000000000000..6b1938655dc33 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.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 { act, renderHook } from '@testing-library/react-hooks'; + +import { createKibanaCoreStartMock } from '../../../../common/mock/kibana_core'; + +import * as api from './api'; +import { ruleMock } from './mock'; +import { + ReturnUseDissasociateExceptionList, + UseDissasociateExceptionListProps, + useDissasociateExceptionList, +} from './use_dissasociate_exception_list'; + +const mockKibanaHttpService = createKibanaCoreStartMock().http; + +describe('useDissasociateExceptionList', () => { + const onError = jest.fn(); + const onSuccess = jest.fn(); + + beforeEach(() => { + jest.spyOn(api, 'patchRule').mockResolvedValue(ruleMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initializes hook', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseDissasociateExceptionListProps, + ReturnUseDissasociateExceptionList + >(() => + useDissasociateExceptionList({ + http: mockKibanaHttpService, + ruleRuleId: 'rule_id', + onError, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual([false, null]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx new file mode 100644 index 0000000000000..dffba3e6e0436 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx @@ -0,0 +1,80 @@ +/* + * 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, useRef } from 'react'; + +import { HttpStart } from '../../../../../../../../src/core/public'; +import { List } from '../../../../../common/detection_engine/schemas/types/lists'; +import { patchRule } from './api'; + +type Func = (lists: List[]) => void; +export type ReturnUseDissasociateExceptionList = [boolean, Func | null]; + +export interface UseDissasociateExceptionListProps { + http: HttpStart; + ruleRuleId: string; + onError: (arg: Error) => void; + onSuccess: () => void; +} + +/** + * Hook for removing an exception list reference from a rule + * + * @param http Kibana http service + * @param ruleRuleId a rule_id (NOT id) + * @param onError error callback + * @param onSuccess success callback + * + */ +export const useDissasociateExceptionList = ({ + http, + ruleRuleId, + onError, + onSuccess, +}: UseDissasociateExceptionListProps): ReturnUseDissasociateExceptionList => { + const [isLoading, setLoading] = useState(false); + const dissasociateList = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const dissasociateListFromRule = (id: string) => async ( + exceptionLists: List[] + ): Promise => { + try { + if (isSubscribed) { + setLoading(true); + + await patchRule({ + ruleProperties: { + rule_id: id, + exceptions_list: exceptionLists, + }, + signal: abortCtrl.signal, + }); + + onSuccess(); + setLoading(false); + } + } catch (err) { + if (isSubscribed) { + setLoading(false); + onError(err); + } + } + }; + + dissasociateList.current = dissasociateListFromRule(ruleRuleId); + + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [http, ruleRuleId, onError, onSuccess]); + + return [isLoading, dissasociateList.current]; +}; From d6c45a2e70a20d552c91f3df89da1cd081077209 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Wed, 26 Aug 2020 09:01:32 -0600 Subject: [PATCH 25/56] Fixes runtime error with meta when it is missing (#75844) ## Summary Found in 7.9.0, if you post a rule with an action that has a missing "meta" then you are going to get errors in your UI that look something like: ```ts An error occurred during rule execution: message: "Cannot read property 'kibana_siem_app_url' of null" name: "Unusual Windows Remote User" id: "1cc27e7e-d7c7-4f6a-b918-8c272fc6b1a3" rule id: "1781d055-5c66-4adf-9e93-fc0fa69550c9" signals index: ".siem-signals-default" ``` This fixes the accidental referencing of the null/undefined property and adds both integration and unit tests in that area of code. If you have an action id handy you can manually test this by editing the json file of: ```ts test_cases/queries/action_without_meta.json ``` to have your action id and then posting it like so: ```ts ./post_rule.sh ./rules/test_cases/queries/action_without_meta.json ``` ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../rules_notification_alert_type.test.ts | 78 ++++++++++ .../rules_notification_alert_type.ts | 4 +- .../queries/action_without_meta.json | 42 ++++++ .../signals/signal_rule_alert_type.test.ts | 100 +++++++++++++ .../signals/signal_rule_alert_type.ts | 3 +- .../security_and_spaces/tests/add_actions.ts | 134 ++++++++++++++++++ .../security_and_spaces/tests/index.ts | 1 + .../detection_engine_api_integration/utils.ts | 43 ++++++ 8 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/queries/action_without_meta.json create mode 100644 x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_actions.ts diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts index 3eefd3e665cd6..593ada470b118 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -79,6 +79,84 @@ describe('rules_notification_alert_type', () => { ); }); + it('should resolve results_link when meta is undefined to use "/app/security"', async () => { + const ruleAlert = getResult(); + delete ruleAlert.params.meta; + alertServices.savedObjectsClient.get.mockResolvedValue({ + id: 'rule-id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + alertServices.callCluster.mockResolvedValue({ + count: 10, + }); + + await alert.executor(payload); + expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); + + const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( + 'default', + expect.objectContaining({ + results_link: + '/app/security/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:1576255233400,kind:absolute,to:1576341633400)),timeline:(linkTo:!(global),timerange:(from:1576255233400,kind:absolute,to:1576341633400)))', + }) + ); + }); + + it('should resolve results_link when meta is an empty object to use "/app/security"', async () => { + const ruleAlert = getResult(); + ruleAlert.params.meta = {}; + alertServices.savedObjectsClient.get.mockResolvedValue({ + id: 'rule-id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + alertServices.callCluster.mockResolvedValue({ + count: 10, + }); + await alert.executor(payload); + expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); + + const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( + 'default', + expect.objectContaining({ + results_link: + '/app/security/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:1576255233400,kind:absolute,to:1576341633400)),timeline:(linkTo:!(global),timerange:(from:1576255233400,kind:absolute,to:1576341633400)))', + }) + ); + }); + + it('should resolve results_link to custom kibana link when given one', async () => { + const ruleAlert = getResult(); + ruleAlert.params.meta = { + kibana_siem_app_url: 'http://localhost', + }; + alertServices.savedObjectsClient.get.mockResolvedValue({ + id: 'rule-id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + alertServices.callCluster.mockResolvedValue({ + count: 10, + }); + await alert.executor(payload); + expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); + + const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( + 'default', + expect.objectContaining({ + results_link: + 'http://localhost/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:1576255233400,kind:absolute,to:1576341633400)),timeline:(linkTo:!(global),timerange:(from:1576255233400,kind:absolute,to:1576341633400)))', + }) + ); + }); + it('should not call alertInstanceFactory if signalsCount was 0', async () => { const ruleAlert = getResult(); alertServices.savedObjectsClient.get.mockResolvedValue({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts index ab824957087fc..2eb34529d044c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts @@ -64,8 +64,8 @@ export const rulesNotificationAlertType = ({ from: fromInMs, to: toInMs, id: ruleAlertSavedObject.id, - kibanaSiemAppUrl: (ruleAlertParams.meta as { kibana_siem_app_url?: string }) - .kibana_siem_app_url, + kibanaSiemAppUrl: (ruleAlertParams.meta as { kibana_siem_app_url?: string } | undefined) + ?.kibana_siem_app_url, }); logger.info( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/queries/action_without_meta.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/queries/action_without_meta.json new file mode 100644 index 0000000000000..6569a641de3a2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/queries/action_without_meta.json @@ -0,0 +1,42 @@ +{ + "type": "query", + "index": [ + "apm-*-transaction*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*" + ], + "filters": [], + "language": "kuery", + "query": "host.name: *", + "author": [], + "false_positives": [], + "references": [], + "risk_score": 50, + "risk_score_mapping": [], + "severity": "low", + "severity_mapping": [], + "threat": [], + "name": "Host Name Test", + "description": "Host Name Test", + "tags": [], + "license": "", + "interval": "5m", + "from": "now-30s", + "to": "now", + "actions": [ + { + "group": "default", + "id": "4c42ecf2-5e9b-4ce6-8a7a-ab620fd8b169", + "params": { + "body": "{}" + }, + "action_type_id": ".webhook" + } + ], + "enabled": true, + "throttle": "rule" +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index b0c855afa8be9..b29d15f5e5c72 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -16,6 +16,7 @@ import { getListsClient, getExceptions, sortExceptionItems, + parseScheduleDates, } from './utils'; import { RuleExecutorOptions } from './types'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; @@ -227,6 +228,105 @@ describe('rules_notification_alert_type', () => { ); }); + it('should resolve results_link when meta is an empty object to use "/app/security"', async () => { + const ruleAlert = getResult(); + ruleAlert.params.meta = {}; + ruleAlert.actions = [ + { + actionTypeId: '.slack', + params: { + message: + 'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}', + }, + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + }, + ]; + + alertServices.savedObjectsClient.get.mockResolvedValue({ + id: 'rule-id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + (parseScheduleDates as jest.Mock).mockReturnValue(moment(100)); + payload.params.meta = {}; + await alert.executor(payload); + + expect(scheduleNotificationActions).toHaveBeenCalledWith( + expect.objectContaining({ + resultsLink: + '/app/security/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:100,kind:absolute,to:100)),timeline:(linkTo:!(global),timerange:(from:100,kind:absolute,to:100)))', + }) + ); + }); + + it('should resolve results_link when meta is undefined use "/app/security"', async () => { + const ruleAlert = getResult(); + delete ruleAlert.params.meta; + ruleAlert.actions = [ + { + actionTypeId: '.slack', + params: { + message: + 'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}', + }, + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + }, + ]; + + alertServices.savedObjectsClient.get.mockResolvedValue({ + id: 'rule-id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + (parseScheduleDates as jest.Mock).mockReturnValue(moment(100)); + delete payload.params.meta; + await alert.executor(payload); + + expect(scheduleNotificationActions).toHaveBeenCalledWith( + expect.objectContaining({ + resultsLink: + '/app/security/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:100,kind:absolute,to:100)),timeline:(linkTo:!(global),timerange:(from:100,kind:absolute,to:100)))', + }) + ); + }); + + it('should resolve results_link with a custom link', async () => { + const ruleAlert = getResult(); + ruleAlert.params.meta = { kibana_siem_app_url: 'http://localhost' }; + ruleAlert.actions = [ + { + actionTypeId: '.slack', + params: { + message: + 'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}', + }, + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + }, + ]; + + alertServices.savedObjectsClient.get.mockResolvedValue({ + id: 'rule-id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + (parseScheduleDates as jest.Mock).mockReturnValue(moment(100)); + payload.params.meta = { kibana_siem_app_url: 'http://localhost' }; + await alert.executor(payload); + + expect(scheduleNotificationActions).toHaveBeenCalledWith( + expect.objectContaining({ + resultsLink: + 'http://localhost/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:100,kind:absolute,to:100)),timeline:(linkTo:!(global),timerange:(from:100,kind:absolute,to:100)))', + }) + ); + }); + describe('ML rule', () => { it('should throw an error if ML plugin was not available', async () => { const ruleAlert = getMlResult(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 0e859ecef31c6..b5cbf80b084f7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -356,7 +356,8 @@ export const signalRulesAlertType = ({ from: fromInMs, to: toInMs, id: savedObject.id, - kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string }).kibana_siem_app_url, + kibanaSiemAppUrl: (meta as { kibana_siem_app_url?: string } | undefined) + ?.kibana_siem_app_url, }); logger.info( diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_actions.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_actions.ts new file mode 100644 index 0000000000000..2468851237047 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_actions.ts @@ -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 expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../plugins/security_solution/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + removeServerGeneratedProperties, + waitFor, + getWebHookAction, + getRuleWithWebHookAction, + getSimpleRuleOutputWithWebHookAction, +} from '../../utils'; +import { CreateRulesSchema } from '../../../../plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('add_actions', () => { + describe('adding actions', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should be able to create a new webhook action and attach it to a rule', async () => { + // create a new action + const { body: hookAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + + // create a rule with the action attached + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getRuleWithWebHookAction(hookAction.id)) + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql( + getSimpleRuleOutputWithWebHookAction(`${bodyToCompare?.actions?.[0].id}`) + ); + }); + + it('should be able to create a new webhook action and attach it to a rule without a meta field and run it correctly', async () => { + // create a new action + const { body: hookAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + + // create a rule with the action attached + const { body: rule } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getRuleWithWebHookAction(hookAction.id)) + .expect(200); + + // wait for Task Manager to execute the rule and its update status + await waitFor(async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_find_statuses`) + .set('kbn-xsrf', 'true') + .send({ ids: [rule.id] }) + .expect(200); + return body[rule.id].current_status?.status === 'succeeded'; + }); + + // expected result for status should be 'succeeded' + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_find_statuses`) + .set('kbn-xsrf', 'true') + .send({ ids: [rule.id] }) + .expect(200); + expect(body[rule.id].current_status.status).to.eql('succeeded'); + }); + + it('should be able to create a new webhook action and attach it to a rule with a meta field and run it correctly', async () => { + // create a new action + const { body: hookAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + + // create a rule with the action attached and a meta field + const ruleWithAction: CreateRulesSchema = { + ...getRuleWithWebHookAction(hookAction.id), + meta: {}, + }; + + const { body: rule } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(ruleWithAction) + .expect(200); + + // wait for Task Manager to execute the rule and update status + await waitFor(async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_find_statuses`) + .set('kbn-xsrf', 'true') + .send({ ids: [rule.id] }) + .expect(200); + return body[rule.id].current_status?.status === 'succeeded'; + }); + + // expected result for status should be 'succeeded' + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_find_statuses`) + .set('kbn-xsrf', 'true') + .send({ ids: [rule.id] }) + .expect(200); + expect(body[rule.id].current_status.status).to.eql('succeeded'); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts index a480e63ff4a92..779205377621d 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -11,6 +11,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { describe('detection engine api security and spaces enabled', function () { this.tags('ciGroup1'); + loadTestFile(require.resolve('./add_actions')); loadTestFile(require.resolve('./add_prepackaged_rules')); loadTestFile(require.resolve('./create_rules')); loadTestFile(require.resolve('./create_rules_bulk')); diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 604133a1c2dc7..4cbbc142edd40 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -557,6 +557,49 @@ export const getComplexRuleOutput = (ruleId = 'rule-1'): Partial => exceptions_list: [], }); +export const getWebHookAction = () => ({ + actionTypeId: '.webhook', + config: { + method: 'post', + url: 'http://localhost', + }, + secrets: { + user: 'example', + password: 'example', + }, + name: 'Some connector', +}); + +export const getRuleWithWebHookAction = (id: string): CreateRulesSchema => ({ + ...getSimpleRule(), + throttle: 'rule', + actions: [ + { + group: 'default', + id, + params: { + body: '{}', + }, + action_type_id: '.webhook', + }, + ], +}); + +export const getSimpleRuleOutputWithWebHookAction = (actionId: string): Partial => ({ + ...getSimpleRuleOutput(), + throttle: 'rule', + actions: [ + { + action_type_id: '.webhook', + group: 'default', + id: actionId, + params: { + body: '{}', + }, + }, + ], +}); + // Similar to ReactJs's waitFor from here: https://testing-library.com/docs/dom-testing-library/api-async#waitfor export const waitFor = async ( functionToTest: () => Promise, From e773f221a3814700d55284bc34bd4637cc7312bd Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Wed, 26 Aug 2020 08:41:09 -0700 Subject: [PATCH 26/56] Revert "[Security Solution][Exceptions] - Improve UX for missing exception list associated with rule (#75898)" This reverts commit b9c820120202dc44296e080550e87c93bd37dd55. --- .../exceptions/add_exception_modal/index.tsx | 90 +++------- .../edit_exception_modal/index.test.tsx | 5 - .../exceptions/edit_exception_modal/index.tsx | 91 +++------- .../exceptions/error_callout.test.tsx | 160 ----------------- .../components/exceptions/error_callout.tsx | 169 ------------------ .../components/exceptions/translations.ts | 49 ----- .../exceptions/use_add_exception.test.tsx | 44 ----- .../exceptions/use_add_exception.tsx | 8 +- ...tch_or_create_rule_exception_list.test.tsx | 2 +- ...se_fetch_or_create_rule_exception_list.tsx | 8 +- .../components/exceptions/viewer/index.tsx | 2 - .../use_dissasociate_exception_list.test.tsx | 52 ------ .../rules/use_dissasociate_exception_list.tsx | 80 --------- 13 files changed, 54 insertions(+), 706 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 21f82c6ab4c98..03051ead357c9 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -18,6 +18,7 @@ import { EuiCheckbox, EuiSpacer, EuiFormRow, + EuiCallOut, EuiText, } from '@elastic/eui'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; @@ -27,7 +28,6 @@ import { ExceptionListType, } from '../../../../../public/lists_plugin_deps'; import * as i18n from './translations'; -import * as sharedI18n from '../translations'; import { TimelineNonEcsData, Ecs } from '../../../../graphql/types'; import { useAppToasts } from '../../../hooks/use_app_toasts'; import { useKibana } from '../../../lib/kibana'; @@ -35,7 +35,6 @@ import { ExceptionBuilderComponent } from '../builder'; import { Loader } from '../../loader'; import { useAddOrUpdateException } from '../use_add_exception'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; -import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; import { useFetchOrCreateRuleExceptionList } from '../use_fetch_or_create_rule_exception_list'; import { AddExceptionComments } from '../add_exception_comments'; import { @@ -47,7 +46,6 @@ import { entryHasNonEcsType, getMappedNonEcsValue, } from '../helpers'; -import { ErrorInfo, ErrorCallout } from '../error_callout'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; export interface AddExceptionModalBaseProps { @@ -109,14 +107,13 @@ export const AddExceptionModal = memo(function AddExceptionModal({ }: AddExceptionModalProps) { const { http } = useKibana().services; const [comment, setComment] = useState(''); - const { rule: maybeRule } = useRuleAsync(ruleId); const [shouldCloseAlert, setShouldCloseAlert] = useState(false); const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState< Array >([]); - const [fetchOrCreateListError, setFetchOrCreateListError] = useState(null); + const [fetchOrCreateListError, setFetchOrCreateListError] = useState(false); const { addError, addSuccess } = useAppToasts(); const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); const [ @@ -167,41 +164,17 @@ export const AddExceptionModal = memo(function AddExceptionModal({ }, [onRuleChange] ); - - const handleDissasociationSuccess = useCallback( - (id: string): void => { - handleRuleChange(true); - addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); - onCancel(); - }, - [handleRuleChange, addSuccess, onCancel] - ); - - const handleDissasociationError = useCallback( - (error: Error): void => { - addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); - onCancel(); - }, - [addError, onCancel] - ); - - const handleFetchOrCreateExceptionListError = useCallback( - (error: Error, statusCode: number | null, message: string | null) => { - setFetchOrCreateListError({ - reason: error.message, - code: statusCode, - details: message, - listListId: null, - }); + const onFetchOrCreateExceptionListError = useCallback( + (error: Error) => { + setFetchOrCreateListError(true); }, [setFetchOrCreateListError] ); - const [isLoadingExceptionList, ruleExceptionList] = useFetchOrCreateRuleExceptionList({ http, ruleId, exceptionListType, - onError: handleFetchOrCreateExceptionListError, + onError: onFetchOrCreateExceptionListError, onSuccess: handleRuleChange, }); @@ -306,9 +279,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ]); const isSubmitButtonDisabled = useMemo( - () => - fetchOrCreateListError != null || - exceptionItemsToAdd.every((item) => item.entries.length === 0), + () => fetchOrCreateListError || exceptionItemsToAdd.every((item) => item.entries.length === 0), [fetchOrCreateListError, exceptionItemsToAdd] ); @@ -324,27 +295,19 @@ export const AddExceptionModal = memo(function AddExceptionModal({ - {fetchOrCreateListError != null && ( - - - + {fetchOrCreateListError === true && ( + +

{i18n.ADD_EXCEPTION_FETCH_ERROR}

+
)} - {fetchOrCreateListError == null && + {fetchOrCreateListError === false && (isLoadingExceptionList || isIndexPatternLoading || isSignalIndexLoading || isSignalIndexPatternLoading) && ( )} - {fetchOrCreateListError == null && + {fetchOrCreateListError === false && !isSignalIndexLoading && !isSignalIndexPatternLoading && !isLoadingExceptionList && @@ -414,21 +377,20 @@ export const AddExceptionModal = memo(function AddExceptionModal({ )} - {fetchOrCreateListError == null && ( - - {i18n.CANCEL} - - {i18n.ADD_EXCEPTION} - - - )} + + {i18n.CANCEL} + + + {i18n.ADD_EXCEPTION} + + ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx index c724e6a2c711f..6ff218ca06059 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx @@ -77,7 +77,6 @@ describe('When the edit exception modal is opened', () => { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> void; onConfirm: () => void; - onRuleChange?: () => void; } const Modal = styled(EuiModal)` @@ -88,18 +83,14 @@ const ModalBodySection = styled.section` export const EditExceptionModal = memo(function EditExceptionModal({ ruleName, - ruleId, ruleIndices, exceptionItem, exceptionListType, onCancel, onConfirm, - onRuleChange, }: EditExceptionModalProps) { const { http } = useKibana().services; const [comment, setComment] = useState(''); - const { rule: maybeRule } = useRuleAsync(ruleId); - const [updateError, setUpdateError] = useState(null); const [hasVersionConflict, setHasVersionConflict] = useState(false); const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); @@ -117,44 +108,18 @@ export const EditExceptionModal = memo(function EditExceptionModal({ 'rules' ); - const handleExceptionUpdateError = useCallback( - (error: Error, statusCode: number | null, message: string | null) => { + const onError = useCallback( + (error) => { if (error.message.includes('Conflict')) { setHasVersionConflict(true); } else { - setUpdateError({ - reason: error.message, - code: statusCode, - details: message, - listListId: exceptionItem.list_id, - }); + addError(error, { title: i18n.EDIT_EXCEPTION_ERROR }); + onCancel(); } }, - [setUpdateError, setHasVersionConflict, exceptionItem.list_id] - ); - - const handleDissasociationSuccess = useCallback( - (id: string): void => { - addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); - - if (onRuleChange) { - onRuleChange(); - } - - onCancel(); - }, - [addSuccess, onCancel, onRuleChange] - ); - - const handleDissasociationError = useCallback( - (error: Error): void => { - addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); - onCancel(); - }, [addError, onCancel] ); - - const handleExceptionUpdateSuccess = useCallback((): void => { + const onSuccess = useCallback(() => { addSuccess(i18n.EDIT_EXCEPTION_SUCCESS); onConfirm(); }, [addSuccess, onConfirm]); @@ -162,8 +127,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({ const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( { http, - onSuccess: handleExceptionUpdateSuccess, - onError: handleExceptionUpdateError, + onSuccess, + onError, } ); @@ -257,9 +222,11 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {ruleName} + {(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && ( )} + {!isSignalIndexLoading && !addExceptionIsLoading && !isIndexPatternLoading && ( <> @@ -313,18 +280,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ )} - {updateError != null && ( - - - - )} + {hasVersionConflict && ( @@ -332,21 +288,20 @@ export const EditExceptionModal = memo(function EditExceptionModal({ )} - {updateError == null && ( - - {i18n.CANCEL} - - {i18n.EDIT_EXCEPTION_SAVE_BUTTON} - - - )} + + {i18n.CANCEL} + + + {i18n.EDIT_EXCEPTION_SAVE_BUTTON} + + ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx deleted file mode 100644 index c9efa5e54dccf..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx +++ /dev/null @@ -1,160 +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 { ThemeProvider } from 'styled-components'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; - -import { getListMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; -import { useDissasociateExceptionList } from '../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list'; -import { createKibanaCoreStartMock } from '../../mock/kibana_core'; -import { ErrorCallout } from './error_callout'; -import { savedRuleMock } from '../../../detections/containers/detection_engine/rules/mock'; - -jest.mock('../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list'); - -const mockKibanaHttpService = createKibanaCoreStartMock().http; - -describe('ErrorCallout', () => { - const mockDissasociate = jest.fn(); - - beforeEach(() => { - (useDissasociateExceptionList as jest.Mock).mockReturnValue([false, mockDissasociate]); - }); - - it('it renders error details', () => { - const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - - ); - - expect( - wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text() - ).toEqual('Error: error reason (500)'); - expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual( - 'Error fetching exception list' - ); - }); - - it('it invokes "onCancel" when cancel button clicked', () => { - const mockOnCancel = jest.fn(); - const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - - ); - - wrapper.find('[data-test-subj="errorCalloutCancelButton"]').at(0).simulate('click'); - - expect(mockOnCancel).toHaveBeenCalled(); - }); - - it('it does not render status code if not available', () => { - const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - - ); - - expect( - wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text() - ).toEqual('Error: not found'); - expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual( - 'Error fetching exception list' - ); - expect(wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').exists()).toBeFalsy(); - }); - - it('it renders specific missing exceptions list error', () => { - const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - - ); - - expect( - wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text() - ).toEqual('Error: not found (404)'); - expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual( - 'The associated exception list (some_uuid) no longer exists. Please remove the missing exception list to add additional exceptions to the detection rule.' - ); - expect(wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').exists()).toBeTruthy(); - }); - - it('it dissasociates list from rule when remove exception list clicked ', () => { - const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - - ); - - wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').at(0).simulate('click'); - - expect(mockDissasociate).toHaveBeenCalledWith([]); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx deleted file mode 100644 index a2419ef16df3a..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx +++ /dev/null @@ -1,169 +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, { useMemo, useEffect, useState, useCallback } from 'react'; -import { - EuiButtonEmpty, - EuiAccordion, - EuiCodeBlock, - EuiButton, - EuiCallOut, - EuiText, - EuiSpacer, -} from '@elastic/eui'; - -import { HttpSetup } from '../../../../../../../src/core/public'; -import { List } from '../../../../common/detection_engine/schemas/types/lists'; -import { Rule } from '../../../detections/containers/detection_engine/rules/types'; -import * as i18n from './translations'; -import { useDissasociateExceptionList } from '../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list'; - -export interface ErrorInfo { - reason: string | null; - code: number | null; - details: string | null; - listListId: string | null; -} - -export interface ErrorCalloutProps { - http: HttpSetup; - rule: Rule | null; - errorInfo: ErrorInfo; - onCancel: () => void; - onSuccess: (listId: string) => void; - onError: (arg: Error) => void; -} - -const ErrorCalloutComponent = ({ - http, - rule, - errorInfo, - onCancel, - onError, - onSuccess, -}: ErrorCalloutProps): JSX.Element => { - const [listToDelete, setListToDelete] = useState(null); - const [errorTitle, setErrorTitle] = useState(''); - const [errorMessage, setErrorMessage] = useState(i18n.ADD_EXCEPTION_FETCH_ERROR); - - const handleOnSuccess = useCallback((): void => { - onSuccess(listToDelete != null ? listToDelete.id : ''); - }, [onSuccess, listToDelete]); - - const [isDissasociatingList, handleDissasociateExceptionList] = useDissasociateExceptionList({ - http, - ruleRuleId: rule != null ? rule.rule_id : '', - onSuccess: handleOnSuccess, - onError, - }); - - const canDisplay404Actions = useMemo( - (): boolean => - errorInfo.code === 404 && - rule != null && - listToDelete != null && - handleDissasociateExceptionList != null, - [errorInfo.code, listToDelete, handleDissasociateExceptionList, rule] - ); - - useEffect((): void => { - // Yes, it's redundant, unfortunately typescript wasn't picking up - // that `listToDelete` is checked in canDisplay404Actions - if (canDisplay404Actions && listToDelete != null) { - setErrorMessage(i18n.ADD_EXCEPTION_FETCH_404_ERROR(listToDelete.id)); - } - - setErrorTitle(`${errorInfo.reason}${errorInfo.code != null ? ` (${errorInfo.code})` : ''}`); - }, [errorInfo.reason, errorInfo.code, listToDelete, canDisplay404Actions]); - - const handleDissasociateList = useCallback((): void => { - // Yes, it's redundant, unfortunately typescript wasn't picking up - // that `handleDissasociateExceptionList` and `list` are checked in - // canDisplay404Actions - if ( - canDisplay404Actions && - rule != null && - listToDelete != null && - handleDissasociateExceptionList != null - ) { - const exceptionLists = (rule.exceptions_list ?? []).filter( - ({ id }) => id !== listToDelete.id - ); - - handleDissasociateExceptionList(exceptionLists); - } - }, [handleDissasociateExceptionList, listToDelete, canDisplay404Actions, rule]); - - useEffect((): void => { - if (errorInfo.code === 404 && rule != null && rule.exceptions_list != null) { - const [listFound] = rule.exceptions_list.filter( - ({ id, list_id: listId }) => - (errorInfo.details != null && errorInfo.details.includes(id)) || - errorInfo.listListId === listId - ); - setListToDelete(listFound); - } - }, [rule, errorInfo.details, errorInfo.code, errorInfo.listListId]); - - return ( - - -

{errorMessage}

-
- - {listToDelete != null && ( - -

{i18n.MODAL_ERROR_ACCORDION_TEXT}

- - } - > - - {JSON.stringify(listToDelete)} - -
- )} - - - {i18n.CANCEL} - - {canDisplay404Actions && ( - - {i18n.CLEAR_EXCEPTIONS_LABEL} - - )} -
- ); -}; - -ErrorCalloutComponent.displayName = 'ErrorCalloutComponent'; - -export const ErrorCallout = React.memo(ErrorCalloutComponent); - -ErrorCallout.displayName = 'ErrorCallout'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 484a3d593026e..13e9d0df549f8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -190,52 +190,3 @@ export const TOTAL_ITEMS_FETCH_ERROR = i18n.translate( defaultMessage: 'Error getting exception item totals', } ); - -export const CLEAR_EXCEPTIONS_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.clearExceptionsLabel', - { - defaultMessage: 'Remove Exception List', - } -); - -export const ADD_EXCEPTION_FETCH_404_ERROR = (listId: string) => - i18n.translate('xpack.securitySolution.exceptions.fetch404Error', { - values: { listId }, - defaultMessage: - 'The associated exception list ({listId}) no longer exists. Please remove the missing exception list to add additional exceptions to the detection rule.', - }); - -export const ADD_EXCEPTION_FETCH_ERROR = i18n.translate( - 'xpack.securitySolution.exceptions.fetchError', - { - defaultMessage: 'Error fetching exception list', - } -); - -export const ERROR = i18n.translate('xpack.securitySolution.exceptions.errorLabel', { - defaultMessage: 'Error', -}); - -export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.cancelLabel', { - defaultMessage: 'Cancel', -}); - -export const MODAL_ERROR_ACCORDION_TEXT = i18n.translate( - 'xpack.securitySolution.exceptions.modalErrorAccordionText', - { - defaultMessage: 'Show rule reference information:', - } -); - -export const DISSASOCIATE_LIST_SUCCESS = (id: string) => - i18n.translate('xpack.securitySolution.exceptions.dissasociateListSuccessText', { - values: { id }, - defaultMessage: 'Exception list ({id}) has successfully been removed', - }); - -export const DISSASOCIATE_EXCEPTION_LIST_ERROR = i18n.translate( - 'xpack.securitySolution.exceptions.dissasociateExceptionListError', - { - defaultMessage: 'Failed to remove exception list', - } -); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx index 46923e07d225a..6611ee2385d10 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx @@ -148,50 +148,6 @@ describe('useAddOrUpdateException', () => { }); }); - it('invokes "onError" if call to add exception item fails', async () => { - const mockError = new Error('error adding item'); - - addExceptionListItem = jest - .spyOn(listsApi, 'addExceptionListItem') - .mockRejectedValue(mockError); - - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(onError).toHaveBeenCalledWith(mockError, null, null); - }); - }); - - it('invokes "onError" if call to update exception item fails', async () => { - const mockError = new Error('error updating item'); - - updateExceptionListItem = jest - .spyOn(listsApi, 'updateExceptionListItem') - .mockRejectedValue(mockError); - - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(onError).toHaveBeenCalledWith(mockError, null, null); - }); - }); - describe('when alertIdToClose is not passed in', () => { it('should not update the alert status', async () => { await act(async () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx index be289b0e85e66..9d45a411b5130 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx @@ -42,7 +42,7 @@ export type ReturnUseAddOrUpdateException = [ export interface UseAddOrUpdateExceptionProps { http: HttpStart; - onError: (arg: Error, code: number | null, message: string | null) => void; + onError: (arg: Error) => void; onSuccess: () => void; } @@ -157,11 +157,7 @@ export const useAddOrUpdateException = ({ } catch (error) { if (isSubscribed) { setIsLoading(false); - if (error.body != null) { - onError(error, error.body.status_code, error.body.message); - } else { - onError(error, null, null); - } + onError(error); } } }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx index f20a58b9ffa36..39d88bd8e4724 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx @@ -379,7 +379,7 @@ describe('useFetchOrCreateRuleExceptionList', () => { await waitForNextUpdate(); await waitForNextUpdate(); expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith(error, null, null); + expect(onError).toHaveBeenCalledWith(error); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx index 944631d4e9fb5..0d367e03a799f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx @@ -30,7 +30,7 @@ export interface UseFetchOrCreateRuleExceptionListProps { http: HttpStart; ruleId: Rule['id']; exceptionListType: ExceptionListSchema['type']; - onError: (arg: Error, code: number | null, message: string | null) => void; + onError: (arg: Error) => void; onSuccess?: (ruleWasChanged: boolean) => void; } @@ -179,11 +179,7 @@ export const useFetchOrCreateRuleExceptionList = ({ if (isSubscribed) { setIsLoading(false); setExceptionList(null); - if (error.body != null) { - onError(error, error.body.status_code, error.body.message); - } else { - onError(error, null, null); - } + onError(error); } } } diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index c97895cdfe236..7482068454a97 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -322,13 +322,11 @@ const ExceptionsViewerComponent = ({ exceptionListTypeToEdit != null && ( )} diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx deleted file mode 100644 index 6b1938655dc33..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.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 { act, renderHook } from '@testing-library/react-hooks'; - -import { createKibanaCoreStartMock } from '../../../../common/mock/kibana_core'; - -import * as api from './api'; -import { ruleMock } from './mock'; -import { - ReturnUseDissasociateExceptionList, - UseDissasociateExceptionListProps, - useDissasociateExceptionList, -} from './use_dissasociate_exception_list'; - -const mockKibanaHttpService = createKibanaCoreStartMock().http; - -describe('useDissasociateExceptionList', () => { - const onError = jest.fn(); - const onSuccess = jest.fn(); - - beforeEach(() => { - jest.spyOn(api, 'patchRule').mockResolvedValue(ruleMock); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('initializes hook', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - UseDissasociateExceptionListProps, - ReturnUseDissasociateExceptionList - >(() => - useDissasociateExceptionList({ - http: mockKibanaHttpService, - ruleRuleId: 'rule_id', - onError, - onSuccess, - }) - ); - - await waitForNextUpdate(); - - expect(result.current).toEqual([false, null]); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx deleted file mode 100644 index dffba3e6e0436..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx +++ /dev/null @@ -1,80 +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 { useEffect, useState, useRef } from 'react'; - -import { HttpStart } from '../../../../../../../../src/core/public'; -import { List } from '../../../../../common/detection_engine/schemas/types/lists'; -import { patchRule } from './api'; - -type Func = (lists: List[]) => void; -export type ReturnUseDissasociateExceptionList = [boolean, Func | null]; - -export interface UseDissasociateExceptionListProps { - http: HttpStart; - ruleRuleId: string; - onError: (arg: Error) => void; - onSuccess: () => void; -} - -/** - * Hook for removing an exception list reference from a rule - * - * @param http Kibana http service - * @param ruleRuleId a rule_id (NOT id) - * @param onError error callback - * @param onSuccess success callback - * - */ -export const useDissasociateExceptionList = ({ - http, - ruleRuleId, - onError, - onSuccess, -}: UseDissasociateExceptionListProps): ReturnUseDissasociateExceptionList => { - const [isLoading, setLoading] = useState(false); - const dissasociateList = useRef(null); - - useEffect(() => { - let isSubscribed = true; - const abortCtrl = new AbortController(); - - const dissasociateListFromRule = (id: string) => async ( - exceptionLists: List[] - ): Promise => { - try { - if (isSubscribed) { - setLoading(true); - - await patchRule({ - ruleProperties: { - rule_id: id, - exceptions_list: exceptionLists, - }, - signal: abortCtrl.signal, - }); - - onSuccess(); - setLoading(false); - } - } catch (err) { - if (isSubscribed) { - setLoading(false); - onError(err); - } - } - }; - - dissasociateList.current = dissasociateListFromRule(ruleRuleId); - - return (): void => { - isSubscribed = false; - abortCtrl.abort(); - }; - }, [http, ruleRuleId, onError, onSuccess]); - - return [isLoading, dissasociateList.current]; -}; From 5a9d227eee1b53673c6445c00746a0846bb69e48 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Wed, 26 Aug 2020 08:03:12 -0700 Subject: [PATCH 27/56] Downloads Chrome 84 and adds to PATH Signed-off-by: Tyler Smalley --- src/dev/ci_setup/setup_env.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/dev/ci_setup/setup_env.sh b/src/dev/ci_setup/setup_env.sh index 72ec73ad810e6..a82ca011b8a5d 100644 --- a/src/dev/ci_setup/setup_env.sh +++ b/src/dev/ci_setup/setup_env.sh @@ -10,6 +10,7 @@ installNode=$1 dir="$(pwd)" cacheDir="$HOME/.kibana" +downloads="$cacheDir/downloads" RED='\033[0;31m' C_RESET='\033[0m' # Reset color @@ -133,6 +134,26 @@ export CYPRESS_DOWNLOAD_MIRROR="https://us-central1-elastic-kibana-184716.cloudf export CHECKS_REPORTER_ACTIVE=false +### +### Download Chrome and install to this shell +### + +# Available using the version information search at https://omahaproxy.appspot.com/ +chromeVersion=84 + +mkdir -p "$downloads" + +if [ -d $cacheDir/chrome-$chromeVersion/chrome-linux ]; then + echo " -- Chrome already downloaded and extracted" +else + mkdir -p "$cacheDir/chrome-$chromeVersion" + + echo " -- Downloading and extracting Chrome" + curl -o "$downloads/chrome.zip" -L "https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/chrome_$chromeVersion.zip" + unzip -o "$downloads/chrome.zip" -d "$cacheDir/chrome-$chromeVersion" + export PATH="$cacheDir/chrome-$chromeVersion/chrome-linux:$PATH" +fi + # This is mainly for release-manager builds, which run in an environment that doesn't have Chrome installed if [[ "$(which google-chrome-stable)" || "$(which google-chrome)" ]]; then echo "Chrome detected, setting DETECT_CHROMEDRIVER_VERSION=true" From 1ca76514933463220e32f4b246c5ba14a553d9a9 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 26 Aug 2020 09:28:22 -0700 Subject: [PATCH 28/56] Revert "Downloads Chrome 84 and adds to PATH" This reverts commit 5a9d227eee1b53673c6445c00746a0846bb69e48. --- src/dev/ci_setup/setup_env.sh | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/dev/ci_setup/setup_env.sh b/src/dev/ci_setup/setup_env.sh index a82ca011b8a5d..72ec73ad810e6 100644 --- a/src/dev/ci_setup/setup_env.sh +++ b/src/dev/ci_setup/setup_env.sh @@ -10,7 +10,6 @@ installNode=$1 dir="$(pwd)" cacheDir="$HOME/.kibana" -downloads="$cacheDir/downloads" RED='\033[0;31m' C_RESET='\033[0m' # Reset color @@ -134,26 +133,6 @@ export CYPRESS_DOWNLOAD_MIRROR="https://us-central1-elastic-kibana-184716.cloudf export CHECKS_REPORTER_ACTIVE=false -### -### Download Chrome and install to this shell -### - -# Available using the version information search at https://omahaproxy.appspot.com/ -chromeVersion=84 - -mkdir -p "$downloads" - -if [ -d $cacheDir/chrome-$chromeVersion/chrome-linux ]; then - echo " -- Chrome already downloaded and extracted" -else - mkdir -p "$cacheDir/chrome-$chromeVersion" - - echo " -- Downloading and extracting Chrome" - curl -o "$downloads/chrome.zip" -L "https://us-central1-elastic-kibana-184716.cloudfunctions.net/kibana-ci-proxy-cache/chrome_$chromeVersion.zip" - unzip -o "$downloads/chrome.zip" -d "$cacheDir/chrome-$chromeVersion" - export PATH="$cacheDir/chrome-$chromeVersion/chrome-linux:$PATH" -fi - # This is mainly for release-manager builds, which run in an environment that doesn't have Chrome installed if [[ "$(which google-chrome-stable)" || "$(which google-chrome)" ]]; then echo "Chrome detected, setting DETECT_CHROMEDRIVER_VERSION=true" From 5447565f0b6b4bca90785268e5008fa9243869ea Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 26 Aug 2020 10:55:27 -0700 Subject: [PATCH 29/56] [Ingest Manager] Return ID when default output is found (#75930) * Return ID when default output is found * Fix typing --- x-pack/plugins/ingest_manager/server/services/output.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/ingest_manager/server/services/output.ts b/x-pack/plugins/ingest_manager/server/services/output.ts index b4af231024370..1e5632719fb72 100644 --- a/x-pack/plugins/ingest_manager/server/services/output.ts +++ b/x-pack/plugins/ingest_manager/server/services/output.ts @@ -15,7 +15,7 @@ let cachedAdminUser: null | { username: string; password: string } = null; class OutputService { public async getDefaultOutput(soClient: SavedObjectsClientContract) { - return await soClient.find({ + return await soClient.find({ type: OUTPUT_SAVED_OBJECT_TYPE, searchFields: ['is_default'], search: 'true', @@ -42,6 +42,7 @@ class OutputService { } return { + id: outputs.saved_objects[0].id, ...outputs.saved_objects[0].attributes, }; } From 61550b7ce0ada786e6962caf5b8c91e37dd4cf31 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Wed, 26 Aug 2020 20:08:39 +0100 Subject: [PATCH 30/56] [ML] Adding authorization header to DFA job update request (#75899) --- x-pack/plugins/ml/server/routes/data_frame_analytics.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts index 3b964588bef19..75d48056cf458 100644 --- a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts @@ -496,6 +496,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat const results = await legacyClient.callAsInternalUser('ml.updateDataFrameAnalytics', { body: request.body, analyticsId, + ...getAuthorizationHeader(request), }); return response.ok({ body: results, From eee139295d1d6edff2666be5af855e9370db16e7 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Wed, 26 Aug 2020 21:40:03 +0200 Subject: [PATCH 31/56] Migrate data folder creation from legacy to KP (#75527) * rename uuid service to environment service * adapt resolve_uuid to directly use the configurations * move data folder creation to core * update generated doc * fix types * fix monitoring tests * move instanceUuid to plugin initializer context * update generated doc --- .../kibana-plugin-core-server.coresetup.md | 1 - ...ibana-plugin-core-server.coresetup.uuid.md | 13 -- .../core/server/kibana-plugin-core-server.md | 1 - ...ore-server.plugininitializercontext.env.md | 1 + ...in-core-server.plugininitializercontext.md | 2 +- ...server.uuidservicesetup.getinstanceuuid.md | 17 --- ...ana-plugin-core-server.uuidservicesetup.md | 20 --- .../environment/create_data_folder.test.ts | 79 ++++++++++++ .../server/environment/create_data_folder.ts | 40 ++++++ .../environment_service.mock.ts} | 12 +- .../environment_service.test.ts} | 55 +++++++- .../environment_service.ts} | 28 ++-- src/core/server/{uuid => environment}/fs.ts | 1 + .../server/{uuid => environment}/index.ts | 2 +- .../resolve_uuid.test.ts | 67 +++++----- .../{uuid => environment}/resolve_uuid.ts | 18 +-- src/core/server/index.ts | 4 - src/core/server/internal_types.ts | 4 +- src/core/server/legacy/legacy_service.test.ts | 10 +- src/core/server/legacy/legacy_service.ts | 5 +- src/core/server/mocks.ts | 6 +- .../discovery/plugins_discovery.test.ts | 70 +++++++--- .../plugins/discovery/plugins_discovery.ts | 24 +++- .../integration_tests/plugins_service.test.ts | 4 +- src/core/server/plugins/plugin.test.ts | 122 +++++++++++++++--- .../server/plugins/plugin_context.test.ts | 26 +++- src/core/server/plugins/plugin_context.ts | 11 +- .../server/plugins/plugins_service.test.ts | 39 +++--- src/core/server/plugins/plugins_service.ts | 12 +- src/core/server/plugins/types.ts | 1 + src/core/server/server.api.md | 8 +- src/core/server/server.test.mocks.ts | 8 +- src/core/server/server.ts | 15 ++- src/legacy/core_plugins/kibana/index.js | 17 --- x-pack/plugins/event_log/server/plugin.ts | 2 +- .../plugins/monitoring/server/plugin.test.ts | 3 - x-pack/plugins/monitoring/server/plugin.ts | 4 +- .../plugins/reporting/server/config/config.ts | 2 +- x-pack/plugins/task_manager/server/plugin.ts | 2 +- 39 files changed, 504 insertions(+), 252 deletions(-) delete mode 100644 docs/development/core/server/kibana-plugin-core-server.coresetup.uuid.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.getinstanceuuid.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.md create mode 100644 src/core/server/environment/create_data_folder.test.ts create mode 100644 src/core/server/environment/create_data_folder.ts rename src/core/server/{uuid/uuid_service.mock.ts => environment/environment_service.mock.ts} (74%) rename src/core/server/{uuid/uuid_service.test.ts => environment/environment_service.test.ts} (52%) rename src/core/server/{uuid/uuid_service.ts => environment/environment_service.ts} (65%) rename src/core/server/{uuid => environment}/fs.ts (95%) rename src/core/server/{uuid => environment}/index.ts (89%) rename src/core/server/{uuid => environment}/resolve_uuid.test.ts (81%) rename src/core/server/{uuid => environment}/resolve_uuid.ts (88%) 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 597bb9bc2376a..ccc73d4fb858e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.md @@ -26,5 +26,4 @@ export interface CoreSetupSavedObjectsServiceSetup | [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) | | [status](./kibana-plugin-core-server.coresetup.status.md) | StatusServiceSetup | [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.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) | diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.uuid.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.uuid.md deleted file mode 100644 index c709c74497bd0..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.uuid.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreSetup](./kibana-plugin-core-server.coresetup.md) > [uuid](./kibana-plugin-core-server.coresetup.uuid.md) - -## CoreSetup.uuid property - -[UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) - -Signature: - -```typescript -uuid: UuidServiceSetup; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 98d7b0610abea..e9bc19e9c92a9 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -214,7 +214,6 @@ The plugin integrates with the core system via lifecycle events: `setup` | [UiSettingsServiceStart](./kibana-plugin-core-server.uisettingsservicestart.md) | | | [URLMeaningfulParts](./kibana-plugin-core-server.urlmeaningfulparts.md) | We define our own typings because the current version of @types/node declares properties to be optional "hostname?: string". Although, parse call returns "hostname: null \| string". | | [UserProvidedValues](./kibana-plugin-core-server.userprovidedvalues.md) | Describes the values explicitly set by user. | -| [UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) | APIs to access the application's instance uuid. | ## Variables diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.env.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.env.md index 4d111c8f20887..76e4f222f0228 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.env.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.env.md @@ -10,5 +10,6 @@ env: { mode: EnvironmentMode; packageInfo: Readonly; + instanceUuid: string; }; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md index 0d7fcf3b10bca..18760170afa1f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md @@ -17,7 +17,7 @@ export interface PluginInitializerContext | Property | Type | Description | | --- | --- | --- | | [config](./kibana-plugin-core-server.plugininitializercontext.config.md) | {
legacy: {
globalConfig$: Observable<SharedGlobalConfig>;
};
create: <T = ConfigSchema>() => Observable<T>;
createIfExists: <T = ConfigSchema>() => Observable<T | undefined>;
} | | -| [env](./kibana-plugin-core-server.plugininitializercontext.env.md) | {
mode: EnvironmentMode;
packageInfo: Readonly<PackageInfo>;
} | | +| [env](./kibana-plugin-core-server.plugininitializercontext.env.md) | {
mode: EnvironmentMode;
packageInfo: Readonly<PackageInfo>;
instanceUuid: string;
} | | | [logger](./kibana-plugin-core-server.plugininitializercontext.logger.md) | LoggerFactory | | | [opaqueId](./kibana-plugin-core-server.plugininitializercontext.opaqueid.md) | PluginOpaqueId | | diff --git a/docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.getinstanceuuid.md b/docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.getinstanceuuid.md deleted file mode 100644 index f33176a32954d..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.getinstanceuuid.md +++ /dev/null @@ -1,17 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) > [getInstanceUuid](./kibana-plugin-core-server.uuidservicesetup.getinstanceuuid.md) - -## UuidServiceSetup.getInstanceUuid() method - -Retrieve the Kibana instance uuid. - -Signature: - -```typescript -getInstanceUuid(): string; -``` -Returns: - -`string` - diff --git a/docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.md deleted file mode 100644 index 99ce4cb08af47..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.uuidservicesetup.md +++ /dev/null @@ -1,20 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) - -## UuidServiceSetup interface - -APIs to access the application's instance uuid. - -Signature: - -```typescript -export interface UuidServiceSetup -``` - -## Methods - -| Method | Description | -| --- | --- | -| [getInstanceUuid()](./kibana-plugin-core-server.uuidservicesetup.getinstanceuuid.md) | Retrieve the Kibana instance uuid. | - diff --git a/src/core/server/environment/create_data_folder.test.ts b/src/core/server/environment/create_data_folder.test.ts new file mode 100644 index 0000000000000..2a480a7a3954f --- /dev/null +++ b/src/core/server/environment/create_data_folder.test.ts @@ -0,0 +1,79 @@ +/* + * 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 { PathConfigType } from '../path'; +import { createDataFolder } from './create_data_folder'; +import { mkdir } from './fs'; +import { loggingSystemMock } from '../logging/logging_system.mock'; + +jest.mock('./fs', () => ({ + mkdir: jest.fn(() => Promise.resolve('')), +})); + +const mkdirMock = mkdir as jest.Mock; + +describe('createDataFolder', () => { + let logger: ReturnType; + let pathConfig: PathConfigType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + pathConfig = { + data: '/path/to/data/folder', + }; + mkdirMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('calls `mkdir` with the correct parameters', async () => { + await createDataFolder({ pathConfig, logger }); + expect(mkdirMock).toHaveBeenCalledTimes(1); + expect(mkdirMock).toHaveBeenCalledWith(pathConfig.data, { recursive: true }); + }); + + it('does not log error if the `mkdir` call is successful', async () => { + await createDataFolder({ pathConfig, logger }); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('throws an error if the `mkdir` call fails', async () => { + mkdirMock.mockRejectedValue('some-error'); + await expect(() => createDataFolder({ pathConfig, logger })).rejects.toMatchInlineSnapshot( + `"some-error"` + ); + }); + + it('logs an error message if the `mkdir` call fails', async () => { + mkdirMock.mockRejectedValue('some-error'); + try { + await createDataFolder({ pathConfig, logger }); + } catch (e) { + /* trap */ + } + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Error trying to create data folder at /path/to/data/folder: some-error", + ] + `); + }); +}); diff --git a/src/core/server/environment/create_data_folder.ts b/src/core/server/environment/create_data_folder.ts new file mode 100644 index 0000000000000..641d95cbf9411 --- /dev/null +++ b/src/core/server/environment/create_data_folder.ts @@ -0,0 +1,40 @@ +/* + * 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 { mkdir } from './fs'; +import { Logger } from '../logging'; +import { PathConfigType } from '../path'; + +export async function createDataFolder({ + pathConfig, + logger, +}: { + pathConfig: PathConfigType; + logger: Logger; +}): Promise { + const dataFolder = pathConfig.data; + try { + // Create the data directory (recursively, if the a parent dir doesn't exist). + // If it already exists, does nothing. + await mkdir(dataFolder, { recursive: true }); + } catch (e) { + logger.error(`Error trying to create data folder at ${dataFolder}: ${e}`); + throw e; + } +} diff --git a/src/core/server/uuid/uuid_service.mock.ts b/src/core/server/environment/environment_service.mock.ts similarity index 74% rename from src/core/server/uuid/uuid_service.mock.ts rename to src/core/server/environment/environment_service.mock.ts index bf40eaee20636..8bf726b4a6388 100644 --- a/src/core/server/uuid/uuid_service.mock.ts +++ b/src/core/server/environment/environment_service.mock.ts @@ -17,25 +17,25 @@ * under the License. */ -import { UuidService, UuidServiceSetup } from './uuid_service'; +import { EnvironmentService, InternalEnvironmentServiceSetup } from './environment_service'; const createSetupContractMock = () => { - const setupContract: jest.Mocked = { - getInstanceUuid: jest.fn().mockImplementation(() => 'uuid'), + const setupContract: jest.Mocked = { + instanceUuid: 'uuid', }; return setupContract; }; -type UuidServiceContract = PublicMethodsOf; +type EnvironmentServiceContract = PublicMethodsOf; const createMock = () => { - const mocked: jest.Mocked = { + const mocked: jest.Mocked = { setup: jest.fn(), }; mocked.setup.mockResolvedValue(createSetupContractMock()); return mocked; }; -export const uuidServiceMock = { +export const environmentServiceMock = { create: createMock, createSetupContract: createSetupContractMock, }; diff --git a/src/core/server/uuid/uuid_service.test.ts b/src/core/server/environment/environment_service.test.ts similarity index 52% rename from src/core/server/uuid/uuid_service.test.ts rename to src/core/server/environment/environment_service.test.ts index 3b1087d72c677..06fd250ebe4f9 100644 --- a/src/core/server/uuid/uuid_service.test.ts +++ b/src/core/server/environment/environment_service.test.ts @@ -17,10 +17,13 @@ * under the License. */ -import { UuidService } from './uuid_service'; +import { BehaviorSubject } from 'rxjs'; +import { EnvironmentService } from './environment_service'; import { resolveInstanceUuid } from './resolve_uuid'; +import { createDataFolder } from './create_data_folder'; import { CoreContext } from '../core_context'; +import { configServiceMock } from '../config/config_service.mock'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { mockCoreContext } from '../core_context.mock'; @@ -28,31 +31,69 @@ jest.mock('./resolve_uuid', () => ({ resolveInstanceUuid: jest.fn().mockResolvedValue('SOME_UUID'), })); +jest.mock('./create_data_folder', () => ({ + createDataFolder: jest.fn(), +})); + +const pathConfig = { + data: 'data-folder', +}; +const serverConfig = { + uuid: 'SOME_UUID', +}; + +const getConfigService = () => { + const configService = configServiceMock.create(); + configService.atPath.mockImplementation((path) => { + if (path === 'path') { + return new BehaviorSubject(pathConfig); + } + if (path === 'server') { + return new BehaviorSubject(serverConfig); + } + return new BehaviorSubject({}); + }); + return configService; +}; + describe('UuidService', () => { let logger: ReturnType; + let configService: ReturnType; let coreContext: CoreContext; beforeEach(() => { jest.clearAllMocks(); logger = loggingSystemMock.create(); - coreContext = mockCoreContext.create({ logger }); + configService = getConfigService(); + coreContext = mockCoreContext.create({ logger, configService }); }); describe('#setup()', () => { - it('calls resolveInstanceUuid with core configuration service', async () => { - const service = new UuidService(coreContext); + it('calls resolveInstanceUuid with correct parameters', async () => { + const service = new EnvironmentService(coreContext); await service.setup(); expect(resolveInstanceUuid).toHaveBeenCalledTimes(1); expect(resolveInstanceUuid).toHaveBeenCalledWith({ - configService: coreContext.configService, + pathConfig, + serverConfig, + logger: logger.get('uuid'), + }); + }); + + it('calls createDataFolder with correct parameters', async () => { + const service = new EnvironmentService(coreContext); + await service.setup(); + expect(createDataFolder).toHaveBeenCalledTimes(1); + expect(createDataFolder).toHaveBeenCalledWith({ + pathConfig, logger: logger.get('uuid'), }); }); it('returns the uuid resolved from resolveInstanceUuid', async () => { - const service = new UuidService(coreContext); + const service = new EnvironmentService(coreContext); const setup = await service.setup(); - expect(setup.getInstanceUuid()).toEqual('SOME_UUID'); + expect(setup.instanceUuid).toEqual('SOME_UUID'); }); }); }); diff --git a/src/core/server/uuid/uuid_service.ts b/src/core/server/environment/environment_service.ts similarity index 65% rename from src/core/server/uuid/uuid_service.ts rename to src/core/server/environment/environment_service.ts index d7c1b3331c447..6a0b1122c7053 100644 --- a/src/core/server/uuid/uuid_service.ts +++ b/src/core/server/environment/environment_service.ts @@ -17,25 +17,27 @@ * under the License. */ -import { resolveInstanceUuid } from './resolve_uuid'; +import { take } from 'rxjs/operators'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; import { IConfigService } from '../config'; +import { PathConfigType, config as pathConfigDef } from '../path'; +import { HttpConfigType, config as httpConfigDef } from '../http'; +import { resolveInstanceUuid } from './resolve_uuid'; +import { createDataFolder } from './create_data_folder'; /** - * APIs to access the application's instance uuid. - * - * @public + * @internal */ -export interface UuidServiceSetup { +export interface InternalEnvironmentServiceSetup { /** * Retrieve the Kibana instance uuid. */ - getInstanceUuid(): string; + instanceUuid: string; } /** @internal */ -export class UuidService { +export class EnvironmentService { private readonly log: Logger; private readonly configService: IConfigService; private uuid: string = ''; @@ -46,13 +48,21 @@ export class UuidService { } public async setup() { + const [pathConfig, serverConfig] = await Promise.all([ + this.configService.atPath(pathConfigDef.path).pipe(take(1)).toPromise(), + this.configService.atPath(httpConfigDef.path).pipe(take(1)).toPromise(), + ]); + + await createDataFolder({ pathConfig, logger: this.log }); + this.uuid = await resolveInstanceUuid({ - configService: this.configService, + pathConfig, + serverConfig, logger: this.log, }); return { - getInstanceUuid: () => this.uuid, + instanceUuid: this.uuid, }; } } diff --git a/src/core/server/uuid/fs.ts b/src/core/server/environment/fs.ts similarity index 95% rename from src/core/server/uuid/fs.ts rename to src/core/server/environment/fs.ts index f10d6370c09d1..dc040ccb73615 100644 --- a/src/core/server/uuid/fs.ts +++ b/src/core/server/environment/fs.ts @@ -22,3 +22,4 @@ import { promisify } from 'util'; export const readFile = promisify(Fs.readFile); export const writeFile = promisify(Fs.writeFile); +export const mkdir = promisify(Fs.mkdir); diff --git a/src/core/server/uuid/index.ts b/src/core/server/environment/index.ts similarity index 89% rename from src/core/server/uuid/index.ts rename to src/core/server/environment/index.ts index ad57041a124c9..57a26d5ea3c79 100644 --- a/src/core/server/uuid/index.ts +++ b/src/core/server/environment/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { UuidService, UuidServiceSetup } from './uuid_service'; +export { EnvironmentService, InternalEnvironmentServiceSetup } from './environment_service'; diff --git a/src/core/server/uuid/resolve_uuid.test.ts b/src/core/server/environment/resolve_uuid.test.ts similarity index 81% rename from src/core/server/uuid/resolve_uuid.test.ts rename to src/core/server/environment/resolve_uuid.test.ts index 3132f639e536f..d162c9d8e364b 100644 --- a/src/core/server/uuid/resolve_uuid.test.ts +++ b/src/core/server/environment/resolve_uuid.test.ts @@ -18,12 +18,11 @@ */ import { join } from 'path'; +import { loggingSystemMock } from '../logging/logging_system.mock'; import { readFile, writeFile } from './fs'; import { resolveInstanceUuid, UUID_7_6_0_BUG } from './resolve_uuid'; -import { configServiceMock } from '../config/config_service.mock'; -import { loggingSystemMock } from '../logging/logging_system.mock'; -import { BehaviorSubject } from 'rxjs'; -import { Logger } from '../logging'; +import { PathConfigType } from '../path'; +import { HttpConfigType } from '../http'; jest.mock('uuid', () => ({ v4: () => 'NEW_UUID', @@ -66,40 +65,34 @@ const mockWriteFile = (error?: object) => { }); }; -const getConfigService = (serverUuid: string | undefined) => { - const configService = configServiceMock.create(); - configService.atPath.mockImplementation((path) => { - if (path === 'path') { - return new BehaviorSubject({ - data: 'data-folder', - }); - } - if (path === 'server') { - return new BehaviorSubject({ - uuid: serverUuid, - }); - } - return new BehaviorSubject({}); - }); - return configService; +const createServerConfig = (serverUuid: string | undefined) => { + return { + uuid: serverUuid, + } as HttpConfigType; }; describe('resolveInstanceUuid', () => { - let configService: ReturnType; - let logger: jest.Mocked; + let logger: ReturnType; + let pathConfig: PathConfigType; + let serverConfig: HttpConfigType; beforeEach(() => { jest.clearAllMocks(); mockReadFile({ uuid: DEFAULT_FILE_UUID }); mockWriteFile(); - configService = getConfigService(DEFAULT_CONFIG_UUID); - logger = loggingSystemMock.create().get() as any; + + pathConfig = { + data: 'data-folder', + }; + serverConfig = createServerConfig(DEFAULT_CONFIG_UUID); + + logger = loggingSystemMock.createLogger(); }); describe('when file is present and config property is set', () => { describe('when they mismatch', () => { it('writes to file and returns the config uuid', async () => { - const uuid = await resolveInstanceUuid({ configService, logger }); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).toEqual(DEFAULT_CONFIG_UUID); expect(writeFile).toHaveBeenCalledWith( join('data-folder', 'uuid'), @@ -118,7 +111,7 @@ describe('resolveInstanceUuid', () => { describe('when they match', () => { it('does not write to file', async () => { mockReadFile({ uuid: DEFAULT_CONFIG_UUID }); - const uuid = await resolveInstanceUuid({ configService, logger }); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).toEqual(DEFAULT_CONFIG_UUID); expect(writeFile).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(1); @@ -134,7 +127,7 @@ describe('resolveInstanceUuid', () => { describe('when file is not present and config property is set', () => { it('writes the uuid to file and returns the config uuid', async () => { mockReadFile({ error: fileNotFoundError }); - const uuid = await resolveInstanceUuid({ configService, logger }); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).toEqual(DEFAULT_CONFIG_UUID); expect(writeFile).toHaveBeenCalledWith( join('data-folder', 'uuid'), @@ -152,8 +145,8 @@ describe('resolveInstanceUuid', () => { describe('when file is present and config property is not set', () => { it('does not write to file and returns the file uuid', async () => { - configService = getConfigService(undefined); - const uuid = await resolveInstanceUuid({ configService, logger }); + serverConfig = createServerConfig(undefined); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).toEqual(DEFAULT_FILE_UUID); expect(writeFile).not.toHaveBeenCalled(); expect(logger.debug).toHaveBeenCalledTimes(1); @@ -169,8 +162,8 @@ describe('resolveInstanceUuid', () => { describe('when config property is not set', () => { it('writes new uuid to file and returns new uuid', async () => { mockReadFile({ uuid: UUID_7_6_0_BUG }); - configService = getConfigService(undefined); - const uuid = await resolveInstanceUuid({ configService, logger }); + serverConfig = createServerConfig(undefined); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).not.toEqual(UUID_7_6_0_BUG); expect(uuid).toEqual('NEW_UUID'); expect(writeFile).toHaveBeenCalledWith( @@ -195,8 +188,8 @@ describe('resolveInstanceUuid', () => { describe('when config property is set', () => { it('writes config uuid to file and returns config uuid', async () => { mockReadFile({ uuid: UUID_7_6_0_BUG }); - configService = getConfigService(DEFAULT_CONFIG_UUID); - const uuid = await resolveInstanceUuid({ configService, logger }); + serverConfig = createServerConfig(DEFAULT_CONFIG_UUID); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).not.toEqual(UUID_7_6_0_BUG); expect(uuid).toEqual(DEFAULT_CONFIG_UUID); expect(writeFile).toHaveBeenCalledWith( @@ -221,9 +214,9 @@ describe('resolveInstanceUuid', () => { describe('when file is not present and config property is not set', () => { it('generates a new uuid and write it to file', async () => { - configService = getConfigService(undefined); + serverConfig = createServerConfig(undefined); mockReadFile({ error: fileNotFoundError }); - const uuid = await resolveInstanceUuid({ configService, logger }); + const uuid = await resolveInstanceUuid({ pathConfig, serverConfig, logger }); expect(uuid).toEqual('NEW_UUID'); expect(writeFile).toHaveBeenCalledWith( join('data-folder', 'uuid'), @@ -243,7 +236,7 @@ describe('resolveInstanceUuid', () => { it('throws an explicit error for file read errors', async () => { mockReadFile({ error: permissionError }); await expect( - resolveInstanceUuid({ configService, logger }) + resolveInstanceUuid({ pathConfig, serverConfig, logger }) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Unable to read Kibana UUID file, please check the uuid.server configuration value in kibana.yml and ensure Kibana has sufficient permissions to read / write to this file. Error was: EACCES"` ); @@ -251,7 +244,7 @@ describe('resolveInstanceUuid', () => { it('throws an explicit error for file write errors', async () => { mockWriteFile(isDirectoryError); await expect( - resolveInstanceUuid({ configService, logger }) + resolveInstanceUuid({ pathConfig, serverConfig, logger }) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Unable to write Kibana UUID file, please check the uuid.server configuration value in kibana.yml and ensure Kibana has sufficient permissions to read / write to this file. Error was: EISDIR"` ); diff --git a/src/core/server/uuid/resolve_uuid.ts b/src/core/server/environment/resolve_uuid.ts similarity index 88% rename from src/core/server/uuid/resolve_uuid.ts rename to src/core/server/environment/resolve_uuid.ts index 36f0eb73b1de7..0267e06939997 100644 --- a/src/core/server/uuid/resolve_uuid.ts +++ b/src/core/server/environment/resolve_uuid.ts @@ -19,11 +19,9 @@ import uuid from 'uuid'; import { join } from 'path'; -import { take } from 'rxjs/operators'; import { readFile, writeFile } from './fs'; -import { IConfigService } from '../config'; -import { PathConfigType, config as pathConfigDef } from '../path'; -import { HttpConfigType, config as httpConfigDef } from '../http'; +import { PathConfigType } from '../path'; +import { HttpConfigType } from '../http'; import { Logger } from '../logging'; const FILE_ENCODING = 'utf8'; @@ -35,19 +33,15 @@ const FILE_NAME = 'uuid'; export const UUID_7_6_0_BUG = `ce42b997-a913-4d58-be46-bb1937feedd6`; export async function resolveInstanceUuid({ - configService, + pathConfig, + serverConfig, logger, }: { - configService: IConfigService; + pathConfig: PathConfigType; + serverConfig: HttpConfigType; logger: Logger; }): Promise { - const [pathConfig, serverConfig] = await Promise.all([ - configService.atPath(pathConfigDef.path).pipe(take(1)).toPromise(), - configService.atPath(httpConfigDef.path).pipe(take(1)).toPromise(), - ]); - const uuidFilePath = join(pathConfig.data, FILE_NAME); - const uuidFromFile = await readUuidFromFile(uuidFilePath, logger); const uuidFromConfig = serverConfig.uuid; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 76bcf5f7df665..5c91d5a8c73ed 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -60,7 +60,6 @@ import { SavedObjectsServiceStart, } from './saved_objects'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; -import { UuidServiceSetup } from './uuid'; import { MetricsServiceStart } from './metrics'; import { StatusServiceSetup } from './status'; import { Auditor, AuditTrailSetup, AuditTrailStart } from './audit_trail'; @@ -432,8 +431,6 @@ export interface CoreSetup; /** {@link AuditTrailSetup} */ @@ -483,7 +480,6 @@ export { PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId, - UuidServiceSetup, AuditTrailStart, }; diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index 4f4bf50f07b8e..6780ca6b59f4d 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -32,7 +32,7 @@ import { InternalSavedObjectsServiceStart, } from './saved_objects'; import { InternalUiSettingsServiceSetup, InternalUiSettingsServiceStart } from './ui_settings'; -import { UuidServiceSetup } from './uuid'; +import { InternalEnvironmentServiceSetup } from './environment'; import { InternalMetricsServiceStart } from './metrics'; import { InternalRenderingServiceSetup } from './rendering'; import { InternalHttpResourcesSetup } from './http_resources'; @@ -49,7 +49,7 @@ export interface InternalCoreSetup { savedObjects: InternalSavedObjectsServiceSetup; status: InternalStatusServiceSetup; uiSettings: InternalUiSettingsServiceSetup; - uuid: UuidServiceSetup; + environment: InternalEnvironmentServiceSetup; rendering: InternalRenderingServiceSetup; httpResources: InternalHttpResourcesSetup; auditTrail: AuditTrailSetup; diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index f8f04c59766b3..45869fd12d2b4 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -45,7 +45,7 @@ import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service. import { capabilitiesServiceMock } from '../capabilities/capabilities_service.mock'; import { httpResourcesMock } from '../http_resources/http_resources_service.mock'; import { setupMock as renderingServiceMock } from '../rendering/__mocks__/rendering_service'; -import { uuidServiceMock } from '../uuid/uuid_service.mock'; +import { environmentServiceMock } from '../environment/environment_service.mock'; import { findLegacyPluginSpecs } from './plugins'; import { LegacyVars, LegacyServiceSetupDeps, LegacyServiceStartDeps } from './types'; import { LegacyService } from './legacy_service'; @@ -66,13 +66,13 @@ let startDeps: LegacyServiceStartDeps; const logger = loggingSystemMock.create(); let configService: ReturnType; -let uuidSetup: ReturnType; +let environmentSetup: ReturnType; beforeEach(() => { coreId = Symbol(); env = Env.createDefault(getEnvOptions()); configService = configServiceMock.create(); - uuidSetup = uuidServiceMock.createSetupContract(); + environmentSetup = environmentServiceMock.createSetupContract(); findLegacyPluginSpecsMock.mockClear(); MockKbnServer.prototype.ready = jest.fn().mockReturnValue(Promise.resolve()); @@ -97,7 +97,7 @@ beforeEach(() => { contracts: new Map([['plugin-id', 'plugin-value']]), }, rendering: renderingServiceMock, - uuid: uuidSetup, + environment: environmentSetup, status: statusServiceMock.createInternalSetupContract(), auditTrail: auditTrailServiceMock.createSetupContract(), logging: loggingServiceMock.createInternalSetupContract(), @@ -523,7 +523,7 @@ test('Sets the server.uuid property on the legacy configuration', async () => { configService: configService as any, }); - uuidSetup.getInstanceUuid.mockImplementation(() => 'UUID_FROM_SERVICE'); + environmentSetup.instanceUuid = 'UUID_FROM_SERVICE'; const configSetMock = jest.fn(); diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index f39282a6f9cb0..adfdecdd7c976 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -188,7 +188,7 @@ export class LegacyService implements CoreService { } // propagate the instance uuid to the legacy config, as it was the legacy way to access it. - this.legacyRawConfig!.set('server.uuid', setupDeps.core.uuid.getInstanceUuid()); + this.legacyRawConfig!.set('server.uuid', setupDeps.core.environment.instanceUuid); this.setupDeps = setupDeps; this.legacyInternals = new LegacyInternals( this.legacyPlugins.uiExports, @@ -327,9 +327,6 @@ export class LegacyService implements CoreService { uiSettings: { register: setupDeps.core.uiSettings.register, }, - uuid: { - getInstanceUuid: setupDeps.core.uuid.getInstanceUuid, - }, auditTrail: setupDeps.core.auditTrail, getStartServices: () => Promise.resolve([coreStart, startDeps.plugins, {}]), }; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index bf9dcc4abe01c..3c79706422cd4 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -33,7 +33,7 @@ import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { SharedGlobalConfig } from './plugins'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; import { metricsServiceMock } from './metrics/metrics_service.mock'; -import { uuidServiceMock } from './uuid/uuid_service.mock'; +import { environmentServiceMock } from './environment/environment_service.mock'; import { statusServiceMock } from './status/status_service.mock'; import { auditTrailServiceMock } from './audit_trail/audit_trail_service.mock'; @@ -94,6 +94,7 @@ function pluginInitializerContextMock(config: T = {} as T) { buildSha: 'buildSha', dist: false, }, + instanceUuid: 'instance-uuid', }, config: pluginInitializerContextConfigMock(config), }; @@ -130,7 +131,6 @@ function createCoreSetupMock({ savedObjects: savedObjectsServiceMock.createInternalSetupContract(), status: statusServiceMock.createSetupContract(), uiSettings: uiSettingsMock, - uuid: uuidServiceMock.createSetupContract(), auditTrail: auditTrailServiceMock.createSetupContract(), logging: loggingServiceMock.createSetupContract(), getStartServices: jest @@ -163,7 +163,7 @@ function createInternalCoreSetupMock() { http: httpServiceMock.createInternalSetupContract(), savedObjects: savedObjectsServiceMock.createInternalSetupContract(), status: statusServiceMock.createInternalSetupContract(), - uuid: uuidServiceMock.createSetupContract(), + environment: environmentServiceMock.createSetupContract(), httpResources: httpResourcesMock.createSetupContract(), rendering: renderingMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), diff --git a/src/core/server/plugins/discovery/plugins_discovery.test.ts b/src/core/server/plugins/discovery/plugins_discovery.test.ts index 70413757de9da..4894f19e38df4 100644 --- a/src/core/server/plugins/discovery/plugins_discovery.test.ts +++ b/src/core/server/plugins/discovery/plugins_discovery.test.ts @@ -26,6 +26,7 @@ import { resolve } from 'path'; import { ConfigService, Env } from '../../config'; import { getEnvOptions } from '../../config/__mocks__/env'; import { PluginsConfig, PluginsConfigType, config } from '../plugins_config'; +import type { InstanceInfo } from '../plugin_context'; import { discover } from './plugins_discovery'; import { rawConfigServiceMock } from '../../config/raw_config_service.mock'; import { CoreContext } from '../../core_context'; @@ -77,6 +78,7 @@ const manifestPath = (...pluginPath: string[]) => describe('plugins discovery system', () => { let logger: ReturnType; + let instanceInfo: InstanceInfo; let env: Env; let configService: ConfigService; let pluginConfig: PluginsConfigType; @@ -87,6 +89,10 @@ describe('plugins discovery system', () => { mockPackage.raw = packageMock; + instanceInfo = { + uuid: 'instance-uuid', + }; + env = Env.createDefault( getEnvOptions({ cliArgs: { envName: 'development' }, @@ -127,7 +133,7 @@ describe('plugins discovery system', () => { }); it('discovers plugins in the search locations', async () => { - const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext, instanceInfo); mockFs( { @@ -146,7 +152,11 @@ describe('plugins discovery system', () => { }); it('return errors when the manifest is invalid or incompatible', async () => { - const { plugin$, error$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$, error$ } = discover( + new PluginsConfig(pluginConfig, env), + coreContext, + instanceInfo + ); mockFs( { @@ -184,7 +194,11 @@ describe('plugins discovery system', () => { }); it('return errors when the plugin search path is not accessible', async () => { - const { plugin$, error$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$, error$ } = discover( + new PluginsConfig(pluginConfig, env), + coreContext, + instanceInfo + ); mockFs( { @@ -219,7 +233,11 @@ describe('plugins discovery system', () => { }); it('return an error when the manifest file is not accessible', async () => { - const { plugin$, error$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$, error$ } = discover( + new PluginsConfig(pluginConfig, env), + coreContext, + instanceInfo + ); mockFs( { @@ -250,7 +268,11 @@ describe('plugins discovery system', () => { }); it('discovers plugins in nested directories', async () => { - const { plugin$, error$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$, error$ } = discover( + new PluginsConfig(pluginConfig, env), + coreContext, + instanceInfo + ); mockFs( { @@ -287,7 +309,7 @@ describe('plugins discovery system', () => { }); it('does not discover plugins nested inside another plugin', async () => { - const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext, instanceInfo); mockFs( { @@ -306,7 +328,7 @@ describe('plugins discovery system', () => { }); it('stops scanning when reaching `maxDepth`', async () => { - const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext, instanceInfo); mockFs( { @@ -332,7 +354,7 @@ describe('plugins discovery system', () => { }); it('works with symlinks', async () => { - const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext); + const { plugin$ } = discover(new PluginsConfig(pluginConfig, env), coreContext, instanceInfo); const pluginFolder = resolve(KIBANA_ROOT, '..', 'ext-plugins'); @@ -365,12 +387,16 @@ describe('plugins discovery system', () => { }) ); - discover(new PluginsConfig({ ...pluginConfig, paths: [extraPluginTestPath] }, env), { - coreId: Symbol(), - configService, - env, - logger, - }); + discover( + new PluginsConfig({ ...pluginConfig, paths: [extraPluginTestPath] }, env), + { + coreId: Symbol(), + configService, + env, + logger, + }, + instanceInfo + ); expect(loggingSystemMock.collect(logger).warn).toEqual([ [ @@ -388,12 +414,16 @@ describe('plugins discovery system', () => { }) ); - discover(new PluginsConfig({ ...pluginConfig, paths: [extraPluginTestPath] }, env), { - coreId: Symbol(), - configService, - env, - logger, - }); + discover( + new PluginsConfig({ ...pluginConfig, paths: [extraPluginTestPath] }, env), + { + coreId: Symbol(), + configService, + env, + logger, + }, + instanceInfo + ); expect(loggingSystemMock.collect(logger).warn).toEqual([]); }); diff --git a/src/core/server/plugins/discovery/plugins_discovery.ts b/src/core/server/plugins/discovery/plugins_discovery.ts index 5e765a9632e55..2b5b8ad071fb5 100644 --- a/src/core/server/plugins/discovery/plugins_discovery.ts +++ b/src/core/server/plugins/discovery/plugins_discovery.ts @@ -24,7 +24,7 @@ import { catchError, filter, map, mergeMap, shareReplay } from 'rxjs/operators'; import { CoreContext } from '../../core_context'; import { Logger } from '../../logging'; import { PluginWrapper } from '../plugin'; -import { createPluginInitializerContext } from '../plugin_context'; +import { createPluginInitializerContext, InstanceInfo } from '../plugin_context'; import { PluginsConfig } from '../plugins_config'; import { PluginDiscoveryError } from './plugin_discovery_error'; import { parseManifest } from './plugin_manifest_parser'; @@ -49,7 +49,11 @@ interface PluginSearchPathEntry { * @param coreContext Kibana core values. * @internal */ -export function discover(config: PluginsConfig, coreContext: CoreContext) { +export function discover( + config: PluginsConfig, + coreContext: CoreContext, + instanceInfo: InstanceInfo +) { const log = coreContext.logger.get('plugins-discovery'); log.debug('Discovering plugins...'); @@ -65,7 +69,7 @@ export function discover(config: PluginsConfig, coreContext: CoreContext) { ).pipe( mergeMap((pluginPathOrError) => { return typeof pluginPathOrError === 'string' - ? createPlugin$(pluginPathOrError, log, coreContext) + ? createPlugin$(pluginPathOrError, log, coreContext, instanceInfo) : [pluginPathOrError]; }), shareReplay() @@ -180,7 +184,12 @@ function mapSubdirectories( * @param log Plugin discovery logger instance. * @param coreContext Kibana core context. */ -function createPlugin$(path: string, log: Logger, coreContext: CoreContext) { +function createPlugin$( + path: string, + log: Logger, + coreContext: CoreContext, + instanceInfo: InstanceInfo +) { return from(parseManifest(path, coreContext.env.packageInfo, log)).pipe( map((manifest) => { log.debug(`Successfully discovered plugin "${manifest.id}" at "${path}"`); @@ -189,7 +198,12 @@ function createPlugin$(path: string, log: Logger, coreContext: CoreContext) { path, manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); }), catchError((err) => [err]) diff --git a/src/core/server/plugins/integration_tests/plugins_service.test.ts b/src/core/server/plugins/integration_tests/plugins_service.test.ts index 49c129d0ae67d..5a216b75a83b9 100644 --- a/src/core/server/plugins/integration_tests/plugins_service.test.ts +++ b/src/core/server/plugins/integration_tests/plugins_service.test.ts @@ -28,12 +28,14 @@ import { BehaviorSubject, from } from 'rxjs'; import { rawConfigServiceMock } from '../../config/raw_config_service.mock'; import { config } from '../plugins_config'; import { loggingSystemMock } from '../../logging/logging_system.mock'; +import { environmentServiceMock } from '../../environment/environment_service.mock'; import { coreMock } from '../../mocks'; import { Plugin } from '../types'; import { PluginWrapper } from '../plugin'; describe('PluginsService', () => { const logger = loggingSystemMock.create(); + const environmentSetup = environmentServiceMock.createSetupContract(); let pluginsService: PluginsService; const createPlugin = ( @@ -158,7 +160,7 @@ describe('PluginsService', () => { } ); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); const setupDeps = coreMock.createInternalSetup(); await pluginsService.setup(setupDeps); diff --git a/src/core/server/plugins/plugin.test.ts b/src/core/server/plugins/plugin.test.ts index 4f26686e1f5e0..1108ffc248161 100644 --- a/src/core/server/plugins/plugin.test.ts +++ b/src/core/server/plugins/plugin.test.ts @@ -30,7 +30,11 @@ import { loggingSystemMock } from '../logging/logging_system.mock'; import { PluginWrapper } from './plugin'; import { PluginManifest } from './types'; -import { createPluginInitializerContext, createPluginSetupContext } from './plugin_context'; +import { + createPluginInitializerContext, + createPluginSetupContext, + InstanceInfo, +} from './plugin_context'; const mockPluginInitializer = jest.fn(); const logger = loggingSystemMock.create(); @@ -67,12 +71,16 @@ configService.atPath.mockReturnValue(new BehaviorSubject({ initialize: true })); let coreId: symbol; let env: Env; let coreContext: CoreContext; +let instanceInfo: InstanceInfo; const setupDeps = coreMock.createInternalSetup(); beforeEach(() => { coreId = Symbol('core'); env = Env.createDefault(getEnvOptions()); + instanceInfo = { + uuid: 'instance-uuid', + }; coreContext = { coreId, env, logger, configService: configService as any }; }); @@ -88,7 +96,12 @@ test('`constructor` correctly initializes plugin instance', () => { path: 'some-plugin-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); expect(plugin.name).toBe('some-plugin-id'); @@ -105,7 +118,12 @@ test('`setup` fails if `plugin` initializer is not exported', async () => { path: 'plugin-without-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); await expect( @@ -122,7 +140,12 @@ test('`setup` fails if plugin initializer is not a function', async () => { path: 'plugin-with-wrong-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); await expect( @@ -139,7 +162,12 @@ test('`setup` fails if initializer does not return object', async () => { path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); mockPluginInitializer.mockReturnValue(null); @@ -158,7 +186,12 @@ test('`setup` fails if object returned from initializer does not define `setup` path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); const mockPluginInstance = { run: jest.fn() }; @@ -174,7 +207,12 @@ test('`setup` fails if object returned from initializer does not define `setup` test('`setup` initializes plugin and calls appropriate lifecycle hook', async () => { const manifest = createPluginManifest(); const opaqueId = Symbol(); - const initializerContext = createPluginInitializerContext(coreContext, opaqueId, manifest); + const initializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); const plugin = new PluginWrapper({ path: 'plugin-with-initializer-path', manifest, @@ -203,7 +241,12 @@ test('`start` fails if setup is not called first', async () => { path: 'some-plugin-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); await expect(plugin.start({} as any, {} as any)).rejects.toThrowErrorMatchingInlineSnapshot( @@ -218,7 +261,12 @@ test('`start` calls plugin.start with context and dependencies', async () => { path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); const context = { any: 'thing' } as any; const deps = { otherDep: 'value' }; @@ -247,7 +295,12 @@ test("`start` resolves `startDependencies` Promise after plugin's start", async path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); const startContext = { any: 'thing' } as any; const pluginDeps = { someDep: 'value' }; @@ -286,7 +339,12 @@ test('`stop` fails if plugin is not set up', async () => { path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); const mockPluginInstance = { setup: jest.fn(), stop: jest.fn() }; @@ -305,7 +363,12 @@ test('`stop` does nothing if plugin does not define `stop` function', async () = path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); mockPluginInitializer.mockReturnValue({ setup: jest.fn() }); @@ -321,7 +384,12 @@ test('`stop` calls `stop` defined by the plugin instance', async () => { path: 'plugin-with-initializer-path', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); const mockPluginInstance = { setup: jest.fn(), stop: jest.fn() }; @@ -351,7 +419,12 @@ describe('#getConfigSchema()', () => { path: 'plugin-with-schema', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); expect(plugin.getConfigDescriptor()).toBe(configDescriptor); @@ -365,7 +438,12 @@ describe('#getConfigSchema()', () => { path: 'plugin-with-no-definition', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); expect(plugin.getConfigDescriptor()).toBe(null); }); @@ -377,7 +455,12 @@ describe('#getConfigSchema()', () => { path: 'plugin-with-no-definition', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); expect(plugin.getConfigDescriptor()).toBe(null); }); @@ -400,7 +483,12 @@ describe('#getConfigSchema()', () => { path: 'plugin-invalid-schema', manifest, opaqueId, - initializerContext: createPluginInitializerContext(coreContext, opaqueId, manifest), + initializerContext: createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ), }); expect(() => plugin.getConfigDescriptor()).toThrowErrorMatchingInlineSnapshot( `"Configuration schema expected to be an instance of Type"` diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index ebd068caadfb9..578c5f39d71ea 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -19,7 +19,7 @@ import { duration } from 'moment'; import { first } from 'rxjs/operators'; -import { createPluginInitializerContext } from './plugin_context'; +import { createPluginInitializerContext, InstanceInfo } from './plugin_context'; import { CoreContext } from '../core_context'; import { Env } from '../config'; import { loggingSystemMock } from '../logging/logging_system.mock'; @@ -35,6 +35,7 @@ let coreId: symbol; let env: Env; let coreContext: CoreContext; let server: Server; +let instanceInfo: InstanceInfo; function createPluginManifest(manifestProps: Partial = {}): PluginManifest { return { @@ -51,9 +52,12 @@ function createPluginManifest(manifestProps: Partial = {}): Plug }; } -describe('Plugin Context', () => { +describe('createPluginInitializerContext', () => { beforeEach(async () => { coreId = Symbol('core'); + instanceInfo = { + uuid: 'instance-uuid', + }; env = Env.createDefault(getEnvOptions()); const config$ = rawConfigServiceMock.create({ rawConfig: {} }); server = new Server(config$, env, logger); @@ -67,7 +71,8 @@ describe('Plugin Context', () => { const pluginInitializerContext = createPluginInitializerContext( coreContext, opaqueId, - manifest + manifest, + instanceInfo ); expect(pluginInitializerContext.config.legacy.globalConfig$).toBeDefined(); @@ -90,4 +95,19 @@ describe('Plugin Context', () => { path: { data: fromRoot('data') }, }); }); + + it('allow to access the provided instance uuid', () => { + const manifest = createPluginManifest(); + const opaqueId = Symbol(); + instanceInfo = { + uuid: 'kibana-uuid', + }; + const pluginInitializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); + expect(pluginInitializerContext.env.instanceUuid).toBe('kibana-uuid'); + }); }); diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 62058f6d478e7..fa2659ca130a0 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -37,6 +37,10 @@ import { import { pick, deepFreeze } from '../../utils'; import { CoreSetup, CoreStart } from '..'; +export interface InstanceInfo { + uuid: string; +} + /** * This returns a facade for `CoreContext` that will be exposed to the plugin initializer. * This facade should be safe to use across entire plugin lifespan. @@ -53,7 +57,8 @@ import { CoreSetup, CoreStart } from '..'; export function createPluginInitializerContext( coreContext: CoreContext, opaqueId: PluginOpaqueId, - pluginManifest: PluginManifest + pluginManifest: PluginManifest, + instanceInfo: InstanceInfo ): PluginInitializerContext { return { opaqueId, @@ -64,6 +69,7 @@ export function createPluginInitializerContext( env: { mode: coreContext.env.mode, packageInfo: coreContext.env.packageInfo, + instanceUuid: instanceInfo.uuid, }, /** @@ -183,9 +189,6 @@ export function createPluginSetupContext( uiSettings: { register: deps.uiSettings.register, }, - uuid: { - getInstanceUuid: deps.uuid.getInstanceUuid, - }, getStartServices: () => plugin.startDependencies, auditTrail: deps.auditTrail, }; diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index aa77335991e2c..5e613343c302f 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -29,6 +29,7 @@ import { rawConfigServiceMock } from '../config/raw_config_service.mock'; import { getEnvOptions } from '../config/__mocks__/env'; import { coreMock } from '../mocks'; import { loggingSystemMock } from '../logging/logging_system.mock'; +import { environmentServiceMock } from '../environment/environment_service.mock'; import { PluginDiscoveryError } from './discovery'; import { PluginWrapper } from './plugin'; import { PluginsService } from './plugins_service'; @@ -45,6 +46,7 @@ let configService: ConfigService; let coreId: symbol; let env: Env; let mockPluginSystem: jest.Mocked; +let environmentSetup: ReturnType; const setupDeps = coreMock.createInternalSetup(); const logger = loggingSystemMock.create(); @@ -124,6 +126,8 @@ describe('PluginsService', () => { [mockPluginSystem] = MockPluginsSystem.mock.instances as any; mockPluginSystem.uiPlugins.mockReturnValue(new Map()); + + environmentSetup = environmentServiceMock.createSetupContract(); }); afterEach(() => { @@ -137,7 +141,8 @@ describe('PluginsService', () => { plugin$: from([]), }); - await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(` + await expect(pluginsService.discover({ environment: environmentSetup })).rejects + .toMatchInlineSnapshot(` [Error: Failed to initialize plugins: Invalid JSON (invalid-manifest, path-1)] `); @@ -158,7 +163,8 @@ describe('PluginsService', () => { plugin$: from([]), }); - await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot(` + await expect(pluginsService.discover({ environment: environmentSetup })).rejects + .toMatchInlineSnapshot(` [Error: Failed to initialize plugins: Incompatible version (incompatible-version, path-3)] `); @@ -192,7 +198,9 @@ describe('PluginsService', () => { ]), }); - await expect(pluginsService.discover()).rejects.toMatchInlineSnapshot( + await expect( + pluginsService.discover({ environment: environmentSetup }) + ).rejects.toMatchInlineSnapshot( `[Error: Plugin with id "conflicting-id" is already registered!]` ); @@ -253,7 +261,7 @@ describe('PluginsService', () => { ]), }); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); const setup = await pluginsService.setup(setupDeps); expect(setup.contracts).toBeInstanceOf(Map); @@ -300,7 +308,7 @@ describe('PluginsService', () => { plugin$: from([firstPlugin, secondPlugin]), }); - const { pluginTree } = await pluginsService.discover(); + const { pluginTree } = await pluginsService.discover({ environment: environmentSetup }); expect(pluginTree).toBeUndefined(); expect(mockDiscover).toHaveBeenCalledTimes(1); @@ -336,7 +344,7 @@ describe('PluginsService', () => { plugin$: from([firstPlugin, secondPlugin, thirdPlugin, lastPlugin, missingDepsPlugin]), }); - const { pluginTree } = await pluginsService.discover(); + const { pluginTree } = await pluginsService.discover({ environment: environmentSetup }); expect(pluginTree).toBeUndefined(); expect(mockDiscover).toHaveBeenCalledTimes(1); @@ -369,7 +377,7 @@ describe('PluginsService', () => { plugin$: from([firstPlugin, secondPlugin]), }); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2); expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin); expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin); @@ -386,7 +394,8 @@ describe('PluginsService', () => { resolve(process.cwd(), '..', 'kibana-extra'), ], }, - { coreId, env, logger, configService } + { coreId, env, logger, configService }, + { uuid: 'uuid' } ); const logs = loggingSystemMock.collect(logger); @@ -417,7 +426,7 @@ describe('PluginsService', () => { }), ]), }); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); expect(configService.setSchema).toBeCalledWith('path', configSchema); }); @@ -448,7 +457,7 @@ describe('PluginsService', () => { }), ]), }); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); expect(configService.addDeprecationProvider).toBeCalledWith( 'config-path', deprecationProvider @@ -496,7 +505,7 @@ describe('PluginsService', () => { }); mockPluginSystem.uiPlugins.mockReturnValue(new Map([pluginToDiscoveredEntry(plugin)])); - const { uiPlugins } = await pluginsService.discover(); + const { uiPlugins } = await pluginsService.discover({ environment: environmentSetup }); const uiConfig$ = uiPlugins.browserConfigs.get('plugin-with-expose'); expect(uiConfig$).toBeDefined(); @@ -532,7 +541,7 @@ describe('PluginsService', () => { }); mockPluginSystem.uiPlugins.mockReturnValue(new Map([pluginToDiscoveredEntry(plugin)])); - const { uiPlugins } = await pluginsService.discover(); + const { uiPlugins } = await pluginsService.discover({ environment: environmentSetup }); expect([...uiPlugins.browserConfigs.entries()]).toHaveLength(0); }); }); @@ -561,7 +570,7 @@ describe('PluginsService', () => { describe('uiPlugins.internal', () => { it('includes disabled plugins', async () => { config$.next({ plugins: { initialize: true }, plugin1: { enabled: false } }); - const { uiPlugins } = await pluginsService.discover(); + const { uiPlugins } = await pluginsService.discover({ environment: environmentSetup }); expect(uiPlugins.internal).toMatchInlineSnapshot(` Map { "plugin-1" => Object { @@ -582,7 +591,7 @@ describe('PluginsService', () => { describe('plugin initialization', () => { it('does initialize if plugins.initialize is true', async () => { config$.next({ plugins: { initialize: true } }); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); const { initialized } = await pluginsService.setup(setupDeps); expect(mockPluginSystem.setupPlugins).toHaveBeenCalled(); expect(initialized).toBe(true); @@ -590,7 +599,7 @@ describe('PluginsService', () => { it('does not initialize if plugins.initialize is false', async () => { config$.next({ plugins: { initialize: false } }); - await pluginsService.discover(); + await pluginsService.discover({ environment: environmentSetup }); const { initialized } = await pluginsService.setup(setupDeps); expect(mockPluginSystem.setupPlugins).not.toHaveBeenCalled(); expect(initialized).toBe(false); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 06de48a215881..30cd47c9d44e1 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -32,6 +32,7 @@ import { PluginsSystem } from './plugins_system'; import { InternalCoreSetup, InternalCoreStart } from '../internal_types'; import { IConfigService } from '../config'; import { pick } from '../../utils'; +import { InternalEnvironmentServiceSetup } from '../environment'; /** @internal */ export interface PluginsServiceSetup { @@ -72,6 +73,11 @@ export type PluginsServiceSetupDeps = InternalCoreSetup; /** @internal */ export type PluginsServiceStartDeps = InternalCoreStart; +/** @internal */ +export interface PluginsServiceDiscoverDeps { + environment: InternalEnvironmentServiceSetup; +} + /** @internal */ export class PluginsService implements CoreService { private readonly log: Logger; @@ -90,12 +96,14 @@ export class PluginsService implements CoreService new PluginsConfig(rawConfig, coreContext.env))); } - public async discover() { + public async discover({ environment }: PluginsServiceDiscoverDeps) { this.log.debug('Discovering plugins'); const config = await this.config$.pipe(first()).toPromise(); - const { error$, plugin$ } = discover(config, this.coreContext); + const { error$, plugin$ } = discover(config, this.coreContext, { + uuid: environment.instanceUuid, + }); await this.handleDiscoveryErrors(error$); await this.handleDiscoveredPlugins(plugin$); diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index 9695c9171a771..eb2a9ca3daf5f 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -278,6 +278,7 @@ export interface PluginInitializerContext { env: { mode: EnvironmentMode; packageInfo: Readonly; + instanceUuid: string; }; logger: LoggerFactory; config: { diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index cd7f4973f886c..6186906bc3a42 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -499,8 +499,6 @@ export interface CoreSetup { env: { mode: EnvironmentMode; packageInfo: Readonly; + instanceUuid: string; }; // (undocumented) logger: LoggerFactory; @@ -2883,11 +2882,6 @@ export interface UserProvidedValues { userValue?: T; } -// @public -export interface UuidServiceSetup { - getInstanceUuid(): string; -} - // @public export const validBodyOutput: readonly ["data", "stream"]; diff --git a/src/core/server/server.test.mocks.ts b/src/core/server/server.test.mocks.ts index 82d0c095bfe95..471e482a20e96 100644 --- a/src/core/server/server.test.mocks.ts +++ b/src/core/server/server.test.mocks.ts @@ -74,10 +74,10 @@ import { RenderingService, mockRenderingService } from './rendering/__mocks__/re export { mockRenderingService }; jest.doMock('./rendering/rendering_service', () => ({ RenderingService })); -import { uuidServiceMock } from './uuid/uuid_service.mock'; -export const mockUuidService = uuidServiceMock.create(); -jest.doMock('./uuid/uuid_service', () => ({ - UuidService: jest.fn(() => mockUuidService), +import { environmentServiceMock } from './environment/environment_service.mock'; +export const mockEnvironmentService = environmentServiceMock.create(); +jest.doMock('./environment/environment_service', () => ({ + EnvironmentService: jest.fn(() => mockEnvironmentService), })); import { metricsServiceMock } from './metrics/metrics_service.mock'; diff --git a/src/core/server/server.ts b/src/core/server/server.ts index aff749ca97534..cc6d8171e7a03 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -31,7 +31,7 @@ import { PluginsService, config as pluginsConfig } from './plugins'; import { SavedObjectsService } from '../server/saved_objects'; import { MetricsService, opsConfig } from './metrics'; import { CapabilitiesService } from './capabilities'; -import { UuidService } from './uuid'; +import { EnvironmentService } from './environment'; import { StatusService } from './status/status_service'; import { config as cspConfig } from './csp'; @@ -64,7 +64,7 @@ export class Server { private readonly plugins: PluginsService; private readonly savedObjects: SavedObjectsService; private readonly uiSettings: UiSettingsService; - private readonly uuid: UuidService; + private readonly environment: EnvironmentService; private readonly metrics: MetricsService; private readonly httpResources: HttpResourcesService; private readonly status: StatusService; @@ -95,7 +95,7 @@ export class Server { this.savedObjects = new SavedObjectsService(core); this.uiSettings = new UiSettingsService(core); this.capabilities = new CapabilitiesService(core); - this.uuid = new UuidService(core); + this.environment = new EnvironmentService(core); this.metrics = new MetricsService(core); this.status = new StatusService(core); this.coreApp = new CoreApp(core); @@ -107,8 +107,12 @@ export class Server { public async setup() { this.log.debug('setting up server'); + const environmentSetup = await this.environment.setup(); + // Discover any plugins before continuing. This allows other systems to utilize the plugin dependency graph. - const { pluginTree, uiPlugins } = await this.plugins.discover(); + const { pluginTree, uiPlugins } = await this.plugins.discover({ + environment: environmentSetup, + }); const legacyPlugins = await this.legacy.discoverPlugins(); // Immediately terminate in case of invalid configuration @@ -124,7 +128,6 @@ export class Server { }); const auditTrailSetup = this.auditTrail.setup(); - const uuidSetup = await this.uuid.setup(); const httpSetup = await this.http.setup({ context: contextServiceSetup, @@ -174,11 +177,11 @@ export class Server { capabilities: capabilitiesSetup, context: contextServiceSetup, elasticsearch: elasticsearchServiceSetup, + environment: environmentSetup, http: httpSetup, savedObjects: savedObjectsSetup, status: statusSetup, uiSettings: uiSettingsSetup, - uuid: uuidSetup, rendering: renderingSetup, httpResources: httpResourcesSetup, auditTrail: auditTrailSetup, diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index 176c5386961a5..722d75d00f78f 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -17,13 +17,8 @@ * under the License. */ -import Fs from 'fs'; -import { promisify } from 'util'; - import { getUiSettingDefaults } from './server/ui_setting_defaults'; -const mkdirAsync = promisify(Fs.mkdir); - export default function (kibana) { return new kibana.Plugin({ id: 'kibana', @@ -40,17 +35,5 @@ export default function (kibana) { uiExports: { uiSettingDefaults: getUiSettingDefaults(), }, - - preInit: async function (server) { - try { - // Create the data directory (recursively, if the a parent dir doesn't exist). - // If it already exists, does nothing. - await mkdirAsync(server.config().get('path.data'), { recursive: true }); - } catch (err) { - server.log(['error', 'init'], err); - // Stop the server startup with a fatal error - throw err; - } - }, }); } diff --git a/x-pack/plugins/event_log/server/plugin.ts b/x-pack/plugins/event_log/server/plugin.ts index 1353877fa4629..4439a4fb9fdbb 100644 --- a/x-pack/plugins/event_log/server/plugin.ts +++ b/x-pack/plugins/event_log/server/plugin.ts @@ -85,7 +85,7 @@ export class Plugin implements CorePlugin { serverBasePath: '', }, }, - uuid: { - getInstanceUuid: jest.fn(), - }, elasticsearch: { legacy: { client: {}, diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index 043435c48a211..501c96b12fde8 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -92,7 +92,7 @@ export class Plugin { const router = core.http.createRouter(); this.legacyShimDependencies = { router, - instanceUuid: core.uuid.getInstanceUuid(), + instanceUuid: this.initializerContext.env.instanceUuid, esDataClient: core.elasticsearch.legacy.client, kibanaStatsCollector: plugins.usageCollection?.getCollectorByType( KIBANA_STATS_TYPE_MONITORING @@ -159,7 +159,7 @@ export class Plugin { config, log: kibanaMonitoringLog, kibanaStats: { - uuid: core.uuid.getInstanceUuid(), + uuid: this.initializerContext.env.instanceUuid, name: serverInfo.name, index: get(legacyConfig, 'kibana.index'), host: serverInfo.hostname, diff --git a/x-pack/plugins/reporting/server/config/config.ts b/x-pack/plugins/reporting/server/config/config.ts index ca07fd8452372..088598829a3e1 100644 --- a/x-pack/plugins/reporting/server/config/config.ts +++ b/x-pack/plugins/reporting/server/config/config.ts @@ -75,7 +75,7 @@ export const buildConfig = async ( host: serverInfo.hostname, name: serverInfo.name, port: serverInfo.port, - uuid: core.uuid.getInstanceUuid(), + uuid: initContext.env.instanceUuid, protocol: serverInfo.protocol, }, }; diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index 70295046d19f4..d7dcf779376bf 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -42,7 +42,7 @@ export class TaskManagerPlugin .toPromise(); setupSavedObjects(core.savedObjects, this.config); - this.taskManagerId = core.uuid.getInstanceUuid(); + this.taskManagerId = this.initContext.env.instanceUuid; return { addMiddleware: (middleware: Middleware) => { From 2946e68581a81c6a685a248192c8f08fec77a552 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 26 Aug 2020 15:51:47 -0400 Subject: [PATCH 32/56] [Ingest Manager] Remove useless saved object update in agent checkin (#75586) --- .../server/services/agents/checkin/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin/index.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin/index.ts index 78e6a11fa78a4..19a5c2dc08762 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/checkin/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import deepEqual from 'fast-deep-equal'; import { SavedObjectsClientContract, SavedObjectsBulkCreateObject } from 'src/core/server'; import { Agent, @@ -29,16 +30,19 @@ export async function agentCheckin( ) { const updateData: Partial = {}; const { updatedErrorEvents } = await processEventsForCheckin(soClient, agent, data.events); - if (updatedErrorEvents) { + if ( + updatedErrorEvents && + !(updatedErrorEvents.length === 0 && agent.current_error_events.length === 0) + ) { updateData.current_error_events = JSON.stringify(updatedErrorEvents); } - if (data.localMetadata) { + if (data.localMetadata && !deepEqual(data.localMetadata, agent.local_metadata)) { updateData.local_metadata = data.localMetadata; } - if (data.status !== agent.last_checkin_status) { updateData.last_checkin_status = data.status; } + // Update agent only if something changed if (Object.keys(updateData).length > 0) { await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, updateData); } From 638df5820c04d2c21311dda3198855e2ae569b64 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Wed, 26 Aug 2020 13:56:18 -0600 Subject: [PATCH 33/56] [Security Solution][Detections] Fixes Alerts Table 'Select all [x] alerts' action (#75945) ## Summary Resolves https://github.com/elastic/kibana/issues/75194 Fixes issue where the `Select all [x] alerts` feature would not select the checkboxes within the Alerts Table. Also resolves issue where bulk actions wouldn't work with Building Block Alerts. ##### Select All Before

##### Select All After

##### Building Block Query Before

##### Building Block Query After

--- .../components/alerts_table/index.tsx | 42 ++++++++++++------- .../components/manage_timeline/index.tsx | 24 +++++++++++ .../timeline/body/stateful_body.tsx | 4 +- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 66423259ec155..07e69d850f173 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -105,7 +105,6 @@ export const AlertsTableComponent: React.FC = ({ updateTimelineIsLoading, }) => { const dispatch = useDispatch(); - const [selectAll, setSelectAll] = useState(false); const apolloClient = useApolloClient(); const [showClearSelectionAction, setShowClearSelectionAction] = useState(false); @@ -120,6 +119,12 @@ export const AlertsTableComponent: React.FC = ({ ); const kibana = useKibana(); const [, dispatchToaster] = useStateToaster(); + const { + initializeTimeline, + setSelectAll, + setTimelineRowActions, + setIndexToAdd, + } = useManageTimeline(); const getGlobalQuery = useCallback( (customFilters: Filter[]) => { @@ -141,8 +146,7 @@ export const AlertsTableComponent: React.FC = ({ } return null; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [browserFields, globalFilters, globalQuery, indexPatterns, kibana, to, from] + [browserFields, defaultFilters, globalFilters, globalQuery, indexPatterns, kibana, to, from] ); // Callback for creating a new timeline -- utilized by row/batch actions @@ -240,12 +244,15 @@ export const AlertsTableComponent: React.FC = ({ // Catches state change isSelectAllChecked->false upon user selection change to reset utility bar useEffect(() => { - if (!isSelectAllChecked) { - setShowClearSelectionAction(false); + if (isSelectAllChecked) { + setSelectAll({ + id: timelineId, + selectAll: false, + }); } else { - setSelectAll(false); + setShowClearSelectionAction(false); } - }, [isSelectAllChecked]); + }, [isSelectAllChecked, setSelectAll, timelineId]); // Callback for when open/closed filter changes const onFilterGroupChangedCallback = useCallback( @@ -261,17 +268,23 @@ export const AlertsTableComponent: React.FC = ({ // Callback for clearing entire selection from utility bar const clearSelectionCallback = useCallback(() => { clearSelected!({ id: timelineId }); - setSelectAll(false); + setSelectAll({ + id: timelineId, + selectAll: false, + }); setShowClearSelectionAction(false); }, [clearSelected, setSelectAll, setShowClearSelectionAction, timelineId]); // Callback for selecting all events on all pages from utility bar // Dispatches to stateful_body's selectAll via TimelineTypeContext props // as scope of response data required to actually set selectedEvents - const selectAllCallback = useCallback(() => { - setSelectAll(true); + const selectAllOnAllPagesCallback = useCallback(() => { + setSelectAll({ + id: timelineId, + selectAll: true, + }); setShowClearSelectionAction(true); - }, [setSelectAll, setShowClearSelectionAction]); + }, [setSelectAll, setShowClearSelectionAction, timelineId]); const updateAlertsStatusCallback: UpdateAlertsStatusCallback = useCallback( async ( @@ -314,7 +327,7 @@ export const AlertsTableComponent: React.FC = ({ clearSelection={clearSelectionCallback} hasIndexWrite={hasIndexWrite} currentFilter={filterGroup} - selectAll={selectAllCallback} + selectAll={selectAllOnAllPagesCallback} selectedEventIds={selectedEventIds} showBuildingBlockAlerts={showBuildingBlockAlerts} onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChanged} @@ -332,7 +345,7 @@ export const AlertsTableComponent: React.FC = ({ showBuildingBlockAlerts, onShowBuildingBlockAlertsChanged, loadingEventIds.length, - selectAllCallback, + selectAllOnAllPagesCallback, selectedEventIds, showClearSelectionAction, updateAlertsStatusCallback, @@ -384,7 +397,6 @@ export const AlertsTableComponent: React.FC = ({ } }, [defaultFilters, filterGroup]); const { filterManager } = useKibana().services.data.query; - const { initializeTimeline, setTimelineRowActions, setIndexToAdd } = useManageTimeline(); useEffect(() => { initializeTimeline({ @@ -395,7 +407,7 @@ export const AlertsTableComponent: React.FC = ({ id: timelineId, indexToAdd: defaultIndices, loadingText: i18n.LOADING_ALERTS, - selectAll: canUserCRUD ? selectAll : false, + selectAll: false, timelineRowActions: () => [getInvestigateInResolverAction({ dispatch, timelineId })], title: '', }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx index a425f9b49add0..560d4c6928e4e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx @@ -71,6 +71,11 @@ type ActionManageTimeline = id: string; payload: string[]; } + | { + type: 'SET_SELECT_ALL'; + id: string; + payload: boolean; + } | { type: 'SET_TIMELINE_ACTIONS'; id: string; @@ -116,6 +121,14 @@ const reducerManageTimeline = ( indexToAdd: action.payload, }, } as ManageTimelineById; + case 'SET_SELECT_ALL': + return { + ...state, + [action.id]: { + ...state[action.id], + selectAll: action.payload, + }, + } as ManageTimelineById; case 'SET_TIMELINE_ACTIONS': return { ...state, @@ -145,6 +158,7 @@ export interface UseTimelineManager { isManagedTimeline: (id: string) => boolean; setIndexToAdd: (indexToAddArgs: { id: string; indexToAdd: string[] }) => void; setIsTimelineLoading: (isLoadingArgs: { id: string; isLoading: boolean }) => void; + setSelectAll: (selectAllArgs: { id: string; selectAll: boolean }) => void; setTimelineRowActions: (actionsArgs: { id: string; queryFields?: string[]; @@ -205,6 +219,14 @@ export const useTimelineManager = ( }); }, []); + const setSelectAll = useCallback(({ id, selectAll }: { id: string; selectAll: boolean }) => { + dispatch({ + type: 'SET_SELECT_ALL', + id, + payload: selectAll, + }); + }, []); + const getTimelineFilterManager = useCallback( (id: string): FilterManager | undefined => state[id]?.filterManager, [state] @@ -238,6 +260,7 @@ export const useTimelineManager = ( isManagedTimeline, setIndexToAdd, setIsTimelineLoading, + setSelectAll, setTimelineRowActions, }; }; @@ -250,6 +273,7 @@ const init = { isManagedTimeline: () => false, setIndexToAdd: () => undefined, setIsTimelineLoading: () => noop, + setSelectAll: () => noop, setTimelineRowActions: () => noop, }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx index 15fa13b1a08f1..8deda03ece70e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/stateful_body.tsx @@ -169,10 +169,10 @@ const StatefulBodyComponent = React.memo( // Sync to selectAll so parent components can select all events useEffect(() => { - if (selectAll) { + if (selectAll && !isSelectAllChecked) { onSelectAll({ isSelected: true }); } - }, [onSelectAll, selectAll]); + }, [isSelectAllChecked, onSelectAll, selectAll]); const enabledRowRenderers = useMemo(() => { if ( From 532f2d70e84af2aec4df06331643fbb6e0ba5033 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Wed, 26 Aug 2020 13:00:00 -0700 Subject: [PATCH 34/56] [Home] Elastic home page redesign (#70571) Co-authored-by: Catherine Liu Co-authored-by: Ryan Keairns Co-authored-by: Catherine Liu Co-authored-by: Michael Marcialis --- .../collapsible_nav.test.tsx.snap | 10 +- .../header/__snapshots__/header.test.tsx.snap | 6 + src/core/public/chrome/ui/header/header.tsx | 2 +- .../overlays/banners/_banners_list.scss | 2 +- src/core/utils/default_app_categories.ts | 2 +- src/plugins/advanced_settings/kibana.json | 3 +- .../management_app/advanced_settings.tsx | 15 + .../field/__snapshots__/field.test.tsx.snap | 54 + .../management_app/components/field/field.tsx | 1 + .../advanced_settings/public/plugin.ts | 18 +- src/plugins/advanced_settings/public/types.ts | 3 + src/plugins/console/kibana.json | 6 +- src/plugins/console/public/plugin.ts | 28 +- .../public/types/plugin_dependencies.ts | 2 +- src/plugins/dashboard/public/plugin.tsx | 2 +- .../discover/public/register_feature.ts | 2 +- src/plugins/home/common/constants.ts | 21 + .../home/public/application/application.tsx | 8 +- .../__snapshots__/add_data.test.js.snap | 1163 -------- .../__snapshots__/home.test.js.snap | 2360 ++++++++++------- .../__snapshots__/synopsis.test.js.snap | 12 +- .../application/components/_add_data.scss | 83 +- .../public/application/components/_home.scss | 74 +- .../public/application/components/_index.scss | 4 +- .../application/components/_manage_data.scss | 22 + .../components/_solutions_section.scss | 122 + .../application/components/_synopsis.scss | 4 + .../public/application/components/add_data.js | 320 --- .../application/components/add_data.test.js | 68 - .../__snapshots__/add_data.test.tsx.snap | 96 + .../components/add_data/add_data.test.tsx | 95 + .../components/add_data/add_data.tsx | 95 + .../application/components/add_data/index.ts | 20 + .../components/app_navigation_handler.ts | 1 + .../components/feature_directory.js | 2 + .../public/application/components/home.js | 250 +- .../application/components/home.test.js | 113 +- .../public/application/components/home_app.js | 19 +- .../__snapshots__/manage_data.test.tsx.snap | 91 + .../components/manage_data/index.tsx | 20 + .../manage_data/manage_data.test.tsx | 91 + .../components/manage_data/manage_data.tsx | 81 + .../solution_panel.test.tsx.snap | 47 + .../solution_title.test.tsx.snap | 41 + .../solutions_section.test.tsx.snap | 288 ++ .../components/solutions_section/index.ts | 20 + .../solutions_section/solution_panel.test.tsx | 43 + .../solutions_section/solution_panel.tsx | 83 + .../solutions_section/solution_title.test.tsx | 45 + .../solutions_section/solution_title.tsx | 59 + .../solutions_section.test.tsx | 94 + .../solutions_section/solutions_section.tsx | 93 + .../public/application/components/synopsis.js | 4 +- .../application/components/synopsis.test.js | 4 + .../components/tutorial_directory.js | 3 + src/plugins/home/public/index.ts | 1 + src/plugins/home/public/plugin.test.ts | 36 + src/plugins/home/public/plugin.ts | 59 +- .../feature_catalogue_registry.mock.ts | 2 + .../feature_catalogue_registry.test.ts | 20 +- .../feature_catalogue_registry.ts | 40 + .../services/feature_catalogue/index.ts | 1 + src/plugins/management/kibana.json | 5 +- src/plugins/management/public/plugin.ts | 30 +- .../saved_objects_management/kibana.json | 6 +- .../saved_objects_management/public/plugin.ts | 32 +- src/plugins/visualize/public/plugin.ts | 2 +- test/functional/page_objects/home_page.ts | 8 +- x-pack/plugins/apm/kibana.json | 7 +- .../apm/public/featureCatalogueEntry.ts | 2 +- x-pack/plugins/apm/public/plugin.ts | 8 +- x-pack/plugins/canvas/kibana.json | 6 +- .../canvas/public/feature_catalogue_entry.ts | 2 +- x-pack/plugins/canvas/public/plugin.tsx | 6 +- x-pack/plugins/enterprise_search/kibana.json | 7 +- .../enterprise_search/public/plugin.ts | 71 +- .../enterprise_search/server/plugin.ts | 3 +- x-pack/plugins/graph/public/plugin.ts | 5 +- .../index_lifecycle_management/kibana.json | 7 +- .../public/plugin.tsx | 23 +- .../public/types.ts | 2 + x-pack/plugins/infra/kibana.json | 7 +- x-pack/plugins/infra/public/plugin.ts | 4 +- .../plugins/infra/public/register_feature.ts | 4 +- x-pack/plugins/infra/public/types.ts | 2 +- x-pack/plugins/infra/server/features.ts | 12 +- x-pack/plugins/ingest_manager/kibana.json | 2 +- .../plugins/ingest_manager/public/plugin.ts | 21 +- .../plugins/ingest_manager/server/plugin.ts | 3 + x-pack/plugins/logstash/public/plugin.ts | 2 +- x-pack/plugins/maps/kibana.json | 5 +- .../maps/public/feature_catalogue_entry.ts | 4 +- x-pack/plugins/maps/public/plugin.ts | 6 +- .../plugins/ml/common/types/capabilities.ts | 2 + x-pack/plugins/ml/kibana.json | 5 +- x-pack/plugins/ml/public/plugin.ts | 6 +- x-pack/plugins/ml/public/register_feature.ts | 17 +- x-pack/plugins/ml/server/plugin.ts | 2 +- x-pack/plugins/monitoring/public/plugin.ts | 9 +- x-pack/plugins/observability/kibana.json | 3 +- x-pack/plugins/observability/public/plugin.ts | 35 +- x-pack/plugins/painless_lab/public/plugin.tsx | 2 +- x-pack/plugins/rollup/public/plugin.ts | 2 +- x-pack/plugins/security/public/plugin.tsx | 8 +- .../cypress/screens/kibana_navigation.ts | 17 +- .../security_solution/cypress/tasks/login.ts | 6 +- x-pack/plugins/security_solution/kibana.json | 4 +- .../security_solution/public/plugin.tsx | 45 +- .../plugins/security_solution/public/types.ts | 2 +- .../security_solution/server/plugin.ts | 3 +- x-pack/plugins/snapshot_restore/kibana.json | 7 +- .../plugins/snapshot_restore/public/plugin.ts | 25 +- .../public/create_feature_catalogue_entry.ts | 2 +- x-pack/plugins/spaces/public/plugin.test.ts | 2 +- .../transform/public/register_feature.ts | 2 +- .../translations/translations/ja-JP.json | 28 - .../translations/translations/zh-CN.json | 28 - x-pack/plugins/uptime/public/apps/plugin.ts | 5 +- x-pack/plugins/watcher/public/plugin.ts | 1 - x-pack/test/accessibility/apps/home.ts | 2 +- .../security_and_spaces/tests/catalogue.ts | 14 +- .../security_only/tests/catalogue.ts | 14 +- 122 files changed, 4073 insertions(+), 2903 deletions(-) create mode 100644 src/plugins/home/common/constants.ts delete mode 100644 src/plugins/home/public/application/components/__snapshots__/add_data.test.js.snap create mode 100644 src/plugins/home/public/application/components/_manage_data.scss create mode 100644 src/plugins/home/public/application/components/_solutions_section.scss delete mode 100644 src/plugins/home/public/application/components/add_data.js delete mode 100644 src/plugins/home/public/application/components/add_data.test.js create mode 100644 src/plugins/home/public/application/components/add_data/__snapshots__/add_data.test.tsx.snap create mode 100644 src/plugins/home/public/application/components/add_data/add_data.test.tsx create mode 100644 src/plugins/home/public/application/components/add_data/add_data.tsx create mode 100644 src/plugins/home/public/application/components/add_data/index.ts create mode 100644 src/plugins/home/public/application/components/manage_data/__snapshots__/manage_data.test.tsx.snap create mode 100644 src/plugins/home/public/application/components/manage_data/index.tsx create mode 100644 src/plugins/home/public/application/components/manage_data/manage_data.test.tsx create mode 100644 src/plugins/home/public/application/components/manage_data/manage_data.tsx create mode 100644 src/plugins/home/public/application/components/solutions_section/__snapshots__/solution_panel.test.tsx.snap create mode 100644 src/plugins/home/public/application/components/solutions_section/__snapshots__/solution_title.test.tsx.snap create mode 100644 src/plugins/home/public/application/components/solutions_section/__snapshots__/solutions_section.test.tsx.snap create mode 100644 src/plugins/home/public/application/components/solutions_section/index.ts create mode 100644 src/plugins/home/public/application/components/solutions_section/solution_panel.test.tsx create mode 100644 src/plugins/home/public/application/components/solutions_section/solution_panel.tsx create mode 100644 src/plugins/home/public/application/components/solutions_section/solution_title.test.tsx create mode 100644 src/plugins/home/public/application/components/solutions_section/solution_title.tsx create mode 100644 src/plugins/home/public/application/components/solutions_section/solutions_section.test.tsx create mode 100644 src/plugins/home/public/application/components/solutions_section/solutions_section.tsx diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 2cfe232bf5653..fe959e570ab98 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -147,7 +147,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "baseUrl": "/", "category": Object { "euiIconType": "logoSecurity", - "id": "security", + "id": "securitySolution", "label": "Security", "order": 4000, }, @@ -1393,11 +1393,11 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` @@ -1433,7 +1433,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
} className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-security" + data-test-subj="collapsibleNavGroup-securitySolution" id="mockId" initialIsOpen={true} onToggle={[Function]} @@ -1441,7 +1441,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` >
- + {navType === 'modern' ? ( diff --git a/src/core/public/overlays/banners/_banners_list.scss b/src/core/public/overlays/banners/_banners_list.scss index ff260f7dc42fd..9d4df065a0a4f 100644 --- a/src/core/public/overlays/banners/_banners_list.scss +++ b/src/core/public/overlays/banners/_banners_list.scss @@ -3,5 +3,5 @@ } .kbnGlobalBannerList__item + .kbnGlobalBannerList__item { - margin-top: $euiSize; + margin-top: $euiSizeS; } diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index cc9bfb1db04d5..1fb7c284c0dfd 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -46,7 +46,7 @@ export const DEFAULT_APP_CATEGORIES = Object.freeze({ order: 3000, }, security: { - id: 'security', + id: 'securitySolution', label: i18n.translate('core.ui.securityNavList.label', { defaultMessage: 'Security', }), diff --git a/src/plugins/advanced_settings/kibana.json b/src/plugins/advanced_settings/kibana.json index 8cf9b9c656d8f..0e49fe17089f0 100644 --- a/src/plugins/advanced_settings/kibana.json +++ b/src/plugins/advanced_settings/kibana.json @@ -4,5 +4,6 @@ "server": true, "ui": true, "requiredPlugins": ["management"], - "requiredBundles": ["kibanaReact"] + "optionalPlugins": ["home"], + "requiredBundles": ["kibanaReact", "home"] } diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index d8853015d362a..4afcba14abef4 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -114,6 +114,21 @@ export class AdvancedSettingsComponent extends Component< filteredSettings: this.mapSettings(Query.execute(query, this.settings)), }); }); + + // scrolls to setting provided in the URL hash + const { hash } = window.location; + if (hash !== '') { + setTimeout(() => { + const id = hash.replace('#', ''); + const element = document.getElementById(id); + const globalNavOffset = document.getElementById('headerGlobalNav')?.offsetHeight || 0; + + if (element) { + element.scrollIntoView(); + window.scrollBy(0, -globalNavOffset); // offsets scroll by height of the global nav + } + }, 0); + } } componentWillUnmount() { diff --git a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap index da18eb70e5874..2aabacb061667 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap +++ b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap @@ -15,6 +15,7 @@ exports[`Field for array setting should render as read only if saving is disable } fullWidth={true} + id="array:test:setting" title={

} fullWidth={true} + id="array:test:setting" title={

} fullWidth={true} + id="array:test:setting" title={

} fullWidth={true} + id="array:test:setting" title={

} fullWidth={true} + id="array:test:setting" title={

} fullWidth={true} + id="array:test:setting" title={

} fullWidth={true} + id="boolean:test:setting" title={

} fullWidth={true} + id="boolean:test:setting" title={

} fullWidth={true} + id="boolean:test:setting" title={

} fullWidth={true} + id="boolean:test:setting" title={

} fullWidth={true} + id="boolean:test:setting" title={

} fullWidth={true} + id="boolean:test:setting" title={

} fullWidth={true} + id="image:test:setting" title={

} fullWidth={true} + id="image:test:setting" title={

} fullWidth={true} + id="image:test:setting" title={

} fullWidth={true} + id="image:test:setting" title={

} fullWidth={true} + id="image:test:setting" title={

} fullWidth={true} + id="image:test:setting" title={

} fullWidth={true} + id="json:test:setting" title={

} fullWidth={true} + id="json:test:setting" title={

} fullWidth={true} + id="json:test:setting" title={

} fullWidth={true} + id="json:test:setting" title={

} fullWidth={true} + id="json:test:setting" title={

} fullWidth={true} + id="json:test:setting" title={

} fullWidth={true} + id="markdown:test:setting" title={

} fullWidth={true} + id="markdown:test:setting" title={

} fullWidth={true} + id="markdown:test:setting" title={

} fullWidth={true} + id="markdown:test:setting" title={

} fullWidth={true} + id="markdown:test:setting" title={

} fullWidth={true} + id="markdown:test:setting" title={

} fullWidth={true} + id="number:test:setting" title={

} fullWidth={true} + id="number:test:setting" title={

} fullWidth={true} + id="number:test:setting" title={

} fullWidth={true} + id="number:test:setting" title={

} fullWidth={true} + id="number:test:setting" title={

} fullWidth={true} + id="number:test:setting" title={

} fullWidth={true} + id="select:test:setting" title={

} fullWidth={true} + id="select:test:setting" title={

} fullWidth={true} + id="select:test:setting" title={

} fullWidth={true} + id="select:test:setting" title={

} fullWidth={true} + id="select:test:setting" title={

} fullWidth={true} + id="select:test:setting" title={

} fullWidth={true} + id="string:test:setting" title={

} fullWidth={true} + id="string:test:setting" title={

} fullWidth={true} + id="string:test:setting" title={

} fullWidth={true} + id="string:test:setting" title={

} fullWidth={true} + id="string:test:setting" title={

} fullWidth={true} + id="string:test:setting" title={

} fullWidth={true} + id="string:test-validation:setting" title={

} fullWidth={true} + id="string:test-validation:setting" title={

} fullWidth={true} + id="string:test-validation:setting" title={

} fullWidth={true} + id="string:test-validation:setting" title={

} fullWidth={true} + id="string:test-validation:setting" title={

} fullWidth={true} + id="string:test-validation:setting" title={

{ return ( { - public setup(core: CoreSetup, { management }: AdvancedSettingsPluginSetup) { + public setup(core: CoreSetup, { management, home }: AdvancedSettingsPluginSetup) { const kibanaSection = management.sections.section.kibana; kibanaSection.registerApp({ @@ -44,6 +45,21 @@ export class AdvancedSettingsPlugin }, }); + if (home) { + home.featureCatalogue.register({ + id: 'advanced_settings', + title, + description: i18n.translate('advancedSettings.featureCatalogueTitle', { + defaultMessage: + 'Customize your Kibana experience — change the date format, turn on dark mode, and more.', + }), + icon: 'gear', + path: '/app/management/kibana/settings', + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, + }); + } + return { component: component.setup, }; diff --git a/src/plugins/advanced_settings/public/types.ts b/src/plugins/advanced_settings/public/types.ts index a233b3debab8d..cc59f52b1f30f 100644 --- a/src/plugins/advanced_settings/public/types.ts +++ b/src/plugins/advanced_settings/public/types.ts @@ -18,6 +18,8 @@ */ import { ComponentRegistry } from './component_registry'; +import { HomePublicPluginSetup } from '../../home/public'; + import { ManagementSetup } from '../../management/public'; export interface AdvancedSettingsSetup { @@ -29,6 +31,7 @@ export interface AdvancedSettingsStart { export interface AdvancedSettingsPluginSetup { management: ManagementSetup; + home?: HomePublicPluginSetup; } export { ComponentRegistry }; diff --git a/src/plugins/console/kibana.json b/src/plugins/console/kibana.json index 031aa00eb6613..ca43e4f258add 100644 --- a/src/plugins/console/kibana.json +++ b/src/plugins/console/kibana.json @@ -3,7 +3,7 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["devTools", "home"], - "optionalPlugins": ["usageCollection"], - "requiredBundles": ["esUiShared", "kibanaReact", "kibanaUtils"] + "requiredPlugins": ["devTools"], + "optionalPlugins": ["usageCollection", "home"], + "requiredBundles": ["esUiShared", "kibanaReact", "kibanaUtils", "home"] } diff --git a/src/plugins/console/public/plugin.ts b/src/plugins/console/public/plugin.ts index 03b65a8bd145c..f3421aefaf38d 100644 --- a/src/plugins/console/public/plugin.ts +++ b/src/plugins/console/public/plugin.ts @@ -28,19 +28,21 @@ export class ConsoleUIPlugin implements Plugin { const homeTitle = i18n.translate('home.breadcrumbs.homeTitle', { defaultMessage: 'Home' }); const { featureCatalogue, chrome } = getServices(); + const navLinks = chrome.navLinks.getAll(); // all the directories could be get in "start" phase of plugin after all of the legacy plugins will be moved to a NP const directories = featureCatalogue.get(); + // Filters solutions by available nav links + const solutions = featureCatalogue + .getSolutions() + .filter(({ id }) => navLinks.find(({ category, hidden }) => !hidden && category?.id === id)); + chrome.setBreadcrumbs([{ text: homeTitle }]); render( - + , element ); diff --git a/src/plugins/home/public/application/components/__snapshots__/add_data.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/add_data.test.js.snap deleted file mode 100644 index 9178d0e08f3e0..0000000000000 --- a/src/plugins/home/public/application/components/__snapshots__/add_data.test.js.snap +++ /dev/null @@ -1,1163 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`apmUiEnabled 1`] = ` - - - - - - - - - - -

- -

-
-
-
- - - - - APM automatically collects in-depth performance metrics and errors from inside your applications. -
- } - footer={ - - - - } - textAlign="left" - title="APM" - titleSize="xs" - /> - - - - Ingest logs from popular data sources and easily visualize in preconfigured dashboards. - - } - footer={ - - - - } - textAlign="left" - title="Logs" - titleSize="xs" - /> - - - - Collect metrics from the operating system and services running on your servers. - - } - footer={ - - - - } - textAlign="left" - title="Metrics" - titleSize="xs" - /> - - - - - - - - - - -

- -

-
-
-
- - - Protect hosts, analyze security information and events, hunt threats, automate detections, and create cases. - - } - footer={ - - - - } - textAlign="left" - title="SIEM + Endpoint Security" - titleSize="xs" - /> -
- - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`isNewKibanaInstance 1`] = ` - - - - - - - - - - -

- -

-
-
-
- - - - - Ingest logs from popular data sources and easily visualize in preconfigured dashboards. - - } - footer={ - - - - } - textAlign="left" - title="Logs" - titleSize="xs" - /> - - - - Collect metrics from the operating system and services running on your servers. - - } - footer={ - - - - } - textAlign="left" - title="Metrics" - titleSize="xs" - /> - - -
- - - - - - - -

- -

-
-
-
- - - Protect hosts, analyze security information and events, hunt threats, automate detections, and create cases. - - } - footer={ - - - - } - textAlign="left" - title="SIEM + Endpoint Security" - titleSize="xs" - /> -
-
- - - - - - - - - - - - - - - - - - - - - - - -
-`; - -exports[`mlEnabled 1`] = ` - - - - - - - - - - -

- -

-
-
-
- - - - - APM automatically collects in-depth performance metrics and errors from inside your applications. - - } - footer={ - - - - } - textAlign="left" - title="APM" - titleSize="xs" - /> - - - - Ingest logs from popular data sources and easily visualize in preconfigured dashboards. - - } - footer={ - - - - } - textAlign="left" - title="Logs" - titleSize="xs" - /> - - - - Collect metrics from the operating system and services running on your servers. - - } - footer={ - - - - } - textAlign="left" - title="Metrics" - titleSize="xs" - /> - - -
- - - - - - - -

- -

-
-
-
- - - Protect hosts, analyze security information and events, hunt threats, automate detections, and create cases. - - } - footer={ - - - - } - textAlign="left" - title="SIEM + Endpoint Security" - titleSize="xs" - /> -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-`; - -exports[`render 1`] = ` - - - - - - - - - - -

- -

-
-
-
- - - - - Ingest logs from popular data sources and easily visualize in preconfigured dashboards. - - } - footer={ - - - - } - textAlign="left" - title="Logs" - titleSize="xs" - /> - - - - Collect metrics from the operating system and services running on your servers. - - } - footer={ - - - - } - textAlign="left" - title="Metrics" - titleSize="xs" - /> - - -
- - - - - - - -

- -

-
-
-
- - - Protect hosts, analyze security information and events, hunt threats, automate detections, and create cases. - - } - footer={ - - - - } - textAlign="left" - title="SIEM + Endpoint Security" - titleSize="xs" - /> -
-
- - - - - - - - - - - - - - - - - - - - - - - -
-`; diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap index 4fa04bb64b177..1b10756c2975c 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap @@ -1,1066 +1,1615 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`home directories should not render directory entry when showOnHomePage is false 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+

- - - - - - + - -

- -

-
- - -
+ + + Add data + + + +
+ +

+ +
+ 0 + + + + + + -
+ `; -exports[`home directories should render ADMIN directory entry in "Manage" panel 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + + Manage + + + + +
+
+ +
+ 0 + + + - + + +
+ +`; + +exports[`home directories should render ADMIN directory entry in "Manage your data" panel 1`] = ` +
+
+
+ + -

+

-

+
- - + + - + + Add data + - - +
+ + +
+
+
+ 0 + + + + + + -
+
`; -exports[`home directories should render DATA directory entry in "Explore Data" panel 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - + + - + + Add data + - - +
+ + +
+ +
+ 0 + + + - + + +
+ +`; + +exports[`home directories should render solutions in the "solution section" 1`] = ` +
+
+
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + +
+
+
+
+ + + + + + + -
+
`; -exports[`home isNewKibanaInstance should safely handle execeptions 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + +
+
+ +
+ 0 + + + - + + +
+ +`; + +exports[`home header should show "Dev tools" link if console is available 1`] = ` +
+
+
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + + Dev tools + + + + +
+
+
+
+ 0 + + + + + + -
+
`; -exports[`home isNewKibanaInstance should set isNewKibanaInstance to false when there are index patterns 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + + Manage + + + + +
+
+ +
+ 0 + + + - + + +
+ +`; + +exports[`home isNewKibanaInstance should safely handle execeptions 1`] = ` +
+
+
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + +
+
+
+
+ 0 + + + + + + -
+
`; -exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when there are no index patterns 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + +
+
+ +
+ 0 + + + - + + +
+ +`; + +exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when there are no index patterns 1`] = ` +
+
+
+ + -

+

-

+
- - - +
+ + + + + Add data + + + + +
+
+
+
+ 0 + + + + + + -
+
`; exports[`home should render home component 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - -
- - + - -

- -

-
- - -
+ + + Add data + + +
+ + +
+ +
+ 0 + + + + + + -
+ `; exports[`home welcome should show the normal home page if loading fails 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - -
- - + - -

- -

-
- - -
+ + + Add data + + +
+ + +
+ +
+ 0 + + + + + + -
+ `; exports[`home welcome should show the normal home page if welcome screen is disabled locally 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - -
- - + - -

- -

-
- - -
+ + + Add data + + +
+ + +
+ +
+ 0 + + + + + + -
+ `; exports[`home welcome should show the welcome screen if enabled, and there are no index patterns defined 1`] = ` @@ -1071,116 +1620,107 @@ exports[`home welcome should show the welcome screen if enabled, and there are n `; exports[`home welcome stores skip welcome setting if skipped 1`] = ` - - - -

- -

-
- - - - - +
+ + -

+

-

+
- - - -
- - + - -

- -

-
- - -
+ + + Add data + + +
+ + +
+ +
+ 0 + + + + + + -
+ `; diff --git a/src/plugins/home/public/application/components/__snapshots__/synopsis.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/synopsis.test.js.snap index d757d6a8b7305..190985f70659d 100644 --- a/src/plugins/home/public/application/components/__snapshots__/synopsis.test.js.snap +++ b/src/plugins/home/public/application/components/__snapshots__/synopsis.test.js.snap @@ -4,7 +4,7 @@ exports[`props iconType 1`] = ` `; @@ -24,7 +25,7 @@ exports[`props iconUrl 1`] = ` `; @@ -44,11 +46,12 @@ exports[`props isBeta 1`] = ` `; @@ -57,11 +60,12 @@ exports[`render 1`] = ` `; diff --git a/src/plugins/home/public/application/components/_add_data.scss b/src/plugins/home/public/application/components/_add_data.scss index 836b34227a37c..e588edfe35240 100644 --- a/src/plugins/home/public/application/components/_add_data.scss +++ b/src/plugins/home/public/application/components/_add_data.scss @@ -1,63 +1,22 @@ -.homAddData__card { - border: none; - box-shadow: none; -} - -.homAddData__cardDivider { - position: relative; - - &:after { - position: absolute; - content: ''; - width: 1px; - right: -$euiSizeS; - top: 0; - bottom: 0; - background: $euiBorderColor; - } -} - -.homAddData__icon { - width: $euiSizeXL * 2; - height: $euiSizeXL * 2; -} - -.homAddData__footerItem--highlight { - background-color: tintOrShade($euiColorPrimary, 90%, 70%); - padding: $euiSize; -} - -.homAddData__footerItem { - text-align: center; -} - -.homAddData__logo { - margin-left: $euiSize; -} - -@include euiBreakpoint('xs', 's') { - .homeAddData__flexGroup { - flex-wrap: wrap; - } -} - -@include euiBreakpoint('xs', 's', 'm') { - .homAddDat__flexTablet { - flex-direction: column; - } - - .homAddData__cardDivider:after { - display: none; - } - - .homAddData__cardDivider { - flex-grow: 0 !important; - flex-basis: 100% !important; - } -} - -@include euiBreakpoint('l', 'xl') { - .homeAddData__flexGroup { - flex-wrap: nowrap; - } +/* + * 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. + */ + +.homDataAdd__content .euiIcon__fillSecondary { + fill: $euiColorDarkestShade; } diff --git a/src/plugins/home/public/application/components/_home.scss b/src/plugins/home/public/application/components/_home.scss index 4101f6519829b..d9b7602971e8d 100644 --- a/src/plugins/home/public/application/components/_home.scss +++ b/src/plugins/home/public/application/components/_home.scss @@ -1,5 +1,73 @@ -@include euiBreakpoint('xs', 's', 'm') { - .homHome__synopsisItem { - flex-basis: 100% !important; +/* + * 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. + */ + +// Local page variables +$homePageWidth: 1200px; + +.homWrapper { + background-color: $euiColorEmptyShade; + display: flex; + flex-direction: column; + min-height: calc(100vh - #{$euiHeaderHeightCompensation}); +} + +.homHeader { + background-color: $euiPageBackgroundColor; + border-bottom: $euiBorderWidthThin solid $euiColorLightShade; +} + +.homHeader__inner { + margin: 0 auto; + max-width: $homePageWidth; + padding: $euiSizeXL $euiSize; + + .homHeader--hasSolutions & { + padding-bottom: $euiSizeXL + $euiSizeL; + } +} + +#homHeader__title { + @include euiBreakpoint('xs', 's') { + text-align: center; + } +} + +.homHeader__actionItem { + @include euiBreakpoint('xs', 's') { + margin-bottom: 0 !important; + margin-top: 0 !important; + } +} + +.homContent { + margin: 0 auto; + max-width: $homePageWidth; + padding: $euiSizeXL $euiSize; + width: 100%; +} + +.homData--expanded { + flex-direction: column; + + &, + & > * { + margin-bottom: 0 !important; + margin-top: 0 !important; } } diff --git a/src/plugins/home/public/application/components/_index.scss b/src/plugins/home/public/application/components/_index.scss index 870099ffb350e..a0547a4588561 100644 --- a/src/plugins/home/public/application/components/_index.scss +++ b/src/plugins/home/public/application/components/_index.scss @@ -5,9 +5,11 @@ // homChart__legend--small // homChart__legend-isLoading -@import 'add_data'; @import 'home'; +@import 'add_data'; +@import 'manage_data'; @import 'sample_data_set_cards'; +@import 'solutions_section'; @import 'synopsis'; @import 'welcome'; diff --git a/src/plugins/home/public/application/components/_manage_data.scss b/src/plugins/home/public/application/components/_manage_data.scss new file mode 100644 index 0000000000000..389d8c8b3bf0f --- /dev/null +++ b/src/plugins/home/public/application/components/_manage_data.scss @@ -0,0 +1,22 @@ +/* + * 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. + */ + +.homDataManage__content .euiIcon__fillSecondary { + fill: $euiColorDarkestShade; +} diff --git a/src/plugins/home/public/application/components/_solutions_section.scss b/src/plugins/home/public/application/components/_solutions_section.scss new file mode 100644 index 0000000000000..be693707e06b4 --- /dev/null +++ b/src/plugins/home/public/application/components/_solutions_section.scss @@ -0,0 +1,122 @@ +/* + * 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. + */ + +.homSolutions { + margin-top: -($euiSizeXL + $euiSizeL + $euiSizeM); +} + +.homSolutions__content { + min-height: $euiSize * 16; + + @include euiBreakpoint('xs', 's') { + flex-direction: column; + } +} + +.homSolutions__group { + max-width: 50%; + + @include euiBreakpoint('xs', 's') { + max-width: none; + } +} + +.homSolutionPanel { + border-radius: $euiBorderRadius; + color: inherit; + flex: 1; + transition: all $euiAnimSpeedFast $euiAnimSlightResistance; + + &:hover, + &:focus { + @include euiSlightShadowHover; + transform: translateY(-2px); + + .euiTitle { + text-decoration: underline; + } + } + + &, + .euiPanel { + display: flex; + flex-direction: column; + } + + .euiPanel { + overflow: hidden; + } +} + +.homSolutionPanel__header { + color: $euiColorEmptyShade; + padding: $euiSize; +} + +.homSolutionPanel__icon { + background-color: $euiColorEmptyShade !important; + box-shadow: none !important; + margin: 0 auto $euiSizeS; + padding: $euiSizeS; +} + +.homSolutionPanel__subtitle { + margin-top: $euiSizeXS; +} + +.homSolutionPanel__content { + flex-direction: column; + justify-content: center; + padding: $euiSize; + + @include euiBreakpoint('xs', 's') { + text-align: center; + } +} + +.homSolutionPanel__header { + background-color: $euiColorPrimary; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQYAAAFjCAYAAADfDKXVAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAfiSURBVHgB7d1JblRZGobh45SrhJiWGOQ6EENLSKyABTBmOcwRq2EnTKtUdInBuEln/OkMdB3HTTjiNqd5HinA3YBUSq++24UPUkq/J+jE+/fv09HRURrD27dv0+vXr1OLfksAG4QByAgDkBEGICMMQEYYgIwwABlhADLCAGSEAcgIA5ARBiAjDEBGGICMMAAZYQAywgBkhAHICAOQEQZg05/CAGy6FAYgIwzApnNhADLCAGy6EAZgkzAAGVclgIyTj0DGYgAyZ8IADP2ZLAZgw1n8IQzA0Hn8IQzA0EX8IQzAkEMJICMMwDURhcv44DDBlp4+fZrevHmTxvLq1av04cOHRDHO1h8IA1t7/PhxOjo6SjTrdP2BQwlg7ddiEAYgxGXK8/UnwgCEs+EnwgCEk+EnwgCE0+EnwgDEYcTF8AvCAJxufkEYgO+bXxAG6Nu1y5RrwgB9O7npi8IAfTu+6YvCAP3KrkasCQP06/i2bwgD9Ov0tm8IA/QpLlFe3PZNYYA+/bjrm8IA/YmTjqd3/YAwQH+O7/sBYYC+xHmFH/f9kDBAX/7Y5oeEAfqx1VoIwgD9+L7tDwoD9GHrtRCEAfoQ5xYutv1hYYD2PWgtBGGA9m11JWJIGKBtD14LQRigbf9POxAGaNedT1DeRRigTRGEb2lHwgBtetDlyU3CAO35mXY44TgkDNCWy9XrS9qTMEBb9jqEWBMGaEdchbj3TVi2IQzQhr2uQmwSBmjD5zTCIcSaMED94rzCaRqRMEDd4tLkaIcQa8IA9YpDh70vTd5EGKBOcb9CPCA12nmFIWGAOo16snGTMEB94mTjSZqQMEBdIgqjn2zcJAxQj7ircfIoBGGAOsTtzl/TTIQByhe/nXqSy5K3EQYoW0Rhp/dt3MdhAkoVdzV+Slf3LMxKGKBMcU5h1sOHIYcSUJ5FoxAsBijLLPcp3EcYoBxxOXKUd2DalzDA8uLk4sc08nsq7EMYYFnxINRkT0nuShhgOfEgVDwlOfvlyPsIAyyjmPMJNxEGmFccMsRKKOZ8wk2EAeZT7KHDJmGA6UUI4v6EYg8dNgkDTCued4i7GIu66nAfYYBpVLcShoQBxlflShgSBhhP1SthSBhgHNWvhCFhgP00sxKGhAF29OTJkyKfcxiDN2qBHb18+TIOH5qLQhAGICMMQEYYgIwwABlhADLCAGSEAcgIA5ARBiAjDEBGGICMMNCTy0ePHhX/RqwlEAZ6EQ88/e/Zs2dniXt57JrWNfl+CVMTBloW6+BTavTR6CkJA62KlfAtsRNhoDWxEr788zc7EgZaYiWMRBhogZUwMmGgdlbCBISBWlkJExIGamQlTEwYqImVMBNhoBZx5+LXxCyEgdLFXYufV6/TxGyEgZLFSojzCZ6InJkwUCIrYWHCQGmshAIIA6WwEgoiDJTASiiMMLCYw8NDK6FQ3tqNxbx79+5jEoUiCQOLef78uUOHQgkDkBEGICMMQEYYgIwwABlhADLCAGSEAcgIA5ARBiAjDEBGGNjWxYsXL/5IdOFg9fo9wd3+fr+Ey8vLf63+/k8az38PDg5m/RX1q/+G+Pf/O43j++rf/yU1yPsxcBfvl9ApYeA23lWpY8LAJisBYeAaK4G/CQMhQhAr4SRBEgauYhBRsBL4RRj6ZSVwK2Hok5XAnYShL1YCWxGGflgJbE0Y2mcl8GDC0DYrgZ0IQ5usBPYiDO35uXrFE3+zPrVIW4ShHbES4nbm4wR7EoY2WAmMShjqZiUwCWGol5XAZIShPlYCkxOGulgJzEIY6mAlMCthKJ+VwOyEoVxWAosRhjJZCSxKGMpiJVAEYSiHlUAxhGF5VgLFEYZlWQkUSRiWYSVQNGGYn5VA8YRhPlYC1RCGeVgJVEUYpmUlUCVhmI6VQLWEYXxWAtUThnFZCTRBGMZhJdAUYdiflUBzhGF3VgLNEobdnK1en5KVQKOE4eFiJXxL0DBh2F6shC///A1NE4btWAl0RRjuZiXQJWG4nZVAt4QhZyXQPWG4zkqAJAxrVgIMCMPVnYtfE/BLz2GIuxY/r16nCbim1zDESojzCZeJ3sTh4lj/389To3oLg5XQuYODA4eNW+gpDFYCbKmHMFgJ8ECth8FKgB20GgYrAfbQYhisBNhTS2GwEmAkrYTBSoAR1R4GKwEmUHMYrASYSI1hsBJgYrWFwUqAGdQSBisBZlRDGKwEmFnJYbASYCGlhsFKgAWVFgYrAQpQUhisBChECWGwEqAwS4fBSoACLRUGKwEKtkQYrAQo3JxhsBKgEnOFwUqAikwdhghBrISTBFRjyjBEDCIKVgJUZoowOJcAlRs7DM4lQAPGCsPP1etbshKgCfuGIQ4bYiH8SEAzdg1DHCrEQjhODhugOQ8NgyBAB7YNgyBAR+4LgyBAh24Lg6sM0LFhGGIRfE9XdywKAnQswrBeB2fJ4QKQrsLwMQEM/JYANggDkBEGICMMQEYYgIwwABlhADLCAGSEAcgIA5ARBiAjDEBGGICMMAAZYQAywgBkhAHICAOQEQYgIwxARhiAjDAAGWEAMg/9bdf0LX4h0XkCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACq8hdouPfUCk+KHQAAAABJRU5ErkJggg=='), + url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAWgAAAFKCAYAAAAwgcXoAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAjGSURBVHgB7d27ctRaFoDh1WBzsQ9wLkVsngHHfglSIOUdgJQiJSfmIUjhJUwGjiGaU7hmzoB7vLA1GPCt21vSlvR9Vao+5lAFbbf+EktbUgQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABUaxYAEzCfz/NldX9biTK+zGaz/0aLSv1FAYbg6v52I8r4e39rNdCXAoAqCTRApQQaoFICDVApgQaolEADVEqgASol0ACVEmiASgk0QKUEGqBSAg1QKYEGqJRAA1RKoAEqJdAAlRJogEp5ogowJV/3t/9EGV8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFjELABOMZ/Po6TZrL/sDO29rATA6Vb3t/UoY29/+1f04DDO1w63Ev59uLVGoIGz5GHi9Sjja/QU6EPZvFLv5Uu07FIAUCWBBqiUQANUSqABKiXQAJUSaIBKCTRApQQaoFICDVApgQaolEADVEqgASol0ACVEmiASgl0ANRJoAEqJdAAlfJEFeAs+RSUv6OMsg8FXNw/Ue69/BMAAAAAAAAAAAAAAAAAAAAAQCdmAcBpspNn3flzL1q4EZRAA2PRhLR5vXz4683ryk+/7+j/ixO+vqiM9t6Rr78e899ffvq9za9/FWigdhnNbNVqfA/wyuHr5fgxxqMi0EDfMq4Z3ya4K4fbaMN7XgINdKGJ8OUjW/M1JxBooKSj4c3X5mhYa5bgmwYsoxlHXA0hbo1vJnCW5gRds10Jo4lOCDTws4xvRjiPjvOoeDXohUDDtB09Or4W31dTUAE/CJiWJshNjK8E1RJoGDdBHjCBhvHJCBtZjIAfHAxf7scZ46uHr/brkfCDhGFqVllcCWOL0RJoGIajs+S16Hjfnc3K/XHzefG7cp7b0N6HQEO9qhhdbG5uxosXL6KET58+xb1796IPGef79+/Ho0ePooSXL1/Gq1evok0rAdSkiXIeJVdxgm9tbS22traihJ2dnejTxsZGsffy+vXraJtAQ/+qizJ1EGjoRzNTvhGizAkEGrqVKy56OdHH8Ag0tC9vPpRBXg9RZgECDe3IEGeU82jZOmWWItBQlhEGxQg0XFyG+Lc4CLN9imJ8mGB5ebScc+W8kMTRMsUJNCzGbJnOCDScj5UYdE6g4XR5lHwjHC3TA4GGXx299FqY6Y1Aw3fNagxjDKog0CDMVEqgmbIcX+QY43pAhQSaKXLij0EQaKZEmJfU52OqShvSexFopkCYL2B7ezsePnwYJezu7kZfMsz5FJQPHz5ECe/evYu2OSHCmAkzgybQjJEwMwoCzZgIM6Mi0IxB3ifjZhxc/QejIdAMWX5+84h5PWCEBJohcuUfk+DDzdBklPOo2WeX0fMhZyjyxN/vcTBvhkkQaGq3GgcnAK3MYHIEmlo5AcjkCTQ1MmeGsANQlxxj5DhjNQCBpgr5ObwV7ssMPxBo+macASewU9AX982AMwg0XbM6A85JoOmSi01gAQJNF9xtDpYg0LTNSUBYkp2GtuRRc44znASEJQk0bXDUDAXYgSjJUTMUJNCU4qgZCrMzcVGOmqElAs1F5LK5jLPPEbTAjsUyXA0IHRBoFuVqQOiIQLOIPGr+LZic2axcKubzefSl5PtIbb+XSwFny6Plv0KcJ2lrayv29vaKbO/fv4++ZJyfPHlS7L08fvw42rYScDonAqEnAs1JnAiEngk0x8mRxh/h2YDQK4HmZ2txcGtQIw3omUBzVIbZSAMqIdAkIw2okEDjwhOolEBPW44zbgZQJYGepjwBeGt/ux5AtQR6epqrAo00oHICPS05b/4zLKGDQRDo6TBvhoER6GmwvhkGSKDHzfpmGDCBHi8nA2HgBHqcnAyEERDo8XEyEEZCoMfFI6loRZ+PqSptSO/FP4HHI++n4cpAWrGxsRGl7OzsRF/W1tbi9u3bUcLHjx9jd3c32iTQw5c/wzwZaKUGjIxAD5uVGjBiAj1c4gwjJ9DDlOOMjLOfH4yYHXx4PDMQJsJOPiwZ51sBTIJAD4c1zjAxAj0M4gwTJND1E2eYKIGum/s4w4QJdL1cug0TJ9B1EmdAoCskzsA3Al0XcQb+T6DrIc7ADwS6DuIM/EKg+yfOwLEEul/iDJxIoPsjzsCpLgV9yCsExRk4lUB3L++t4fJt4ExGHN1y4yPg3AS6O+IMLESguyHOwMLMoNuXj6kSZ2BhAt2ua+EZgsCSBLo9q3Gw1hlgKStBGy7vb3+EGT8jsL6+Hpubm1HK27dvoy8bGxvfthJ2dna+bW0S6PIyzn8dvsLg3b17N968eRMlZNDu3LkTfZjNZvHgwYN49uxZlPD06dN4/vx5tMmIo6w8YhZnoAiBLuvPEGegEIEuJ++vcSUAChHoMtxfAyhOoC8uw+xCFKA4gb6YXOt8MwBaINDLa9Y6A7RCoJdjOR3QOoFeTl7CLc5AqwR6cbli41oAtEygF2PFBtAZgT4/KzaATgn0+VixAXROoM8n4+ykINApgT5bjjVWA6BjAn269XCPDaAnAn2yHGk4KQj8Yj6fRxc8UeV4zVNRYPJ2d3dje3s7Smj7EVFn+fz5c5H3koHO70vbPDPveHlS0MUoQK+MOH7lSkGgCgL9o3wiiisFgSoI9Hc5d/49ACoh0N+5Qx1QFYE+kHNnD3wFqiLQ5s5ApaYeaHNnoFpTD3SONsydgSpNOdB5j43rAVCpqQbafTaA6k010O6zAVRvioE2dwYGYWqBtqQOGIwpBdqSOmBQphRoow1gUKYS6OthSR0wMFMIdB413wiAgZlCoI02gEEae6CNNoDBGnOgjTaAQRtzoI02gEEba6CNNoDBG2OgZ2G0AYzA/wBP5hsF50HhogAAAABJRU5ErkJggg=='); + background-repeat: no-repeat; + background-position: top 0 left 0, bottom 0 right 0; + background-size: $euiSizeXL * 4, $euiSizeXL * 6; + + .homSolutionPanel--enterpriseSearch & { + background-color: $euiColorSecondary; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAABKCAYAAADJwhY8AAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAANMSURBVHgBzZp7buIwEMYHKC1QuqsF7Up7Hs4Al+Ff4DCcAonrtPuAPjcL1B/YyA0p2JkZK580SuQm6Y+xx4+xiSqumrG+d7/z/rYx9t+WZdZ2lFhXxq4jngfkP2Ov9qquq8jnm9Zu6eBNgD6TImwsoC80ibY1NIe1sRcSVsPYHfFVN9Yy1jG2pUPbFZEUoJMP+kYHWJakAZ0AemuvrPZZJ10B8jsdHFFKWh70BSe07X1GkUoBCCHib+w1qsq1qzivLkU6JDUgTSaT7m63C4ZMCmjgaDwe4zYYMhmgB+cEyPal95IAFsA5fTGQZ4dbdcAzcO7/9wxk7bMH8AfMTvJDUsO+fG2tSfJwvp5qtdqKPgEMEYABekeBo0IEnNODgTzpI0MBfaFhnwUtAQdlBvA+X1gG0Kmw0y0J57QykE9+ASdIHo3hF29cARMO6uYDhhvFGPwfjG0E4BxPxy/gVPFR8/m8MRwOsTosPa3y9KEtigBCpmoAJwV5jGixjtp8EG3xD8noOASKjiT2V6+Jr5YLFo2hDuvkDfEErqa7EZXx4naxWKyIr9b+e6QkU0U/iBcw+2jWnM08E09NtMMqA0JNNUC0ReInlRraE9bodXBOzaoD1rQBuVku9SpmZ7eSL9wjVa864LbqgOpVzJ4bagNyv7/RBozZgymSOuAN8bRTA7RrFM4+DJRpepBbvZAqIDf3jQmrThXbxCS3i5FdduYksXOAjUp5QJt75npvK75whwwclopd4uvV3YgB2m7lG8nouK0rAojkEcnlZTZ+plUCsDEajfom9SYBB31InXA/is64h+8sl0vKsowGgwExBO/99QvKDkXISHQpFxCz2Wx/nU6nVFIniacyHkTOpGevJ2J48sR7UKgHnceCRoiSnvxVVOj2P6C6ZyjHVKljwb7a54KDKtKT2MgpPDECD/ZJSYGedEdaCqW+437BkzgcdG/zOIVKciTgDOTawL2dezfVmYUiyHV+V6lIyQAhDxJwjyHvoPv4SWmFgAiCg1JnFqLgIO6qK1SIVowS0afjUgCin/tNJZOZ2oCIUlRr6aOlWoDwFjZz2CczpQHhKXjsYv8WKilAeAwBgL0R0ZPCHECAvFirzCFb/5hyknPV7zL4DLH0CVGgAAAAAElFTkSuQmCC'), + url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAA+CAYAAACbQR1vAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACBSURBVHgB7dpBDcAwDMVQdyzGH9xojEQjtbIfBEu5fGUx62WPjyEPcgVArgDIFQC5AiBXAOQKgFwBkCsAcgVATh9gsW+4vFIngFwBkCsAcgVArgDIFQC5AiBXAORahJjVm9zpCoBcAZArAHIFQK4AyBUAuQIgVwDkCoBcAZDTB/gBLrgCk8OhuvYAAAAASUVORK5CYII='); + background-position: top $euiSizeS left 0, bottom $euiSizeS right $euiSizeS; + background-size: $euiSize * 1.25, $euiSizeXL; + } + + .homSolutionPanel--observability & { + background-color: $euiColorAccent; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAADNSURBVHgB7duxCcIAEEDR08IFrGyz/0pZwVYEIcYF/FWKwHtwXP/hyrvMQbZtu+3rPid3Hf4SKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgcJmDnt6WZfms6/qak/sFeswx3vs85+ScWBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgKFLx4KCqcIFEJnAAAAAElFTkSuQmCC'); + background-position: top $euiSizeS right $euiSizeS; + background-size: $euiSizeL * 1.5; + } + + .homSolutionPanel--securitySolution & { + background-color: $euiColorDarkestShade; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGIAAABiCAYAAACrpQYOAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAKRSURBVHgB7d3tSgMxEIXhMeqPQoWC9f4vte5Z3dLvTbKZzCQ5LyxqKwg+tNjdUxQR+RJmXpiOvRDDvPD/kRjGhYvPgbETZlK4+fogxDApPLiNGAaFJ7cD40NYtcKL+76FGNUKK/cRo1Ih4n5gvAtTbQ1i+R5iKBcDgYBADMViIRAxFEuBQMRQKhUCEUOhHAi0YLwJK1IuBCJGwbZAoE8hRpG2QqAFg22oBAQCxkFYdqUgEE6dEyOzkhCIGJmVhkDA4PXvxDQgEMcIiWlBIGIkpAmBiBGZNgQiRkQ1IBAxVqoFgThge1FNCMTN1JNqQyBiPMgCAnHAdpMVBOJm6iJLCA7YLrKEWH4+r3+LPYQIMeY8QKDhlyFeINDQGJ4g0LAY3iDQkBgeIdBwmymvEGgoDM8QaJgBm3cINMSArQUI1P2ArRUI1PVmqiUI1C1GaxCoywFbixCouzFCqxCIyxBPnU6nLjBafkQs7YExHdJyPUCg+WmqZYxeIBAwmv3TticItJseFYcWHxm9QaD5RV9rGD1CIGAcJ4xmztr2CoHms7atYPQMgc4Y3p+qeodAwPiZjnfPGCNAoPMgwSvGKBDINcZIEMgtxmgQyCXGiBDIHcaoEAgYx+n48IAxMgQ6v1nGGmN0COQCgxB/4feAF307KwxCXDe/9dgCgxD3mWAQ4nHAqHrplRDPq3odnBCvq4ZBiPWqYBAiLnUMQsSnikGItObtlAYGIdJTmesQIq/iGITIr+h2ihDbKradIsT2imynCFGmzdspQpRr03VwQpQtG4MQ5cvCIIROyRiE0CsJgxC6RW+nCKFf1FyHEHVaxSBEvV5upwhRv4dzHULYdIdBCLuutlOEsO18HZz/u8E+YMgvrbKfmp8y7IEAAAAASUVORK5CYII='); + background-position: top $euiSizeS left $euiSizeS; + background-size: $euiSizeL * 2; + } +} diff --git a/src/plugins/home/public/application/components/_synopsis.scss b/src/plugins/home/public/application/components/_synopsis.scss index 49e71f159fe6f..3eac2bc9705e0 100644 --- a/src/plugins/home/public/application/components/_synopsis.scss +++ b/src/plugins/home/public/application/components/_synopsis.scss @@ -5,6 +5,10 @@ box-shadow: none; } + .homSynopsis__cardTitle { + display: flex; + } + // SASSTODO: Fix in EUI .euiCard__content { padding-top: 0 !important; diff --git a/src/plugins/home/public/application/components/add_data.js b/src/plugins/home/public/application/components/add_data.js deleted file mode 100644 index c35b7b04932fb..0000000000000 --- a/src/plugins/home/public/application/components/add_data.js +++ /dev/null @@ -1,320 +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 React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; -import { getServices } from '../kibana_services'; - -import { - EuiButton, - EuiLink, - EuiPanel, - EuiTitle, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiCard, - EuiIcon, - EuiHorizontalRule, - EuiFlexGrid, -} from '@elastic/eui'; - -const AddDataUi = ({ apmUiEnabled, isNewKibanaInstance, intl, mlEnabled }) => { - const basePath = getServices().getBasePath(); - - const renderCards = () => { - const apmData = { - title: intl.formatMessage({ - id: 'home.addData.apm.nameTitle', - defaultMessage: 'APM', - }), - description: intl.formatMessage({ - id: 'home.addData.apm.nameDescription', - defaultMessage: - 'APM automatically collects in-depth performance metrics and errors from inside your applications.', - }), - ariaDescribedby: 'aria-describedby.addAmpButtonLabel', - }; - const loggingData = { - title: intl.formatMessage({ - id: 'home.addData.logging.nameTitle', - defaultMessage: 'Logs', - }), - description: intl.formatMessage({ - id: 'home.addData.logging.nameDescription', - defaultMessage: - 'Ingest logs from popular data sources and easily visualize in preconfigured dashboards.', - }), - ariaDescribedby: 'aria-describedby.addLogDataButtonLabel', - }; - const metricsData = { - title: intl.formatMessage({ - id: 'home.addData.metrics.nameTitle', - defaultMessage: 'Metrics', - }), - description: intl.formatMessage({ - id: 'home.addData.metrics.nameDescription', - defaultMessage: - 'Collect metrics from the operating system and services running on your servers.', - }), - ariaDescribedby: 'aria-describedby.addMetricsButtonLabel', - }; - const siemData = { - title: intl.formatMessage({ - id: 'home.addData.securitySolution.nameTitle', - defaultMessage: 'SIEM + Endpoint Security', - }), - description: intl.formatMessage({ - id: 'home.addData.securitySolution.nameDescription', - defaultMessage: - 'Protect hosts, analyze security information and events, hunt threats, automate detections, and create cases.', - }), - ariaDescribedby: 'aria-describedby.addSiemButtonLabel', - }; - - const getApmCard = () => ( - - {apmData.description}} - footer={ - - - - } - /> - - ); - - return ( - - - - - - - - - -

- -

-
-
-
- - - {apmUiEnabled !== false && getApmCard()} - - - {loggingData.description} - } - footer={ - - - - } - /> - - - - {metricsData.description} - } - footer={ - - - - } - /> - - -
- - - - - - - - -

- -

-
-
-
- - {siemData.description}} - footer={ - - - - } - /> -
-
- ); - }; - - const footerItemClasses = classNames('homAddData__footerItem', { - 'homAddData__footerItem--highlight': isNewKibanaInstance, - }); - - return ( - - {renderCards()} - - - - - - - - - - - - - - - {mlEnabled !== false ? ( - - - - - - - - - - - ) : null} - - - - - - - - - - - - - ); -}; - -AddDataUi.propTypes = { - apmUiEnabled: PropTypes.bool.isRequired, - mlEnabled: PropTypes.bool.isRequired, - isNewKibanaInstance: PropTypes.bool.isRequired, -}; - -export const AddData = injectI18n(AddDataUi); diff --git a/src/plugins/home/public/application/components/add_data.test.js b/src/plugins/home/public/application/components/add_data.test.js deleted file mode 100644 index 9457f766409b8..0000000000000 --- a/src/plugins/home/public/application/components/add_data.test.js +++ /dev/null @@ -1,68 +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 React from 'react'; -import { AddData } from './add_data'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { getServices } from '../kibana_services'; - -jest.mock('../kibana_services', () => { - const mock = { - getBasePath: jest.fn(() => 'path'), - }; - return { - getServices: () => mock, - }; -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -test('render', () => { - const component = shallowWithIntl( - - ); - expect(component).toMatchSnapshot(); // eslint-disable-line - expect(getServices().getBasePath).toHaveBeenCalledTimes(1); -}); - -test('mlEnabled', () => { - const component = shallowWithIntl( - - ); - expect(component).toMatchSnapshot(); // eslint-disable-line - expect(getServices().getBasePath).toHaveBeenCalledTimes(1); -}); - -test('apmUiEnabled', () => { - const component = shallowWithIntl( - - ); - expect(component).toMatchSnapshot(); // eslint-disable-line - expect(getServices().getBasePath).toHaveBeenCalledTimes(1); -}); - -test('isNewKibanaInstance', () => { - const component = shallowWithIntl( - - ); - expect(component).toMatchSnapshot(); // eslint-disable-line - expect(getServices().getBasePath).toHaveBeenCalledTimes(1); -}); diff --git a/src/plugins/home/public/application/components/add_data/__snapshots__/add_data.test.tsx.snap b/src/plugins/home/public/application/components/add_data/__snapshots__/add_data.test.tsx.snap new file mode 100644 index 0000000000000..787802e508ca7 --- /dev/null +++ b/src/plugins/home/public/application/components/add_data/__snapshots__/add_data.test.tsx.snap @@ -0,0 +1,96 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddData render 1`] = ` +
+ + + +

+ +

+
+
+ + + + + +
+ + + + + + + + + + + + +
+`; diff --git a/src/plugins/home/public/application/components/add_data/add_data.test.tsx b/src/plugins/home/public/application/components/add_data/add_data.test.tsx new file mode 100644 index 0000000000000..e76e834802284 --- /dev/null +++ b/src/plugins/home/public/application/components/add_data/add_data.test.tsx @@ -0,0 +1,95 @@ +/* + * 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. + */ + +/* + * 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 React from 'react'; +import { AddData } from './add_data'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; + +jest.mock('../app_navigation_handler', () => { + return { + createAppNavigationHandler: jest.fn(() => () => {}), + }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const addBasePathMock = jest.fn((path: string) => (path ? path : 'path')); + +const mockFeatures = [ + { + category: 'data', + description: 'Ingest data from popular apps and services.', + homePageSection: 'add_data', + icon: 'indexOpen', + id: 'home_tutorial_directory', + order: 500, + path: '/app/home#/tutorial_directory', + title: 'Ingest data', + }, + { + category: 'admin', + description: 'Add and manage your fleet of Elastic Agents and integrations.', + homePageSection: 'add_data', + icon: 'indexManagementApp', + id: 'ingestManager', + order: 510, + path: '/app/ingestManager', + title: 'Add Elastic Agent', + }, + { + category: 'data', + description: 'Import your own CSV, NDJSON, or log file', + homePageSection: 'add_data', + icon: 'document', + id: 'ml_file_data_visualizer', + order: 520, + path: '/app/ml#/filedatavisualizer', + title: 'Upload a file', + }, +]; + +describe('AddData', () => { + test('render', () => { + const component = shallowWithIntl( + + ); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/home/public/application/components/add_data/add_data.tsx b/src/plugins/home/public/application/components/add_data/add_data.tsx new file mode 100644 index 0000000000000..82f0020b1b389 --- /dev/null +++ b/src/plugins/home/public/application/components/add_data/add_data.tsx @@ -0,0 +1,95 @@ +/* + * 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 React, { FC } from 'react'; +import PropTypes from 'prop-types'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +// @ts-expect-error untyped service +import { FeatureCatalogueEntry } from '../../services'; +import { createAppNavigationHandler } from '../app_navigation_handler'; +// @ts-expect-error untyped component +import { Synopsis } from '../synopsis'; + +interface Props { + addBasePath: (path: string) => string; + features: FeatureCatalogueEntry[]; +} + +export const AddData: FC = ({ addBasePath, features }) => ( +
+ + + +

+ +

+
+
+ + + + + + +
+ + + + + {features.map((feature) => ( + + + + ))} + +
+); + +AddData.propTypes = { + addBasePath: PropTypes.func.isRequired, + features: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + showOnHomePage: PropTypes.bool.isRequired, + category: PropTypes.string.isRequired, + order: PropTypes.number, + }) + ), +}; diff --git a/src/plugins/home/public/application/components/add_data/index.ts b/src/plugins/home/public/application/components/add_data/index.ts new file mode 100644 index 0000000000000..a7d465d177636 --- /dev/null +++ b/src/plugins/home/public/application/components/add_data/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 './add_data'; diff --git a/src/plugins/home/public/application/components/app_navigation_handler.ts b/src/plugins/home/public/application/components/app_navigation_handler.ts index 6e78af7f42f52..61d85c033b544 100644 --- a/src/plugins/home/public/application/components/app_navigation_handler.ts +++ b/src/plugins/home/public/application/components/app_navigation_handler.ts @@ -17,6 +17,7 @@ * under the License. */ +import { MouseEvent } from 'react'; import { getServices } from '../kibana_services'; export const createAppNavigationHandler = (targetUrl: string) => (event: MouseEvent) => { diff --git a/src/plugins/home/public/application/components/feature_directory.js b/src/plugins/home/public/application/components/feature_directory.js index e9ab348f164c7..36ececcdfd8df 100644 --- a/src/plugins/home/public/application/components/feature_directory.js +++ b/src/plugins/home/public/application/components/feature_directory.js @@ -115,6 +115,7 @@ export class FeatureDirectory extends React.Component { return ( { - const { addBasePath, directories } = this.props; - return directories - .filter((directory) => { - return directory.showOnHomePage && directory.category === category; - }) - .map((directory) => { - return ( - - - - ); - }); - }; + findDirectoryById = (id) => this.props.directories.find((directory) => directory.id === id); + + getFeaturesByCategory = (category) => + this.props.directories + .filter((directory) => directory.showOnHomePage && directory.category === category) + .sort((directoryA, directoryB) => directoryA.order - directoryB.order); renderNormal() { - const { apmUiEnabled, mlEnabled } = this.props; + const { addBasePath, solutions } = this.props; + + const devTools = this.findDirectoryById('console'); + const stackManagement = this.findDirectoryById('stack-management'); + const advancedSettings = this.findDirectoryById('advanced_settings'); + + const addDataFeatures = this.getFeaturesByCategory(FeatureCatalogueCategory.DATA); + const manageDataFeatures = this.getFeaturesByCategory(FeatureCatalogueCategory.ADMIN); + + // Show card for console if none of the manage data plugins are available, most likely in OSS + if (manageDataFeatures.length < 1 && devTools) { + manageDataFeatures.push(devTools); + } return ( - - - -

- -

-
- - - - - - - - - -

- -

+
+
+
+ + + +

+ +

- - - {this.renderDirectories(FeatureCatalogueCategory.DATA)} - - +
+ + + + + + {i18n.translate('home.pageHeader.addDataButtonLabel', { + defaultMessage: 'Add data', + })} + + + + {stackManagement ? ( + + + {i18n.translate('home.pageHeader.stackManagementButtonLabel', { + defaultMessage: 'Manage', + })} + + + ) : null} + + {devTools ? ( + + + {i18n.translate('home.pageHeader.devToolsButtonLabel', { + defaultMessage: 'Dev tools', + })} + + + ) : null} + + +
+
+
+ +
+ {solutions.length && } + + + + + - - -

- -

-
- - - {this.renderDirectories(FeatureCatalogueCategory.ADMIN)} - -
+
- +
+
); } @@ -260,13 +296,23 @@ Home.propTypes = { path: PropTypes.string.isRequired, showOnHomePage: PropTypes.bool.isRequired, category: PropTypes.string.isRequired, + order: PropTypes.number, + }) + ), + solutions: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + subtitle: PropTypes.string.isRequired, + descriptions: PropTypes.arrayOf(PropTypes.string).isRequired, + icon: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + order: PropTypes.number, }) ), - apmUiEnabled: PropTypes.bool.isRequired, find: PropTypes.func.isRequired, localStorage: PropTypes.object.isRequired, urlBasePath: PropTypes.string.isRequired, - mlEnabled: PropTypes.bool.isRequired, telemetry: PropTypes.shape({ telemetryService: PropTypes.any, telemetryNotifications: PropTypes.any, diff --git a/src/plugins/home/public/application/components/home.test.js b/src/plugins/home/public/application/components/home.test.js index 3bcfce513cb12..0d7596d92a5a1 100644 --- a/src/plugins/home/public/application/components/home.test.js +++ b/src/plugins/home/public/application/components/home.test.js @@ -41,6 +41,7 @@ describe('home', () => { beforeEach(() => { defaultProps = { directories: [], + solutions: [], apmUiEnabled: true, mlEnabled: true, kibanaVersion: '99.2.1', @@ -92,8 +93,96 @@ describe('home', () => { expect(component).toMatchSnapshot(); }); + describe('header', () => { + test('render', async () => { + const component = await renderHome(); + expect(component).toMatchSnapshot(); + }); + + test('should show "Manage" link if stack management is available', async () => { + const directoryEntry = { + id: 'stack-management', + title: 'Management', + description: 'Your center console for managing the Elastic Stack.', + icon: 'managementApp', + path: 'management_landing_page', + category: FeatureCatalogueCategory.ADMIN, + showOnHomePage: false, + }; + + const component = await renderHome({ + directories: [directoryEntry], + }); + + expect(component).toMatchSnapshot(); + }); + + test('should show "Dev tools" link if console is available', async () => { + const directoryEntry = { + id: 'console', + title: 'Console', + description: 'Skip cURL and use a JSON interface to work with your data in Console.', + icon: 'consoleApp', + path: 'path-to-dev-tools', + category: FeatureCatalogueCategory.ADMIN, + showOnHomePage: false, + }; + + const component = await renderHome({ + directories: [directoryEntry], + }); + + expect(component).toMatchSnapshot(); + }); + }); + describe('directories', () => { - test('should render DATA directory entry in "Explore Data" panel', async () => { + test('should render solutions in the "solution section"', async () => { + const solutionEntry1 = { + id: 'kibana', + title: 'Kibana', + subtitle: 'Visualize & analyze', + descriptions: ['Analyze data in dashboards'], + icon: 'logoKibana', + path: 'kibana_landing_page', + order: 1, + }; + const solutionEntry2 = { + id: 'solution-2', + title: 'Solution two', + subtitle: 'Subtitle for solution two', + descriptions: ['Example use case'], + icon: 'empty', + path: 'path-to-solution-two', + order: 2, + }; + const solutionEntry3 = { + id: 'solution-3', + title: 'Solution three', + subtitle: 'Subtitle for solution three', + descriptions: ['Example use case'], + icon: 'empty', + path: 'path-to-solution-three', + order: 3, + }; + const solutionEntry4 = { + id: 'solution-4', + title: 'Solution four', + subtitle: 'Subtitle for solution four', + descriptions: ['Example use case'], + icon: 'empty', + path: 'path-to-solution-four', + order: 4, + }; + + const component = await renderHome({ + solutions: [solutionEntry1, solutionEntry2, solutionEntry3, solutionEntry4], + }); + + expect(component).toMatchSnapshot(); + }); + + test('should render DATA directory entry in "Ingest your data" panel', async () => { const directoryEntry = { id: 'dashboard', title: 'Dashboard', @@ -111,7 +200,7 @@ describe('home', () => { expect(component).toMatchSnapshot(); }); - test('should render ADMIN directory entry in "Manage" panel', async () => { + test('should render ADMIN directory entry in "Manage your data" panel', async () => { const directoryEntry = { id: 'index_patterns', title: 'Index Patterns', @@ -148,6 +237,26 @@ describe('home', () => { }); }); + describe('change home route', () => { + test('should render a link to change the default route in advanced settings if advanced settings is enabled', async () => { + const component = await renderHome({ + directories: [ + { + description: 'Change your settings', + icon: 'gear', + id: 'advanced_settings', + path: 'path-to-advanced_settings', + showOnHomePage: false, + title: 'Advanced settings', + category: FeatureCatalogueCategory.ADMIN, + }, + ], + }); + + expect(component).toMatchSnapshot(); + }); + }); + describe('welcome', () => { test('should show the welcome screen if enabled, and there are no index patterns defined', async () => { defaultProps.localStorage.getItem = sinon.spy(() => 'true'); diff --git a/src/plugins/home/public/application/components/home_app.js b/src/plugins/home/public/application/components/home_app.js index 648915b6dae0c..90e549c873436 100644 --- a/src/plugins/home/public/application/components/home_app.js +++ b/src/plugins/home/public/application/components/home_app.js @@ -38,7 +38,7 @@ const RedirectToDefaultApp = () => { return null; }; -export function HomeApp({ directories }) { +export function HomeApp({ directories, solutions }) { const { savedObjectsClient, getBasePath, @@ -48,8 +48,6 @@ export function HomeApp({ directories }) { } = getServices(); const environment = environmentService.getEnvironment(); const isCloudEnabled = environment.cloud; - const mlEnabled = environment.ml; - const apmUiEnabled = environment.apmUi; const renderTutorialDirectory = (props) => { return ( @@ -87,8 +85,7 @@ export function HomeApp({ directories }) { +
+ ), + }} + > + onChange({ createNewCopies: false })} + > + {overwriteRadio} + + + + + onChange({ createNewCopies: true })} + /> + + ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.scss b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.scss new file mode 100644 index 0000000000000..4b46c1244e246 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.scss @@ -0,0 +1,20 @@ +.savedObjectsManagementImportSummary__row { + margin-bottom: $euiSizeXS; +} + +.savedObjectsManagementImportSummary__title { + // Constrains title to the flex item, and allows for truncation when necessary + min-width: 0; +} + +.savedObjectsManagementImportSummary__createdCount { + color: $euiColorSuccessText; +} + +.savedObjectsManagementImportSummary__errorCount { + color: $euiColorDangerText; +} + +.savedObjectsManagementImportSummary__icon { + margin-left: $euiSizeXS; +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx new file mode 100644 index 0000000000000..ed65131b0fc6b --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx @@ -0,0 +1,152 @@ +/* + * 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 React from 'react'; +import { ShallowWrapper } from 'enzyme'; +import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; +import { ImportSummary, ImportSummaryProps } from './import_summary'; +import { FailedImport } from '../../../lib'; + +// @ts-expect-error +import { findTestSubject } from '@elastic/eui/lib/test'; + +describe('ImportSummary', () => { + const errorUnsupportedType: FailedImport = { + obj: { type: 'error-obj-type', id: 'error-obj-id', meta: { title: 'Error object' } }, + error: { type: 'unsupported_type' }, + }; + const successNew = { type: 'dashboard', id: 'dashboard-id', meta: { title: 'New' } }; + const successOverwritten = { + type: 'visualization', + id: 'viz-id', + meta: { title: 'Overwritten' }, + overwrite: true, + }; + + const findHeader = (wrapper: ShallowWrapper) => wrapper.find('h3'); + const findCountCreated = (wrapper: ShallowWrapper) => + wrapper.find('h4.savedObjectsManagementImportSummary__createdCount'); + const findCountOverwritten = (wrapper: ShallowWrapper) => + wrapper.find('h4.savedObjectsManagementImportSummary__overwrittenCount'); + const findCountError = (wrapper: ShallowWrapper) => + wrapper.find('h4.savedObjectsManagementImportSummary__errorCount'); + const findObjectRow = (wrapper: ShallowWrapper) => + wrapper.find('.savedObjectsManagementImportSummary__row'); + + it('should render as expected with no results', async () => { + const props: ImportSummaryProps = { failedImports: [], successfulImports: [] }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 0 } }) + ); + expect(findCountCreated(wrapper)).toHaveLength(0); + expect(findCountOverwritten(wrapper)).toHaveLength(0); + expect(findCountError(wrapper)).toHaveLength(0); + expect(findObjectRow(wrapper)).toHaveLength(0); + }); + + it('should render as expected with a newly created object', async () => { + const props: ImportSummaryProps = { + failedImports: [], + successfulImports: [successNew], + }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 1 } }) + ); + const countCreated = findCountCreated(wrapper); + expect(countCreated).toHaveLength(1); + expect(countCreated.childAt(0).props()).toEqual( + expect.objectContaining({ values: { createdCount: 1 } }) + ); + expect(findCountOverwritten(wrapper)).toHaveLength(0); + expect(findCountError(wrapper)).toHaveLength(0); + expect(findObjectRow(wrapper)).toHaveLength(1); + }); + + it('should render as expected with an overwritten object', async () => { + const props: ImportSummaryProps = { + failedImports: [], + successfulImports: [successOverwritten], + }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 1 } }) + ); + expect(findCountCreated(wrapper)).toHaveLength(0); + const countOverwritten = findCountOverwritten(wrapper); + expect(countOverwritten).toHaveLength(1); + expect(countOverwritten.childAt(0).props()).toEqual( + expect.objectContaining({ values: { overwrittenCount: 1 } }) + ); + expect(findCountError(wrapper)).toHaveLength(0); + expect(findObjectRow(wrapper)).toHaveLength(1); + }); + + it('should render as expected with an error object', async () => { + const props: ImportSummaryProps = { + failedImports: [errorUnsupportedType], + successfulImports: [], + }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 1 } }) + ); + expect(findCountCreated(wrapper)).toHaveLength(0); + expect(findCountOverwritten(wrapper)).toHaveLength(0); + const countError = findCountError(wrapper); + expect(countError).toHaveLength(1); + expect(countError.childAt(0).props()).toEqual( + expect.objectContaining({ values: { errorCount: 1 } }) + ); + expect(findObjectRow(wrapper)).toHaveLength(1); + }); + + it('should render as expected with mixed objects', async () => { + const props: ImportSummaryProps = { + failedImports: [errorUnsupportedType], + successfulImports: [successNew, successOverwritten], + }; + const wrapper = shallowWithI18nProvider(); + + expect(findHeader(wrapper).childAt(0).props()).toEqual( + expect.objectContaining({ values: { importCount: 3 } }) + ); + const countCreated = findCountCreated(wrapper); + expect(countCreated).toHaveLength(1); + expect(countCreated.childAt(0).props()).toEqual( + expect.objectContaining({ values: { createdCount: 1 } }) + ); + const countOverwritten = findCountOverwritten(wrapper); + expect(countOverwritten).toHaveLength(1); + expect(countOverwritten.childAt(0).props()).toEqual( + expect.objectContaining({ values: { overwrittenCount: 1 } }) + ); + const countError = findCountError(wrapper); + expect(countError).toHaveLength(1); + expect(countError.childAt(0).props()).toEqual( + expect.objectContaining({ values: { errorCount: 1 } }) + ); + expect(findObjectRow(wrapper)).toHaveLength(3); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx new file mode 100644 index 0000000000000..7949f7d18d350 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx @@ -0,0 +1,237 @@ +/* + * 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 './import_summary.scss'; +import _ from 'lodash'; +import React, { Fragment } from 'react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiIcon, + EuiIconTip, + EuiHorizontalRule, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { SavedObjectsImportSuccess } from 'kibana/public'; +import { FailedImport } from '../../..'; +import { getDefaultTitle, getSavedObjectLabel } from '../../../lib'; + +const DEFAULT_ICON = 'apps'; + +export interface ImportSummaryProps { + failedImports: FailedImport[]; + successfulImports: SavedObjectsImportSuccess[]; +} + +interface ImportItem { + type: string; + id: string; + title: string; + icon: string; + outcome: 'created' | 'overwritten' | 'error'; + errorMessage?: string; +} + +const unsupportedTypeErrorMessage = i18n.translate( + 'savedObjectsManagement.objectsTable.importSummary.unsupportedTypeError', + { defaultMessage: 'Unsupported object type' } +); + +const getErrorMessage = ({ error }: FailedImport) => { + if (error.type === 'unknown') { + return error.message; + } else if (error.type === 'unsupported_type') { + return unsupportedTypeErrorMessage; + } +}; + +const mapFailedImport = (failure: FailedImport): ImportItem => { + const { obj } = failure; + const { type, id, meta } = obj; + const title = meta.title || getDefaultTitle(obj); + const icon = meta.icon || DEFAULT_ICON; + const errorMessage = getErrorMessage(failure); + return { type, id, title, icon, outcome: 'error', errorMessage }; +}; + +const mapImportSuccess = (obj: SavedObjectsImportSuccess): ImportItem => { + const { type, id, meta, overwrite } = obj; + const title = meta.title || getDefaultTitle(obj); + const icon = meta.icon || DEFAULT_ICON; + const outcome = overwrite ? 'overwritten' : 'created'; + return { type, id, title, icon, outcome }; +}; + +const getCountIndicators = (importItems: ImportItem[]) => { + if (!importItems.length) { + return null; + } + + const outcomeCounts = importItems.reduce( + (acc, { outcome }) => acc.set(outcome, (acc.get(outcome) ?? 0) + 1), + new Map() + ); + const createdCount = outcomeCounts.get('created'); + const overwrittenCount = outcomeCounts.get('overwritten'); + const errorCount = outcomeCounts.get('error'); + + return ( + + {createdCount && ( + + +

+ +

+
+
+ )} + {overwrittenCount && ( + + +

+ +

+
+
+ )} + {errorCount && ( + + +

+ +

+
+
+ )} +
+ ); +}; + +const getStatusIndicator = ({ outcome, errorMessage }: ImportItem) => { + switch (outcome) { + case 'created': + return ( + + ); + case 'overwritten': + return ( + + ); + case 'error': + return ( + + ); + } +}; + +export const ImportSummary = ({ failedImports, successfulImports }: ImportSummaryProps) => { + const importItems: ImportItem[] = _.sortBy( + [ + ...failedImports.map((x) => mapFailedImport(x)), + ...successfulImports.map((x) => mapImportSuccess(x)), + ], + ['type', 'title'] + ); + + return ( + + +

+ +

+
+ + {getCountIndicators(importItems)} + + {importItems.map((item, index) => { + const { type, title, icon } = item; + return ( + + + + + + + + +

+ {title} +

+
+
+ +
{getStatusIndicator(item)}
+
+
+ ); + })} +
+ ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.test.tsx new file mode 100644 index 0000000000000..c93bc9e5038df --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.test.tsx @@ -0,0 +1,107 @@ +/* + * 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 React from 'react'; +import { shallowWithI18nProvider, mountWithIntl } from 'test_utils/enzyme_helpers'; +import { OverwriteModalProps, OverwriteModal } from './overwrite_modal'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +describe('OverwriteModal', () => { + const obj = { type: 'foo', id: 'bar', meta: { title: 'baz' } }; + const onFinish = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('with a regular conflict', () => { + const props: OverwriteModalProps = { + conflict: { obj, error: { type: 'conflict', destinationId: 'qux' } }, + onFinish, + }; + + it('should render as expected', async () => { + const wrapper = shallowWithI18nProvider(); + + expect(wrapper.find('p').text()).toMatchInlineSnapshot( + `"\\"baz\\" conflicts with an existing object, are you sure you want to overwrite it?"` + ); + expect(wrapper.find('EuiSuperSelect')).toHaveLength(0); + }); + + it('should call onFinish with expected args when Skip is clicked', async () => { + const wrapper = mountWithIntl(); + + expect(onFinish).not.toHaveBeenCalled(); + findTestSubject(wrapper, 'confirmModalCancelButton').simulate('click'); + expect(onFinish).toHaveBeenCalledWith(false); + }); + + it('should call onFinish with expected args when Overwrite is clicked', async () => { + const wrapper = mountWithIntl(); + + expect(onFinish).not.toHaveBeenCalled(); + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + expect(onFinish).toHaveBeenCalledWith(true, 'qux'); + }); + }); + + describe('with an ambiguous conflict', () => { + const props: OverwriteModalProps = { + conflict: { + obj, + error: { + type: 'ambiguous_conflict', + destinations: [ + // TODO: change one of these to have an actual `updatedAt` date string, and mock Moment for the snapshot below + { id: 'qux', title: 'some title', updatedAt: undefined }, + { id: 'quux', title: 'another title', updatedAt: undefined }, + ], + }, + }, + onFinish, + }; + + it('should render as expected', async () => { + const wrapper = shallowWithI18nProvider(); + + expect(wrapper.find('p').text()).toMatchInlineSnapshot( + `"\\"baz\\" conflicts with multiple existing objects, do you want to overwrite one of them?"` + ); + expect(wrapper.find('EuiSuperSelect')).toHaveLength(1); + }); + + it('should call onFinish with expected args when Skip is clicked', async () => { + const wrapper = mountWithIntl(); + + expect(onFinish).not.toHaveBeenCalled(); + findTestSubject(wrapper, 'confirmModalCancelButton').simulate('click'); + expect(onFinish).toHaveBeenCalledWith(false); + }); + + it('should call onFinish with expected args when Overwrite is clicked', async () => { + const wrapper = mountWithIntl(); + + expect(onFinish).not.toHaveBeenCalled(); + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + // first destination is selected by default + expect(onFinish).toHaveBeenCalledWith(true, 'qux'); + }); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx new file mode 100644 index 0000000000000..dbe95161cbeae --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/overwrite_modal.tsx @@ -0,0 +1,139 @@ +/* + * 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 React, { useState, Fragment, ReactNode } from 'react'; +import { + EuiOverlayMask, + EuiConfirmModal, + EUI_MODAL_CONFIRM_BUTTON, + EuiText, + EuiSuperSelect, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { FailedImportConflict } from '../../../lib/resolve_import_errors'; +import { getDefaultTitle } from '../../../lib'; + +export interface OverwriteModalProps { + conflict: FailedImportConflict; + onFinish: (overwrite: boolean, destinationId?: string) => void; +} + +export const OverwriteModal = ({ conflict, onFinish }: OverwriteModalProps) => { + const { obj, error } = conflict; + let initialDestinationId: string | undefined; + let selectControl: ReactNode = null; + if (error.type === 'conflict') { + initialDestinationId = error.destinationId; + } else { + // ambiguous conflict must have at least two destinations; default to the first one + initialDestinationId = error.destinations[0].id; + } + const [destinationId, setDestinationId] = useState(initialDestinationId); + + if (error.type === 'ambiguous_conflict') { + const selectProps = { + options: error.destinations.map((destination) => { + const header = destination.title ?? `${type} [id=${destination.id}]`; + const lastUpdated = destination.updatedAt + ? moment(destination.updatedAt).fromNow() + : 'never'; + const idText = `ID: ${destination.id}`; + const lastUpdatedText = `Last updated: ${lastUpdated}`; + return { + value: destination.id, + inputDisplay: destination.id, + dropdownDisplay: ( + + {header} + +

+ {idText} +
+ {lastUpdatedText} +

+
+
+ ), + }; + }), + onChange: (value: string) => { + setDestinationId(value); + }, + }; + selectControl = ( + + ); + } + + const { type, meta } = obj; + const title = meta.title || getDefaultTitle(obj); + const bodyText = + error.type === 'conflict' + ? i18n.translate('savedObjectsManagement.objectsTable.overwriteModal.body.conflict', { + defaultMessage: + '"{title}" conflicts with an existing object, are you sure you want to overwrite it?', + values: { title }, + }) + : i18n.translate( + 'savedObjectsManagement.objectsTable.overwriteModal.body.ambiguousConflict', + { + defaultMessage: + '"{title}" conflicts with multiple existing objects, do you want to overwrite one of them?', + values: { title }, + } + ); + return ( + + onFinish(false)} + onConfirm={() => onFinish(true, destinationId)} + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + maxWidth="500px" + > +

{bodyText}

+ {selectControl} +
+
+ ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx index 2e545b372f781..ead2738973074 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx @@ -87,7 +87,7 @@ describe('Relationships', () => { const component = shallowWithI18nProvider(); // Make sure we are showing loading - expect(component.find('EuiLoadingKibana').length).toBe(1); + expect(component.find('EuiLoadingElastic').length).toBe(1); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); @@ -154,7 +154,7 @@ describe('Relationships', () => { const component = shallowWithI18nProvider(); // Make sure we are showing loading - expect(component.find('EuiLoadingKibana').length).toBe(1); + expect(component.find('EuiLoadingElastic').length).toBe(1); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); @@ -221,7 +221,7 @@ describe('Relationships', () => { const component = shallowWithI18nProvider(); // Make sure we are showing loading - expect(component.find('EuiLoadingKibana').length).toBe(1); + expect(component.find('EuiLoadingElastic').length).toBe(1); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); @@ -288,7 +288,7 @@ describe('Relationships', () => { const component = shallowWithI18nProvider(); // Make sure we are showing loading - expect(component.find('EuiLoadingKibana').length).toBe(1); + expect(component.find('EuiLoadingElastic').length).toBe(1); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx index cc654f9717bd6..194733433ce29 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx @@ -26,7 +26,7 @@ import { EuiLink, EuiIcon, EuiCallOut, - EuiLoadingKibana, + EuiLoadingElastic, EuiInMemoryTable, EuiToolTip, EuiText, @@ -119,7 +119,7 @@ export class Relationships extends Component; + return ; } const columns = [ diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx index 0c7bf64ca011d..7733a587ca9a7 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx @@ -23,11 +23,14 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import { keys } from '@elastic/eui'; import { httpServiceMock } from '../../../../../../core/public/mocks'; import { actionServiceMock } from '../../../services/action_service.mock'; +import { columnServiceMock } from '../../../services/column_service.mock'; +import { SavedObjectsManagementAction } from '../../..'; import { Table, TableProps } from './table'; const defaultProps: TableProps = { basePath: httpServiceMock.createSetupContract().basePath, actionRegistry: actionServiceMock.createStart(), + columnRegistry: columnServiceMock.createStart(), selectedSavedObjects: [ { id: '1', @@ -50,6 +53,7 @@ const defaultProps: TableProps = { }, filterOptions: [{ value: 2 }], onDelete: () => {}, + onActionRefresh: () => {}, onExport: () => {}, goInspectObject: () => {}, canGoInApp: () => true, @@ -122,4 +126,32 @@ describe('Table', () => { expect(component).toMatchSnapshot(); }); + + it(`allows for automatic refreshing after an action`, () => { + const actionRegistry = actionServiceMock.createStart(); + actionRegistry.getAll.mockReturnValue([ + { + // minimal action mock to exercise this test case + id: 'someAction', + render: () =>
action!
, + refreshOnFinish: () => true, + euiAction: { name: 'foo', description: 'bar', icon: 'beaker', type: 'icon' }, + registerOnFinishCallback: (callback: Function) => callback(), // call the callback immediately for this test + } as SavedObjectsManagementAction, + ]); + const onActionRefresh = jest.fn(); + const customizedProps = { ...defaultProps, actionRegistry, onActionRefresh }; + const component = shallowWithI18nProvider(); + + const table = component.find('EuiBasicTable'); + const columns = table.prop('columns') as any[]; + const actionColumn = columns.find((x) => x.hasOwnProperty('actions')) as { actions: any[] }; + const someAction = actionColumn.actions.find( + (x) => x['data-test-subj'] === 'savedObjectsTableAction-someAction' + ); + + expect(onActionRefresh).not.toHaveBeenCalled(); + someAction.onClick(); + expect(onActionRefresh).toHaveBeenCalled(); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index 719729cee2602..0ce7e6e38962a 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -42,11 +42,13 @@ import { SavedObjectWithMetadata } from '../../../types'; import { SavedObjectsManagementActionServiceStart, SavedObjectsManagementAction, + SavedObjectsManagementColumnServiceStart, } from '../../../services'; export interface TableProps { basePath: IBasePath; actionRegistry: SavedObjectsManagementActionServiceStart; + columnRegistry: SavedObjectsManagementColumnServiceStart; selectedSavedObjects: SavedObjectWithMetadata[]; selectionConfig: { onSelectionChange: (selection: SavedObjectWithMetadata[]) => void; @@ -54,6 +56,7 @@ export interface TableProps { filterOptions: any[]; canDelete: boolean; onDelete: () => void; + onActionRefresh: (object: SavedObjectWithMetadata) => void; onExport: (includeReferencesDeep: boolean) => void; goInspectObject: (obj: SavedObjectWithMetadata) => void; pageIndex: number; @@ -74,6 +77,7 @@ interface TableState { isExportPopoverOpen: boolean; isIncludeReferencesDeepChecked: boolean; activeAction?: SavedObjectsManagementAction; + isColumnDataLoaded: boolean; } export class Table extends PureComponent { @@ -83,12 +87,22 @@ export class Table extends PureComponent { isExportPopoverOpen: false, isIncludeReferencesDeepChecked: true, activeAction: undefined, + isColumnDataLoaded: false, }; constructor(props: TableProps) { super(props); } + componentDidMount() { + this.loadColumnData(); + } + + loadColumnData = async () => { + await Promise.all(this.props.columnRegistry.getAll().map((column) => column.loadData())); + this.setState({ isColumnDataLoaded: true }); + }; + onChange = ({ query, error }: any) => { if (error) { this.setState({ @@ -139,12 +153,14 @@ export class Table extends PureComponent { filterOptions, selectionConfig: selection, onDelete, + onActionRefresh, selectedSavedObjects, onTableChange, goInspectObject, onShowRelationships, basePath, actionRegistry, + columnRegistry, } = this.props; const pagination = { @@ -224,10 +240,18 @@ export class Table extends PureComponent { ); }, } as EuiTableFieldDataColumnType>, + ...columnRegistry.getAll().map((column) => { + return { + ...column.euiColumn, + sortable: false, + 'data-test-subj': `savedObjectsTableColumn-${column.id}`, + }; + }), { name: i18n.translate('savedObjectsManagement.objectsTable.table.columnActionsName', { defaultMessage: 'Actions', }), + width: '80px', actions: [ { name: i18n.translate( @@ -274,6 +298,10 @@ export class Table extends PureComponent { this.setState({ activeAction: undefined, }); + const { refreshOnFinish = () => false } = action; + if (refreshOnFinish()) { + onActionRefresh(object); + } }); if (action.euiAction.onClick) { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 3719dac24e6e7..1bc3dc8066520 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -41,6 +41,7 @@ import { import { dataPluginMock } from '../../../../data/public/mocks'; import { serviceRegistryMock } from '../../services/service_registry.mock'; import { actionServiceMock } from '../../services/action_service.mock'; +import { columnServiceMock } from '../../services/column_service.mock'; import { SavedObjectsTable, SavedObjectsTableProps, @@ -134,6 +135,7 @@ describe('SavedObjectsTable', () => { allowedTypes, serviceRegistry: serviceRegistryMock.create(), actionRegistry: actionServiceMock.createStart(), + columnRegistry: columnServiceMock.createStart(), savedObjectsClient: savedObjects.client, indexPatterns: dataPluginMock.createStartContract().indexPatterns, http, diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 340c0e3237f91..d879a71cc2269 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -27,7 +27,7 @@ import { EuiInMemoryTable, EuiIcon, EuiConfirmModal, - EuiLoadingKibana, + EuiLoadingElastic, EuiOverlayMask, EUI_MODAL_CONFIRM_BUTTON, EuiCheckboxGroup, @@ -65,6 +65,7 @@ import { fetchExportObjects, fetchExportByTypeAndSearch, findObjects, + findObject, extractExportDetails, SavedObjectsExportResultDetails, } from '../../lib'; @@ -72,6 +73,7 @@ import { SavedObjectWithMetadata } from '../../types'; import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, + SavedObjectsManagementColumnServiceStart, } from '../../services'; import { Header, Table, Flyout, Relationships } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; @@ -85,6 +87,7 @@ export interface SavedObjectsTableProps { allowedTypes: string[]; serviceRegistry: ISavedObjectsManagementServiceRegistry; actionRegistry: SavedObjectsManagementActionServiceStart; + columnRegistry: SavedObjectsManagementColumnServiceStart; savedObjectsClient: SavedObjectsClientContract; indexPatterns: IndexPatternsContract; http: HttpStart; @@ -157,7 +160,7 @@ export class SavedObjectsTable extends Component { @@ -202,15 +205,14 @@ export class SavedObjectsTable extends Component { - this.setState( - { - isSearching: true, - }, - this.debouncedFetch - ); + this.setState({ isSearching: true }, this.debouncedFetchObjects); }; - debouncedFetch = debounce(async () => { + fetchSavedObject = (type: string, id: string) => { + this.setState({ isSearching: true }, () => this.debouncedFetchObject(type, id)); + }; + + debouncedFetchObjects = debounce(async () => { const { activeQuery: query, page, perPage } = this.state; const { notifications, http, allowedTypes } = this.props; const { queryText, visibleTypes } = parseQuery(query); @@ -261,10 +263,48 @@ export class SavedObjectsTable extends Component { + debouncedFetchObject = debounce(async (type: string, id: string) => { + const { notifications, http } = this.props; + try { + const resp = await findObject(http, type, id); + if (!this._isMounted) { + return; + } + + this.setState(({ savedObjects, filteredItemCount }) => { + const refreshedSavedObjects = savedObjects.map((object) => + object.type === type && object.id === id ? resp : object + ); + return { + savedObjects: refreshedSavedObjects, + filteredItemCount, + isSearching: false, + }; + }); + } catch (error) { + if (this._isMounted) { + this.setState({ + isSearching: false, + }); + } + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.unableFindSavedObjectNotificationMessage', + { defaultMessage: 'Unable to find saved object' } + ), + text: `${error}`, + }); + } + }, 300); + + refreshObjects = async () => { await Promise.all([this.fetchSavedObjects(), this.fetchCounts()]); }; + refreshObject = async ({ type, id }: SavedObjectWithMetadata) => { + await this.fetchSavedObject(type, id); + }; + onSelectionChanged = (selection: SavedObjectWithMetadata[]) => { this.setState({ selectedSavedObjects: selection }); }; @@ -505,7 +545,7 @@ export class SavedObjectsTable extends Component; + modal = ; } else { const onCancel = () => { this.setState({ isShowingDeleteConfirmModal: false }); @@ -731,7 +771,7 @@ export class SavedObjectsTable extends Component this.setState({ isShowingExportAllOptionsModal: true })} onImport={this.showImportFlyout} - onRefresh={this.refreshData} + onRefresh={this.refreshObjects} filteredCount={filteredItemCount} /> @@ -740,6 +780,7 @@ export class SavedObjectsTable extends Component void; }) => { const capabilities = coreStart.application.capabilities; @@ -62,6 +65,7 @@ const SavedObjectsTablePage = ({ allowedTypes={allowedTypes} serviceRegistry={serviceRegistry} actionRegistry={actionRegistry} + columnRegistry={columnRegistry} savedObjectsClient={coreStart.savedObjects.client} indexPatterns={dataStart.indexPatterns} search={dataStart.search} diff --git a/src/plugins/saved_objects_management/public/mocks.ts b/src/plugins/saved_objects_management/public/mocks.ts index 1de3de8e85302..3bd5a70884d85 100644 --- a/src/plugins/saved_objects_management/public/mocks.ts +++ b/src/plugins/saved_objects_management/public/mocks.ts @@ -18,12 +18,14 @@ */ import { actionServiceMock } from './services/action_service.mock'; +import { columnServiceMock } from './services/column_service.mock'; import { serviceRegistryMock } from './services/service_registry.mock'; import { SavedObjectsManagementPluginSetup, SavedObjectsManagementPluginStart } from './plugin'; const createSetupContractMock = (): jest.Mocked => { const mock = { actions: actionServiceMock.createSetup(), + columns: columnServiceMock.createSetup(), serviceRegistry: serviceRegistryMock.create(), }; return mock; @@ -32,6 +34,7 @@ const createSetupContractMock = (): jest.Mocked => { const mock = { actions: actionServiceMock.createStart(), + columns: columnServiceMock.createStart(), }; return mock; }; diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index ac30c63409760..907352f52699e 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -29,6 +29,9 @@ import { SavedObjectsManagementActionService, SavedObjectsManagementActionServiceSetup, SavedObjectsManagementActionServiceStart, + SavedObjectsManagementColumnService, + SavedObjectsManagementColumnServiceSetup, + SavedObjectsManagementColumnServiceStart, SavedObjectsManagementServiceRegistry, ISavedObjectsManagementServiceRegistry, } from './services'; @@ -36,11 +39,13 @@ import { registerServices } from './register_services'; export interface SavedObjectsManagementPluginSetup { actions: SavedObjectsManagementActionServiceSetup; + columns: SavedObjectsManagementColumnServiceSetup; serviceRegistry: ISavedObjectsManagementServiceRegistry; } export interface SavedObjectsManagementPluginStart { actions: SavedObjectsManagementActionServiceStart; + columns: SavedObjectsManagementColumnServiceStart; } export interface SetupDependencies { @@ -64,6 +69,7 @@ export class SavedObjectsManagementPlugin StartDependencies > { private actionService = new SavedObjectsManagementActionService(); + private columnService = new SavedObjectsManagementColumnService(); private serviceRegistry = new SavedObjectsManagementServiceRegistry(); public setup( @@ -71,6 +77,7 @@ export class SavedObjectsManagementPlugin { home, management }: SetupDependencies ): SavedObjectsManagementPluginSetup { const actionSetup = this.actionService.setup(); + const columnSetup = this.columnService.setup(); if (home) { home.featureCatalogue.register({ @@ -111,15 +118,18 @@ export class SavedObjectsManagementPlugin return { actions: actionSetup, + columns: columnSetup, serviceRegistry: this.serviceRegistry, }; } public start(core: CoreStart, { data }: StartDependencies) { const actionStart = this.actionService.start(); + const columnStart = this.columnService.start(); return { actions: actionStart, + columns: columnStart, }; } } diff --git a/src/plugins/saved_objects_management/public/services/column_service.mock.ts b/src/plugins/saved_objects_management/public/services/column_service.mock.ts new file mode 100644 index 0000000000000..977b2099771ba --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/column_service.mock.ts @@ -0,0 +1,57 @@ +/* + * 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 { + SavedObjectsManagementColumnService, + SavedObjectsManagementColumnServiceSetup, + SavedObjectsManagementColumnServiceStart, +} from './column_service'; + +const createSetupMock = (): jest.Mocked => { + const mock = { + register: jest.fn(), + }; + return mock; +}; + +const createStartMock = (): jest.Mocked => { + const mock = { + has: jest.fn(), + getAll: jest.fn(), + }; + + mock.has.mockReturnValue(true); + mock.getAll.mockReturnValue([]); + + return mock; +}; + +const createServiceMock = (): jest.Mocked> => { + const mock = { + setup: jest.fn().mockReturnValue(createSetupMock()), + start: jest.fn().mockReturnValue(createStartMock()), + }; + return mock; +}; + +export const columnServiceMock = { + create: createServiceMock, + createSetup: createSetupMock, + createStart: createStartMock, +}; diff --git a/src/plugins/saved_objects_management/public/services/column_service.test.ts b/src/plugins/saved_objects_management/public/services/column_service.test.ts new file mode 100644 index 0000000000000..367422b0bbe11 --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/column_service.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { + SavedObjectsManagementColumnService, + SavedObjectsManagementColumnServiceSetup, +} from './column_service'; +import { SavedObjectsManagementColumn } from './types'; + +class DummyColumn implements SavedObjectsManagementColumn { + constructor(public id: string) {} + + public euiColumn = { + field: 'id', + name: 'name', + }; + + public loadData = async () => {}; +} + +describe('SavedObjectsManagementColumnRegistry', () => { + let service: SavedObjectsManagementColumnService; + let setup: SavedObjectsManagementColumnServiceSetup; + + const createColumn = (id: string): SavedObjectsManagementColumn => { + return new DummyColumn(id); + }; + + beforeEach(() => { + service = new SavedObjectsManagementColumnService(); + setup = service.setup(); + }); + + describe('#register', () => { + it('allows columns to be registered and retrieved', () => { + const column = createColumn('foo'); + setup.register(column); + const start = service.start(); + expect(start.getAll()).toContain(column); + }); + + it('does not allow columns with duplicate ids to be registered', () => { + const column = createColumn('my-column'); + setup.register(column); + expect(() => setup.register(column)).toThrowErrorMatchingInlineSnapshot( + `"Saved Objects Management Column with id 'my-column' already exists"` + ); + }); + }); +}); diff --git a/src/plugins/saved_objects_management/public/services/column_service.ts b/src/plugins/saved_objects_management/public/services/column_service.ts new file mode 100644 index 0000000000000..5006d9df813cf --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/column_service.ts @@ -0,0 +1,55 @@ +/* + * 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 { SavedObjectsManagementColumn } from './types'; + +export interface SavedObjectsManagementColumnServiceSetup { + /** + * register given column in the registry. + */ + register: (column: SavedObjectsManagementColumn) => void; +} + +export interface SavedObjectsManagementColumnServiceStart { + /** + * return all {@link SavedObjectsManagementColumn | columns} currently registered. + */ + getAll: () => Array>; +} + +export class SavedObjectsManagementColumnService { + private readonly columns = new Map>(); + + setup(): SavedObjectsManagementColumnServiceSetup { + return { + register: (column) => { + if (this.columns.has(column.id)) { + throw new Error(`Saved Objects Management Column with id '${column.id}' already exists`); + } + this.columns.set(column.id, column); + }, + }; + } + + start(): SavedObjectsManagementColumnServiceStart { + return { + getAll: () => [...this.columns.values()], + }; + } +} diff --git a/src/plugins/saved_objects_management/public/services/index.ts b/src/plugins/saved_objects_management/public/services/index.ts index a59ad9012c402..f3379a3e29702 100644 --- a/src/plugins/saved_objects_management/public/services/index.ts +++ b/src/plugins/saved_objects_management/public/services/index.ts @@ -22,9 +22,18 @@ export { SavedObjectsManagementActionServiceStart, SavedObjectsManagementActionServiceSetup, } from './action_service'; +export { + SavedObjectsManagementColumnService, + SavedObjectsManagementColumnServiceStart, + SavedObjectsManagementColumnServiceSetup, +} from './column_service'; export { SavedObjectsManagementServiceRegistry, ISavedObjectsManagementServiceRegistry, SavedObjectsManagementServiceRegistryEntry, } from './service_registry'; -export { SavedObjectsManagementAction, SavedObjectsManagementRecord } from './types'; +export { + SavedObjectsManagementAction, + SavedObjectsManagementColumn, + SavedObjectsManagementRecord, +} from './types'; diff --git a/src/plugins/saved_objects_management/public/services/types.ts b/src/plugins/saved_objects_management/public/services/types/action.ts similarity index 86% rename from src/plugins/saved_objects_management/public/services/types.ts rename to src/plugins/saved_objects_management/public/services/types/action.ts index c2f807f63b1b9..2ead55d1f4338 100644 --- a/src/plugins/saved_objects_management/public/services/types.ts +++ b/src/plugins/saved_objects_management/public/services/types/action.ts @@ -17,18 +17,8 @@ * under the License. */ -import { ReactNode } from 'react'; -import { SavedObjectReference } from 'src/core/public'; - -export interface SavedObjectsManagementRecord { - type: string; - id: string; - meta: { - icon: string; - title: string; - }; - references: SavedObjectReference[]; -} +import { ReactNode } from '@elastic/eui/node_modules/@types/react'; +import { SavedObjectsManagementRecord } from '.'; export abstract class SavedObjectsManagementAction { public abstract render: () => ReactNode; @@ -43,6 +33,7 @@ export abstract class SavedObjectsManagementAction { onClick?: (item: SavedObjectsManagementRecord) => void; render?: (item: SavedObjectsManagementRecord) => any; }; + public refreshOnFinish?: () => boolean; private callbacks: Function[] = []; diff --git a/src/plugins/saved_objects_management/public/services/types/column.ts b/src/plugins/saved_objects_management/public/services/types/column.ts new file mode 100644 index 0000000000000..79ee4d649177f --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/types/column.ts @@ -0,0 +1,29 @@ +/* + * 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 { EuiTableFieldDataColumnType } from '@elastic/eui'; +import { SavedObjectsManagementRecord } from '.'; + +export interface SavedObjectsManagementColumn { + id: string; + euiColumn: Omit, 'sortable'>; + + data?: T; + loadData: () => Promise; +} diff --git a/src/plugins/saved_objects_management/public/services/types/index.ts b/src/plugins/saved_objects_management/public/services/types/index.ts new file mode 100644 index 0000000000000..667ba8a683d8d --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/types/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { SavedObjectsManagementAction } from './action'; +export { SavedObjectsManagementColumn } from './column'; +export { SavedObjectsManagementRecord } from './record'; diff --git a/src/plugins/saved_objects_management/public/services/types/record.ts b/src/plugins/saved_objects_management/public/services/types/record.ts new file mode 100644 index 0000000000000..9e00935e674ad --- /dev/null +++ b/src/plugins/saved_objects_management/public/services/types/record.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. + */ + +import { SavedObjectReference, SavedObjectsNamespaceType } from 'src/core/public'; + +export interface SavedObjectsManagementRecord { + type: string; + id: string; + meta: { + icon: string; + title: string; + namespaceType: SavedObjectsNamespaceType; + }; + references: SavedObjectReference[]; + namespaces?: string[]; +} diff --git a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts index 0c0f9d8feb506..11e685bd198e4 100644 --- a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts +++ b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.test.ts @@ -34,6 +34,7 @@ describe('injectMetaAttributes', () => { path: 'path', uiCapabilitiesPath: 'uiCapabilitiesPath', }); + managementService.getNamespaceType.mockReturnValue('single'); }); it('inject the metadata to the obj', () => { @@ -58,6 +59,7 @@ describe('injectMetaAttributes', () => { path: 'path', uiCapabilitiesPath: 'uiCapabilitiesPath', }, + namespaceType: 'single', }, }); }); diff --git a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts index 615caffd3b60b..54cad2d54e60a 100644 --- a/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts +++ b/src/plugins/saved_objects_management/server/lib/inject_meta_attributes.ts @@ -35,6 +35,7 @@ export function injectMetaAttributes( result.meta.title = savedObjectsManagement.getTitle(savedObject); result.meta.editUrl = savedObjectsManagement.getEditUrl(savedObject); result.meta.inAppUrl = savedObjectsManagement.getInAppUrl(savedObject); + result.meta.namespaceType = savedObjectsManagement.getNamespaceType(savedObject); return result; } diff --git a/src/plugins/saved_objects_management/server/routes/get.ts b/src/plugins/saved_objects_management/server/routes/get.ts new file mode 100644 index 0000000000000..a2c12a3970523 --- /dev/null +++ b/src/plugins/saved_objects_management/server/routes/get.ts @@ -0,0 +1,51 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { injectMetaAttributes } from '../lib'; +import { ISavedObjectsManagement } from '../services'; + +export const registerGetRoute = ( + router: IRouter, + managementServicePromise: Promise +) => { + router.get( + { + path: '/api/kibana/management/saved_objects/{type}/{id}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const managementService = await managementServicePromise; + const { client } = context.core.savedObjects; + + const { type, id } = req.params; + const findResponse = await client.get(type, id); + + const enhancedSavedObject = injectMetaAttributes(findResponse, managementService); + + return res.ok({ body: enhancedSavedObject }); + }) + ); +}; diff --git a/src/plugins/saved_objects_management/server/routes/index.test.ts b/src/plugins/saved_objects_management/server/routes/index.test.ts index 237760444f04e..b39262f0c8b3c 100644 --- a/src/plugins/saved_objects_management/server/routes/index.test.ts +++ b/src/plugins/saved_objects_management/server/routes/index.test.ts @@ -34,7 +34,7 @@ describe('registerRoutes', () => { }); expect(httpSetup.createRouter).toHaveBeenCalledTimes(1); - expect(router.get).toHaveBeenCalledTimes(3); + expect(router.get).toHaveBeenCalledTimes(4); expect(router.post).toHaveBeenCalledTimes(2); expect(router.get).toHaveBeenCalledWith( @@ -43,6 +43,12 @@ describe('registerRoutes', () => { }), expect.any(Function) ); + expect(router.get).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/kibana/management/saved_objects/{type}/{id}', + }), + expect.any(Function) + ); expect(router.get).toHaveBeenCalledWith( expect.objectContaining({ path: '/api/kibana/management/saved_objects/relationships/{type}/{id}', diff --git a/src/plugins/saved_objects_management/server/routes/index.ts b/src/plugins/saved_objects_management/server/routes/index.ts index 0929de56b215e..e074a0d5cbee2 100644 --- a/src/plugins/saved_objects_management/server/routes/index.ts +++ b/src/plugins/saved_objects_management/server/routes/index.ts @@ -20,6 +20,7 @@ import { HttpServiceSetup } from 'src/core/server'; import { ISavedObjectsManagement } from '../services'; import { registerFindRoute } from './find'; +import { registerGetRoute } from './get'; import { registerScrollForCountRoute } from './scroll_count'; import { registerScrollForExportRoute } from './scroll_export'; import { registerRelationshipsRoute } from './relationships'; @@ -33,6 +34,7 @@ interface RegisterRouteOptions { export function registerRoutes({ http, managementServicePromise }: RegisterRouteOptions) { const router = http.createRouter(); registerFindRoute(router, managementServicePromise); + registerGetRoute(router, managementServicePromise); registerScrollForCountRoute(router); registerScrollForExportRoute(router); registerRelationshipsRoute(router, managementServicePromise); diff --git a/src/plugins/saved_objects_management/server/services/management.mock.ts b/src/plugins/saved_objects_management/server/services/management.mock.ts index 2099cc0f77bcc..85c2d3e4b08d9 100644 --- a/src/plugins/saved_objects_management/server/services/management.mock.ts +++ b/src/plugins/saved_objects_management/server/services/management.mock.ts @@ -28,6 +28,7 @@ const createManagementMock = () => { getTitle: jest.fn(), getEditUrl: jest.fn(), getInAppUrl: jest.fn(), + getNamespaceType: jest.fn(), }; return mocked; }; diff --git a/src/plugins/saved_objects_management/server/services/management.test.ts b/src/plugins/saved_objects_management/server/services/management.test.ts index 3625a3f913444..7ddde312767de 100644 --- a/src/plugins/saved_objects_management/server/services/management.test.ts +++ b/src/plugins/saved_objects_management/server/services/management.test.ts @@ -198,4 +198,28 @@ describe('SavedObjectsManagement', () => { expect(result).toEqual({ path: 'called', uiCapabilitiesPath: 'my.path' }); }); }); + + describe('getNamespaceType()', () => { + it('returns empty for unknown type', () => { + const result = management.getNamespaceType({ + id: '1', + type: 'foo', + attributes: {}, + references: [], + }); + expect(result).toEqual(undefined); + }); + + it('returns explicit value', () => { + registerType({ name: 'foo', namespaceType: 'single' }); + + const result = management.getNamespaceType({ + id: '1', + type: 'foo', + attributes: {}, + references: [], + }); + expect(result).toEqual('single'); + }); + }); }); diff --git a/src/plugins/saved_objects_management/server/services/management.ts b/src/plugins/saved_objects_management/server/services/management.ts index 7aee974182497..499f37990c346 100644 --- a/src/plugins/saved_objects_management/server/services/management.ts +++ b/src/plugins/saved_objects_management/server/services/management.ts @@ -50,4 +50,8 @@ export class SavedObjectsManagement { const getInAppUrl = this.registry.getType(savedObject.type)?.management?.getInAppUrl; return getInAppUrl ? getInAppUrl(savedObject) : undefined; } + + public getNamespaceType(savedObject: SavedObject) { + return this.registry.getType(savedObject.type)?.namespaceType; + } } diff --git a/test/api_integration/apis/saved_objects/import.js b/test/api_integration/apis/saved_objects/import.js index fbacfe458d976..1666df2c83e5a 100644 --- a/test/api_integration/apis/saved_objects/import.js +++ b/test/api_integration/apis/saved_objects/import.js @@ -25,25 +25,33 @@ export default function ({ getService }) { const esArchiver = getService('esArchiver'); describe('import', () => { + // mock success results including metadata + const indexPattern = { + type: 'index-pattern', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + meta: { title: 'logstash-*', icon: 'indexPatternApp' }, + }; + const visualization = { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + meta: { title: 'Count of requests', icon: 'visualizeApp' }, + }; + const dashboard = { + type: 'dashboard', + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + meta: { title: 'Requests', icon: 'dashboardApp' }, + }; + const createError = (object, type) => ({ + ...object, + title: object.meta.title, + error: { type }, + }); + describe('with kibana index', () => { describe('with basic data existing', () => { before(() => esArchiver.load('saved_objects/basic')); after(() => esArchiver.unload('saved_objects/basic')); - it('should return 200', async () => { - await supertest - .post('/api/saved_objects/_import') - .query({ overwrite: true }) - .attach('file', join(__dirname, '../../fixtures/import.ndjson')) - .expect(200) - .then((resp) => { - expect(resp.body).to.eql({ - success: true, - successCount: 3, - }); - }); - }); - it('should return 415 when no file passed in', async () => { await supertest .post('/api/saved_objects/_import') @@ -67,30 +75,9 @@ export default function ({ getService }) { success: false, successCount: 0, errors: [ - { - id: '91200a00-9efd-11e7-acb3-3dab96693fab', - type: 'index-pattern', - title: 'logstash-*', - error: { - type: 'conflict', - }, - }, - { - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - type: 'visualization', - title: 'Count of requests', - error: { - type: 'conflict', - }, - }, - { - id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', - type: 'dashboard', - title: 'Requests', - error: { - type: 'conflict', - }, - }, + createError(indexPattern, 'conflict'), + createError(visualization, 'conflict'), + createError(dashboard, 'conflict'), ], }); }); @@ -99,15 +86,18 @@ export default function ({ getService }) { it('should return 200 when conflicts exist but overwrite is passed in', async () => { await supertest .post('/api/saved_objects/_import') - .query({ - overwrite: true, - }) + .query({ overwrite: true }) .attach('file', join(__dirname, '../../fixtures/import.ndjson')) .expect(200) .then((resp) => { expect(resp.body).to.eql({ success: true, successCount: 3, + successResults: [ + { ...indexPattern, overwrite: true }, + { ...visualization, overwrite: true }, + { ...dashboard, overwrite: true }, + ], }); }); }); @@ -130,9 +120,8 @@ export default function ({ getService }) { id: '1', type: 'wigwags', title: 'my title', - error: { - type: 'unsupported_type', - }, + meta: { title: 'my title' }, + error: { type: 'unsupported_type' }, }, ], }); @@ -162,7 +151,7 @@ export default function ({ getService }) { JSON.stringify({ type: 'visualization', id: '1', - attributes: {}, + attributes: { title: 'My visualization' }, references: [ { name: 'ref_0', @@ -189,9 +178,10 @@ export default function ({ getService }) { { type: 'visualization', id: '1', + title: 'My visualization', + meta: { title: 'My visualization', icon: 'visualizeApp' }, error: { type: 'missing_references', - blocking: [], references: [ { type: 'index-pattern', diff --git a/test/api_integration/apis/saved_objects/resolve_import_errors.js b/test/api_integration/apis/saved_objects/resolve_import_errors.js index aacfcd4382fac..5380e9c3d11d8 100644 --- a/test/api_integration/apis/saved_objects/resolve_import_errors.js +++ b/test/api_integration/apis/saved_objects/resolve_import_errors.js @@ -25,6 +25,23 @@ export default function ({ getService }) { const esArchiver = getService('esArchiver'); describe('resolve_import_errors', () => { + // mock success results including metadata + const indexPattern = { + type: 'index-pattern', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + meta: { title: 'logstash-*', icon: 'indexPatternApp' }, + }; + const visualization = { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + meta: { title: 'Count of requests', icon: 'visualizeApp' }, + }; + const dashboard = { + type: 'dashboard', + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + meta: { title: 'Requests', icon: 'dashboardApp' }, + }; + describe('without kibana index', () => { // Cleanup data that got created in import after(() => esArchiver.unload('saved_objects/basic')); @@ -72,6 +89,11 @@ export default function ({ getService }) { expect(resp.body).to.eql({ success: true, successCount: 3, + successResults: [ + { ...indexPattern, overwrite: true }, + { ...visualization, overwrite: true }, + { ...dashboard, overwrite: true }, + ], }); }); }); @@ -109,9 +131,8 @@ export default function ({ getService }) { id: '1', type: 'wigwags', title: 'my title', - error: { - type: 'unsupported_type', - }, + meta: { title: 'my title' }, + error: { type: 'unsupported_type' }, }, ], }); @@ -175,9 +196,9 @@ export default function ({ getService }) { id: '1', type: 'visualization', title: 'My favorite vis', + meta: { title: 'My favorite vis', icon: 'visualizeApp' }, error: { type: 'missing_references', - blocking: [], references: [ { type: 'index-pattern', @@ -234,7 +255,15 @@ export default function ({ getService }) { .attach('file', join(__dirname, '../../fixtures/import.ndjson')) .expect(200) .then((resp) => { - expect(resp.body).to.eql({ success: true, successCount: 3 }); + expect(resp.body).to.eql({ + success: true, + successCount: 3, + successResults: [ + { ...indexPattern, overwrite: true }, + { ...visualization, overwrite: true }, + { ...dashboard, overwrite: true }, + ], + }); }); }); @@ -254,7 +283,11 @@ export default function ({ getService }) { .attach('file', join(__dirname, '../../fixtures/import.ndjson')) .expect(200) .then((resp) => { - expect(resp.body).to.eql({ success: true, successCount: 1 }); + expect(resp.body).to.eql({ + success: true, + successCount: 1, + successResults: [{ ...visualization, overwrite: true }], + }); }); }); @@ -298,6 +331,13 @@ export default function ({ getService }) { expect(resp.body).to.eql({ success: true, successCount: 1, + successResults: [ + { + type: 'visualization', + id: '1', + meta: { title: 'My favorite vis', icon: 'visualizeApp' }, + }, + ], }); }); await supertest diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 08c4327d7c0c4..c1c78570d8fe1 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -68,6 +68,7 @@ export default function ({ getService }: FtrProviderContext) { uiCapabilitiesPath: 'visualize.show', }, title: 'Count of requests', + namespaceType: 'single', }, }, ], @@ -225,6 +226,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }); })); @@ -243,6 +245,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', }, + namespaceType: 'single', }); })); @@ -261,6 +264,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }); expect(resp.body.saved_objects[1].meta).to.eql({ icon: 'visualizeApp', @@ -271,6 +275,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }); })); @@ -290,6 +295,7 @@ export default function ({ getService }: FtrProviderContext) { '/app/management/kibana/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.kibana.indexPatterns', }, + namespaceType: 'single', }); })); }); diff --git a/test/api_integration/apis/saved_objects_management/get.ts b/test/api_integration/apis/saved_objects_management/get.ts new file mode 100644 index 0000000000000..8eb4cd7ab9a43 --- /dev/null +++ b/test/api_integration/apis/saved_objects_management/get.ts @@ -0,0 +1,69 @@ +/* + * 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 expect from '@kbn/expect'; +import { Response } from 'supertest'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const es = getService('legacyEs'); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('get', () => { + const existingObject = 'visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab'; + const nonexistentObject = 'wigwags/foo'; + + describe('with kibana index', () => { + before(() => esArchiver.load('saved_objects/basic')); + after(() => esArchiver.unload('saved_objects/basic')); + + it('should return 200 for object that exists and inject metadata', async () => + await supertest + .get(`/api/kibana/management/saved_objects/${existingObject}`) + .expect(200) + .then((resp: Response) => { + const { body } = resp; + const { type, id, meta } = body; + expect(type).to.eql('visualization'); + expect(id).to.eql('dd7caf20-9efd-11e7-acb3-3dab96693fab'); + expect(meta).to.not.equal(undefined); + })); + + it('should return 404 for object that does not exist', async () => + await supertest + .get(`/api/kibana/management/saved_objects/${nonexistentObject}`) + .expect(404)); + }); + + describe('without kibana index', () => { + before( + async () => + // just in case the kibana server has recreated it + await es.indices.delete({ + index: '.kibana', + ignore: [404], + }) + ); + + it('should return 404 for object that no longer exists', async () => + await supertest.get(`/api/kibana/management/saved_objects/${existingObject}`).expect(404)); + }); + }); +} diff --git a/test/api_integration/apis/saved_objects_management/index.ts b/test/api_integration/apis/saved_objects_management/index.ts index 9f13e4fc5975d..a5db29a6200f3 100644 --- a/test/api_integration/apis/saved_objects_management/index.ts +++ b/test/api_integration/apis/saved_objects_management/index.ts @@ -22,6 +22,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('saved objects management apis', () => { loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./relationships')); loadTestFile(require.resolve('./scroll_count')); }); diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index a1ea65645c13f..8b7837f80ee44 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -38,6 +38,7 @@ export default function ({ getService }: FtrProviderContext) { path: schema.string(), uiCapabilitiesPath: schema.string(), }), + namespaceType: schema.string(), }), }) ); @@ -89,6 +90,7 @@ export default function ({ getService }: FtrProviderContext) { '/app/management/kibana/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.kibana.indexPatterns', }, + namespaceType: 'single', }, }, { @@ -104,6 +106,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, }, ]); @@ -130,6 +133,7 @@ export default function ({ getService }: FtrProviderContext) { '/app/management/kibana/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.kibana.indexPatterns', }, + namespaceType: 'single', }, relationship: 'child', }, @@ -145,6 +149,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, relationship: 'parent', }, @@ -189,6 +194,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, }, { @@ -204,6 +210,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, }, ]); @@ -227,6 +234,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, relationship: 'child', }, @@ -242,6 +250,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, relationship: 'child', }, @@ -286,6 +295,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }, }, { @@ -301,6 +311,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', }, + namespaceType: 'single', }, }, ]); @@ -326,6 +337,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }, relationship: 'child', }, @@ -369,6 +381,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }, }, { @@ -384,6 +397,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', }, + namespaceType: 'single', }, }, ]); @@ -409,6 +423,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', }, + namespaceType: 'single', }, relationship: 'parent', }, diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index ad82ea9b6fbc1..e165341dbd63d 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -48,7 +48,13 @@ export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProv if (!overwriteAll) { log.debug(`Toggling overwriteAll`); - await testSubjects.click('importSavedObjectsOverwriteToggle'); + const radio = await testSubjects.find( + 'savedObjectsManagement-importModeControl-overwriteRadioGroup' + ); + // a radio button consists of a div tag that contains an input, a div, and a label + // we can't click the input directly, need to go up one level and click the parent div + const div = await radio.findByXpath("//div[input[@id='overwriteDisabled']]"); + await div.click(); } else { log.debug(`Leaving overwriteAll alone`); } diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 5d4ea5a6370e4..f8d66b8ecac27 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -42,6 +42,19 @@ beforeEach(() => { afterEach(() => jest.clearAllMocks()); +describe('#checkConflicts', () => { + it('redirects request to underlying base client', async () => { + const objects = [{ type: 'foo', id: 'bar' }]; + const options = { namespace: 'some-namespace' }; + const mockedResponse = { errors: [] }; + mockBaseClient.checkConflicts.mockResolvedValue(mockedResponse); + + await expect(wrapper.checkConflicts(objects, options)).resolves.toEqual(mockedResponse); + expect(mockBaseClient.checkConflicts).toHaveBeenCalledTimes(1); + expect(mockBaseClient.checkConflicts).toHaveBeenCalledWith(objects, options); + }); +}); + describe('#create', () => { it('redirects request to underlying base client if type is not registered', async () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index 3246457179f68..a2725cbc6a274 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -13,6 +13,7 @@ import { SavedObjectsBulkUpdateObject, SavedObjectsBulkResponse, SavedObjectsBulkUpdateResponse, + SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsFindOptions, @@ -48,6 +49,13 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon public readonly errors = options.baseClient.errors ) {} + public async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options?: SavedObjectsBaseOptions + ) { + return await this.options.baseClient.checkConflicts(objects, options); + } + public async create( type: string, attributes: T = {} as T, 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 index bfbc8b68c3d2c..e4014cf49778c 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -323,6 +323,7 @@ Array [ "edit", "delete", "copyIntoSpace", + "shareIntoSpace", ], }, "privilegeId": "all", diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 9df042b45a32e..e37c7491de5dc 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -349,7 +349,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS all: [...savedObjectTypes], read: [], }, - ui: ['read', 'edit', 'delete', 'copyIntoSpace'], + ui: ['read', 'edit', 'delete', 'copyIntoSpace', 'shareIntoSpace'], }, read: { app: ['kibana'], diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts index ff1a91b00d84f..201003629e5ea 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts @@ -14,7 +14,7 @@ import * as Registry from '../../registry'; import { AssetType, KibanaAssetType, AssetReference } from '../../../../types'; import { savedObjectTypes } from '../../packages'; -type SavedObjectToBe = Required> & { +type SavedObjectToBe = Required> & { type: AssetType; }; export type ArchiveAsset = Pick< diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index ca191602dcf44..7f7f969e8b480 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -62,7 +62,7 @@ const expectGeneralError = async (fn: Function, args: Record) => { * Requires that function args are passed in as key/value pairs * The argument properties must be in the correct order to be spread properly */ -const expectForbiddenError = async (fn: Function, args: Record) => { +const expectForbiddenError = async (fn: Function, args: Record, action?: string) => { clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( getMockCheckPrivilegesFailure ); @@ -87,7 +87,7 @@ const expectForbiddenError = async (fn: Function, args: Record) => expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( USERNAME, - ACTION, + action ?? ACTION, types, spaceIds, missing, @@ -96,7 +96,7 @@ const expectForbiddenError = async (fn: Function, args: Record) => expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }; -const expectSuccess = async (fn: Function, args: Record) => { +const expectSuccess = async (fn: Function, args: Record, action?: string) => { const result = await fn.bind(client)(...Object.values(args)); const getCalls = (clientOpts.actions.savedObject.get as jest.MockedFunction< SavedObjectActions['get'] @@ -109,7 +109,7 @@ const expectSuccess = async (fn: Function, args: Record) => { expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( USERNAME, - ACTION, + action ?? ACTION, types, spaceIds, args @@ -492,6 +492,40 @@ describe('#bulkUpdate', () => { }); }); +describe('#checkConflicts', () => { + const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); + const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { + const objects = [obj1, obj2]; + await expectGeneralError(client.checkConflicts, { objects }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const objects = [obj1, obj2]; + await expectForbiddenError(client.checkConflicts, { objects, options }, 'checkConflicts'); + }); + + test(`returns result of baseClient.create when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.checkConflicts.mockResolvedValue(apiCallReturnValue as any); + + const objects = [obj1, obj2]; + const result = await expectSuccess( + client.checkConflicts, + { objects, options }, + 'checkConflicts' + ); + expect(result).toBe(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const objects = [obj1, obj2]; + await expectPrivilegeCheck(client.checkConflicts, { objects, options }); + }); +}); + describe('#create', () => { const type = 'foo'; const attributes = { some_attr: 's' }; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 9fd8a732c4eab..68fe65d204d6d 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -9,6 +9,7 @@ import { SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, + SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsFindOptions, @@ -77,6 +78,18 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.redactSavedObjectNamespaces(savedObject); } + public async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ) { + const types = this.getUniqueObjectTypes(objects); + const args = { objects, options }; + await this.ensureAuthorized(types, 'bulk_create', options.namespace, args, 'checkConflicts'); + + const response = await this.baseClient.checkConflicts(objects, options); + return response; + } + public async bulkCreate( objects: Array>, options: SavedObjectsBaseOptions = {} diff --git a/x-pack/plugins/spaces/common/model/types.ts b/x-pack/plugins/spaces/common/model/types.ts index 30004c739ee7a..aad77f2bbcef9 100644 --- a/x-pack/plugins/spaces/common/model/types.ts +++ b/x-pack/plugins/spaces/common/model/types.ts @@ -4,4 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace' | 'findSavedObjects'; +export type GetSpacePurpose = + | 'any' + | 'copySavedObjectsIntoSpace' + | 'findSavedObjects' + | 'shareSavedObjectsIntoSpace'; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.tsx new file mode 100644 index 0000000000000..4e49a2da3e534 --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.test.tsx @@ -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 React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { CopyModeControl, CopyModeControlProps } from './copy_mode_control'; + +describe('CopyModeControl', () => { + const initialValues = { createNewCopies: false, overwrite: true }; // some test cases below make assumptions based on these initial values + const updateSelection = jest.fn(); + + const getOverwriteRadio = (wrapper: ReactWrapper) => + wrapper.find('EuiRadioGroup[data-test-subj="cts-copyModeControl-overwriteRadioGroup"]'); + const getOverwriteEnabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="overwriteEnabled"]'); + const getOverwriteDisabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="overwriteDisabled"]'); + const getCreateNewCopiesDisabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="createNewCopiesDisabled"]'); + const getCreateNewCopiesEnabled = (wrapper: ReactWrapper) => + wrapper.find('input[id="createNewCopiesEnabled"]'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const props: CopyModeControlProps = { initialValues, updateSelection }; + + it('should allow the user to toggle `overwrite`', async () => { + const wrapper = mountWithIntl(); + + expect(updateSelection).not.toHaveBeenCalled(); + const { createNewCopies } = initialValues; + + getOverwriteDisabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies, overwrite: false }); + + getOverwriteEnabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies, overwrite: true }); + }); + + it('should disable the Overwrite switch when `createNewCopies` is enabled', async () => { + const wrapper = mountWithIntl(); + + expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(false); + getCreateNewCopiesEnabled(wrapper).simulate('change'); + expect(getOverwriteRadio(wrapper).prop('disabled')).toBe(true); + }); + + it('should allow the user to toggle `createNewCopies`', async () => { + const wrapper = mountWithIntl(); + + expect(updateSelection).not.toHaveBeenCalled(); + const { overwrite } = initialValues; + + getCreateNewCopiesEnabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(1, { createNewCopies: true, overwrite }); + + getCreateNewCopiesDisabled(wrapper).simulate('change'); + expect(updateSelection).toHaveBeenNthCalledWith(2, { createNewCopies: false, overwrite }); + }); +}); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.tsx new file mode 100644 index 0000000000000..42fbf8954396e --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_mode_control.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 { + EuiFormFieldset, + EuiTitle, + EuiCheckableCard, + EuiRadioGroup, + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface CopyModeControlProps { + initialValues: CopyMode; + updateSelection: (result: CopyMode) => void; +} + +export interface CopyMode { + createNewCopies: boolean; + overwrite: boolean; +} + +const createNewCopiesDisabled = { + id: 'createNewCopiesDisabled', + text: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.createNewCopies.disabledTitle', + { defaultMessage: 'Check for existing objects' } + ), + tooltip: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.createNewCopies.disabledText', + { + defaultMessage: + 'Check if each object was previously copied or imported into the destination space.', + } + ), +}; +const createNewCopiesEnabled = { + id: 'createNewCopiesEnabled', + text: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.createNewCopies.enabledTitle', + { defaultMessage: 'Create new objects with random IDs' } + ), + tooltip: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.createNewCopies.enabledText', + { defaultMessage: 'All copied objects will be created with new random IDs.' } + ), +}; +const overwriteEnabled = { + id: 'overwriteEnabled', + label: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.overwrite.enabledLabel', + { defaultMessage: 'Automatically try to overwrite conflicts' } + ), +}; +const overwriteDisabled = { + id: 'overwriteDisabled', + label: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.overwrite.disabledLabel', + { defaultMessage: 'Request action when conflict occurs' } + ), +}; +const includeRelated = { + id: 'includeRelated', + text: i18n.translate('xpack.spaces.management.copyToSpace.copyModeControl.includeRelated.title', { + defaultMessage: 'Include related saved objects', + }), + tooltip: i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.includeRelated.text', + { + defaultMessage: + 'This will copy any other objects this has references to -- for example, a dashboard may have references to multiple visualizations.', + } + ), +}; +const copyOptionsTitle = i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.copyOptionsTitle', + { defaultMessage: 'Copy options' } +); +const relationshipOptionsTitle = i18n.translate( + 'xpack.spaces.management.copyToSpace.copyModeControl.relationshipOptionsTitle', + { defaultMessage: 'Relationship options' } +); + +const createLabel = ({ text, tooltip }: { text: string; tooltip: string }) => ( + + + {text} + + + + + +); + +export const CopyModeControl = ({ initialValues, updateSelection }: CopyModeControlProps) => { + const [createNewCopies, setCreateNewCopies] = useState(initialValues.createNewCopies); + const [overwrite, setOverwrite] = useState(initialValues.overwrite); + + const onChange = (partial: Partial) => { + if (partial.createNewCopies !== undefined) { + setCreateNewCopies(partial.createNewCopies); + } else if (partial.overwrite !== undefined) { + setOverwrite(partial.overwrite); + } + updateSelection({ createNewCopies, overwrite, ...partial }); + }; + + return ( + <> + + {copyOptionsTitle} + + ), + }} + > + onChange({ createNewCopies: false })} + > + onChange({ overwrite: id === overwriteEnabled.id })} + disabled={createNewCopies} + data-test-subj={'cts-copyModeControl-overwriteRadioGroup'} + /> + + + + + onChange({ createNewCopies: true })} + /> + + + + + + {relationshipOptionsTitle} + + ), + }} + > + {}} // noop + disabled + /> + + + ); +}; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_indicator.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_indicator.tsx index 62f9503443951..158d7a9a43ef6 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_indicator.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_indicator.tsx @@ -4,20 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiLoadingSpinner, EuiText, EuiIconTip } from '@elastic/eui'; +import React, { Fragment } from 'react'; +import { EuiLoadingSpinner, EuiIconTip, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { ImportRetry } from '../types'; import { SummarizedCopyToSpaceResult, SummarizedSavedObjectResult } from '..'; interface Props { summarizedCopyResult: SummarizedCopyToSpaceResult; object: { type: string; id: string }; - overwritePending: boolean; + pendingObjectRetry?: ImportRetry; conflictResolutionInProgress: boolean; } export const CopyStatusIndicator = (props: Props) => { - const { summarizedCopyResult, conflictResolutionInProgress } = props; + const { summarizedCopyResult, conflictResolutionInProgress, pendingObjectRetry } = props; if (summarizedCopyResult.processing || conflictResolutionInProgress) { return ; } @@ -25,32 +26,55 @@ export const CopyStatusIndicator = (props: Props) => { const objectResult = summarizedCopyResult.objects.find( (o) => o.type === props.object!.type && o.id === props.object!.id ) as SummarizedSavedObjectResult; + const { conflict, hasMissingReferences, hasUnresolvableErrors, overwrite } = objectResult; + const hasConflicts = conflict && !pendingObjectRetry?.overwrite; + const successful = !hasMissingReferences && !hasUnresolvableErrors && !hasConflicts; - const successful = - !objectResult.hasUnresolvableErrors && - (objectResult.conflicts.length === 0 || props.overwritePending === true); - const successColor = props.overwritePending ? 'warning' : 'success'; - const hasConflicts = objectResult.conflicts.length > 0; - const hasUnresolvableErrors = objectResult.hasUnresolvableErrors; - - if (successful) { - const message = props.overwritePending ? ( + if (successful && !pendingObjectRetry) { + // there is no retry pending, so this object was actually copied + const message = overwrite ? ( + // the object was overwritten ) : ( + // the object was not overwritten ); - return ; + return ; } + + if (successful && pendingObjectRetry) { + const message = overwrite ? ( + // this is an "automatic overwrite", e.g., the "Overwrite all conflicts" option was selected + + ) : pendingObjectRetry?.overwrite ? ( + // this is a manual overwrite, e.g., the individual "Overwrite?" switch was enabled + + ) : ( + // this object is pending success, but it will not result in an overwrite + + ); + return ; + } + if (hasUnresolvableErrors) { return ( { /> ); } + if (hasConflicts) { return ( -

- -

-

- -

- + + + + + } /> ); } - return null; + + return hasMissingReferences ? ( + + ) : conflict ? ( + + ) : ( + + ) + } + /> + ) : null; }; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.scss b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.scss new file mode 100644 index 0000000000000..d1c3cbbd2b6af --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.scss @@ -0,0 +1,7 @@ +.spcCopyToSpace__summaryCountBadge { + margin-left: $euiSizeXS; +} + +.spcCopyToSpace__missingReferencesIcon { + margin-left: $euiSizeXS; +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.tsx index 9d73c216c73ce..4bc7e5cfaf31a 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_status_summary_indicator.tsx @@ -4,30 +4,50 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiLoadingSpinner, EuiIconTip } from '@elastic/eui'; +import './copy_status_summary_indicator.scss'; +import React, { Fragment } from 'react'; +import { EuiLoadingSpinner, EuiIconTip, EuiBadge } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Space } from '../../../common/model/space'; +import { ImportRetry } from '../types'; +import { ResolveAllConflicts } from './resolve_all_conflicts'; import { SummarizedCopyToSpaceResult } from '..'; interface Props { space: Space; summarizedCopyResult: SummarizedCopyToSpaceResult; conflictResolutionInProgress: boolean; + retries: ImportRetry[]; + onRetriesChange: (retries: ImportRetry[]) => void; + onDestinationMapChange: (value?: Map) => void; } -export const CopyStatusSummaryIndicator = (props: Props) => { - const { summarizedCopyResult } = props; - const getDataTestSubj = (status: string) => `cts-summary-indicator-${status}-${props.space.id}`; +const renderIcon = (props: Props) => { + const { + space, + summarizedCopyResult, + conflictResolutionInProgress, + retries, + onRetriesChange, + onDestinationMapChange, + } = props; + const getDataTestSubj = (status: string) => `cts-summary-indicator-${status}-${space.id}`; - if (summarizedCopyResult.processing || props.conflictResolutionInProgress) { + if (summarizedCopyResult.processing || conflictResolutionInProgress) { return ; } - if (summarizedCopyResult.successful) { + const { + successful, + hasUnresolvableErrors, + hasMissingReferences, + hasConflicts, + } = summarizedCopyResult; + + if (successful) { return ( { } /> ); } - if (summarizedCopyResult.hasUnresolvableErrors) { + + if (hasUnresolvableErrors) { return ( { } /> ); } - if (summarizedCopyResult.hasConflicts) { - return ( + + const missingReferences = hasMissingReferences ? ( + } /> + + ) : null; + + if (hasConflicts) { + return ( + + + + } + /> + {missingReferences} + ); } - return null; + + return missingReferences; +}; + +export const CopyStatusSummaryIndicator = (props: Props) => { + const { summarizedCopyResult } = props; + + return ( + + {renderIcon(props)} + + {summarizedCopyResult.objects.length} + + + ); }; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx index 99b4e184c071a..dfc908d81887a 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.test.tsx @@ -17,6 +17,7 @@ import { ProcessingCopyToSpace } from './processing_copy_to_space'; import { spacesManagerMock } from '../../spaces_manager/mocks'; import { SpacesManager } from '../../spaces_manager'; import { ToastsApi } from 'src/core/public'; +import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; interface SetupOpts { mockSpaces?: Space[]; @@ -73,8 +74,8 @@ const setup = async (opts: SetupOpts = {}) => { name: 'My Viz', }, ], - meta: { icon: 'dashboard', title: 'foo' }, - }; + meta: { icon: 'dashboard', title: 'foo', namespaceType: 'single' }, + } as SavedObjectsManagementRecord; const wrapper = mountWithIntl( { type: 'index-pattern', id: 'conflicting-ip', error: { type: 'conflict' }, + meta: {}, }, { type: 'visualization', id: 'my-viz', error: { type: 'conflict' }, + meta: {}, }, ], }, @@ -223,8 +226,12 @@ describe('CopyToSpaceFlyout', () => { const spaceResult = findTestSubject(wrapper, `cts-space-result-space-2`); spaceResult.simulate('click'); - const overwriteButton = findTestSubject(wrapper, `cts-overwrite-conflict-conflicting-ip`); - overwriteButton.simulate('click'); + const overwriteSwitch = findTestSubject( + wrapper, + `cts-overwrite-conflict-index-pattern:conflicting-ip` + ); + expect(overwriteSwitch.props()['aria-checked']).toEqual(false); + overwriteSwitch.simulate('click'); const finishButton = findTestSubject(wrapper, 'cts-finish-button'); @@ -282,6 +289,7 @@ describe('CopyToSpaceFlyout', () => { [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }], ['space-1', 'space-2'], true, + false, true ); @@ -309,21 +317,45 @@ describe('CopyToSpaceFlyout', () => { mockSpacesManager.copySavedObjects.mockResolvedValue({ 'space-1': { success: true, - successCount: 3, + successCount: 5, }, 'space-2': { success: false, successCount: 1, errors: [ + // regular conflict without destinationId { type: 'index-pattern', id: 'conflicting-ip', error: { type: 'conflict' }, + meta: {}, + }, + // regular conflict with destinationId + { + type: 'search', + id: 'conflicting-search', + error: { type: 'conflict', destinationId: 'another-search' }, + meta: {}, + }, + // ambiguous conflict + { + type: 'canvas-workpad', + id: 'conflicting-canvas', + error: { + type: 'ambiguous_conflict', + destinations: [ + { id: 'another-canvas', title: 'foo', updatedAt: undefined }, + { id: 'yet-another-canvas', title: 'bar', updatedAt: undefined }, + ], + }, + meta: {}, }, + // negative test case (skip) { type: 'visualization', id: 'my-viz', error: { type: 'conflict' }, + meta: {}, }, ], }, @@ -358,8 +390,15 @@ describe('CopyToSpaceFlyout', () => { const spaceResult = findTestSubject(wrapper, `cts-space-result-space-2`); spaceResult.simulate('click'); - const overwriteButton = findTestSubject(wrapper, `cts-overwrite-conflict-conflicting-ip`); - overwriteButton.simulate('click'); + [ + 'index-pattern:conflicting-ip', + 'search:conflicting-search', + 'canvas-workpad:conflicting-canvas', + ].forEach((id) => { + const overwriteSwitch = findTestSubject(wrapper, `cts-overwrite-conflict-${id}`); + expect(overwriteSwitch.props()['aria-checked']).toEqual(false); + overwriteSwitch.simulate('click'); + }); const finishButton = findTestSubject(wrapper, 'cts-finish-button'); @@ -372,16 +411,148 @@ describe('CopyToSpaceFlyout', () => { expect(mockSpacesManager.resolveCopySavedObjectsErrors).toHaveBeenCalledWith( [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }], { - 'space-2': [{ type: 'index-pattern', id: 'conflicting-ip', overwrite: true }], + 'space-1': [], + 'space-2': [ + { type: 'index-pattern', id: 'conflicting-ip', overwrite: true }, + { + type: 'search', + id: 'conflicting-search', + overwrite: true, + destinationId: 'another-search', + }, + { + type: 'canvas-workpad', + id: 'conflicting-canvas', + overwrite: true, + destinationId: 'another-canvas', + }, + ], }, - true + true, + false + ); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + }); + + it('displays a warning when missing references are encountered', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToCopy, + } = await setup(); + + mockSpacesManager.copySavedObjects.mockResolvedValue({ + 'space-1': { + success: false, + successCount: 1, + errors: [ + // my-viz-1 just has a missing_references error + { + type: 'visualization', + id: 'my-viz-1', + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'missing-index-pattern' }], + }, + meta: {}, + }, + // my-viz-2 has both a missing_references error and a conflict error + { + type: 'visualization', + id: 'my-viz-2', + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'missing-index-pattern' }], + }, + meta: {}, + }, + { + type: 'visualization', + id: 'my-viz-2', + error: { type: 'conflict' }, + meta: {}, + }, + ], + successResults: [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id, meta: {} }], + }, + }); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-1']); + }); + + const startButton = findTestSubject(wrapper, 'cts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(CopyToSpaceForm)).toHaveLength(0); + expect(wrapper.find(ProcessingCopyToSpace)).toHaveLength(1); + + const spaceResult = findTestSubject(wrapper, `cts-space-result-space-1`); + spaceResult.simulate('click'); + + const errorIconTip1 = spaceResult.find( + 'EuiIconTip[data-test-subj="cts-object-result-missing-references-my-viz-1"]' + ); + expect(errorIconTip1.props()).toMatchInlineSnapshot(` + Object { + "color": "warning", + "content": , + "data-test-subj": "cts-object-result-missing-references-my-viz-1", + "type": "link", + } + `); + + const myViz2Icon = 'EuiIconTip[data-test-subj="cts-object-result-missing-references-my-viz-2"]'; + expect(spaceResult.find(myViz2Icon)).toHaveLength(0); + + // TODO: test for a missing references icon by selecting overwrite for the my-viz-2 conflict + + const finishButton = findTestSubject(wrapper, 'cts-finish-button'); + await act(async () => { + finishButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.resolveCopySavedObjectsErrors).toHaveBeenCalledWith( + [{ type: savedObjectToCopy.type, id: savedObjectToCopy.id }], + { + 'space-1': [ + { type: 'dashboard', id: 'my-dash', overwrite: false }, + { + type: 'visualization', + id: 'my-viz-1', + overwrite: false, + ignoreMissingReferences: true, + }, + ], + }, + true, + false ); expect(onClose).toHaveBeenCalledTimes(1); expect(mockToastNotifications.addError).not.toHaveBeenCalled(); }); - it('displays an error when missing references are encountered', async () => { + it('displays an error when an unresolvable error is encountered', async () => { const { wrapper, onClose, mockSpacesManager, mockToastNotifications } = await setup(); mockSpacesManager.copySavedObjects.mockResolvedValue({ @@ -396,11 +567,8 @@ describe('CopyToSpaceFlyout', () => { { type: 'visualization', id: 'my-viz', - error: { - type: 'missing_references', - blocking: [], - references: [{ type: 'index-pattern', id: 'missing-index-pattern' }], - }, + error: { type: 'unknown', message: 'some error message', statusCode: 400 }, + meta: {}, }, ], }, @@ -441,7 +609,7 @@ describe('CopyToSpaceFlyout', () => { values={Object {}} />, "data-test-subj": "cts-object-result-error-my-viz", - "type": "cross", + "type": "alert", } `); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx index 47fc603ee46e8..f9b81be2d6b4b 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx @@ -22,17 +22,17 @@ import { mapValues } from 'lodash'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ToastsStart } from 'src/core/public'; -import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { + ProcessedImportResponse, + processImportResponse, + SavedObjectsManagementRecord, +} from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; import { SpacesManager } from '../../spaces_manager'; import { ProcessingCopyToSpace } from './processing_copy_to_space'; import { CopyToSpaceFlyoutFooter } from './copy_to_space_flyout_footer'; import { CopyToSpaceForm } from './copy_to_space_form'; import { CopyOptions, ImportRetry } from '../types'; -import { - ProcessedImportResponse, - processImportResponse, -} from '../../../../../../src/plugins/saved_objects_management/public'; interface Props { onClose: () => void; @@ -41,11 +41,16 @@ interface Props { toastNotifications: ToastsStart; } +const INCLUDE_RELATED_DEFAULT = true; +const CREATE_NEW_COPIES_DEFAULT = false; +const OVERWRITE_ALL_DEFAULT = true; + export const CopySavedObjectsToSpaceFlyout = (props: Props) => { const { onClose, savedObject, spacesManager, toastNotifications } = props; const [copyOptions, setCopyOptions] = useState({ - includeRelated: true, - overwrite: true, + includeRelated: INCLUDE_RELATED_DEFAULT, + createNewCopies: CREATE_NEW_COPIES_DEFAULT, + overwrite: OVERWRITE_ALL_DEFAULT, selectedSpaceIds: [], }); @@ -90,18 +95,48 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { setCopyResult({}); try { const copySavedObjectsResult = await spacesManager.copySavedObjects( - [ - { - type: savedObject.type, - id: savedObject.id, - }, - ], + [{ type: savedObject.type, id: savedObject.id }], copyOptions.selectedSpaceIds, copyOptions.includeRelated, + copyOptions.createNewCopies, copyOptions.overwrite ); const processedResult = mapValues(copySavedObjectsResult, processImportResponse); setCopyResult(processedResult); + + // retry all successful imports + const getAutomaticRetries = (response: ProcessedImportResponse): ImportRetry[] => { + const { failedImports, successfulImports } = response; + if (!failedImports.length) { + // if no imports failed for this space, return an empty array + return []; + } + + // get missing references failures that do not also have a conflict + const nonMissingReferencesFailures = failedImports + .filter(({ error }) => error.type !== 'missing_references') + .reduce((acc, { obj: { type, id } }) => acc.add(`${type}:${id}`), new Set()); + const missingReferencesToRetry = failedImports.filter( + ({ obj: { type, id }, error }) => + error.type === 'missing_references' && + !nonMissingReferencesFailures.has(`${type}:${id}`) + ); + + // otherwise, some imports failed for this space, so retry any successful imports (if any) + return [ + ...successfulImports.map(({ type, id, overwrite, destinationId, createNewCopy }) => { + return { type, id, overwrite: overwrite === true, destinationId, createNewCopy }; + }), + ...missingReferencesToRetry.map(({ obj: { type, id } }) => ({ + type, + id, + overwrite: false, + ignoreMissingReferences: true, + })), + ]; + }; + const automaticRetries = mapValues(processedResult, getAutomaticRetries); + setRetries(automaticRetries); } catch (e) { setCopyInProgress(false); toastNotifications.addError(e, { @@ -113,27 +148,22 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { } async function finishCopy() { - const needsConflictResolution = Object.values(retries).some((spaceRetry) => - spaceRetry.some((retry) => retry.overwrite) - ); + // if any retries are present, attempt to resolve errors again + const needsErrorResolution = Object.values(retries).some((spaceRetry) => spaceRetry.length); - if (needsConflictResolution) { + if (needsErrorResolution) { setConflictResolutionInProgress(true); try { await spacesManager.resolveCopySavedObjectsErrors( - [ - { - type: savedObject.type, - id: savedObject.id, - }, - ], + [{ type: savedObject.type, id: savedObject.id }], retries, - copyOptions.includeRelated + copyOptions.includeRelated, + copyOptions.createNewCopies ); toastNotifications.addSuccess( i18n.translate('xpack.spaces.management.copyToSpace.resolveCopySuccessTitle', { - defaultMessage: 'Overwrite successful', + defaultMessage: 'Copy successful', }) ); @@ -184,7 +214,12 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { // Step 2: Copy has not been initiated yet; User must fill out form to continue. if (!copyInProgress) { return ( - + ); } @@ -208,14 +243,14 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { - +

@@ -247,6 +282,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { copyResult={copyResult} numberOfSelectedSpaces={copyOptions.selectedSpaceIds.length} retries={retries} + onClose={onClose} onCopyStart={startCopy} onCopyFinish={finishCopy} /> diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx index d7ded819771fc..524361bf6ef1d 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_footer.tsx @@ -5,11 +5,18 @@ */ import React, { Fragment } from 'react'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiStat, EuiHorizontalRule } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiStat, + EuiHorizontalRule, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { ProcessedImportResponse, FailedImport } from 'src/plugins/saved_objects_management/public'; import { ImportRetry } from '../types'; -import { ProcessedImportResponse } from '../../../../../../src/plugins/saved_objects_management/public'; interface Props { copyInProgress: boolean; @@ -18,33 +25,54 @@ interface Props { copyResult: Record; retries: Record; numberOfSelectedSpaces: number; + onClose: () => void; onCopyStart: () => void; onCopyFinish: () => void; } + +const isResolvableError = ({ error: { type } }: FailedImport) => + ['conflict', 'ambiguous_conflict', 'missing_references'].includes(type); +const isUnresolvableError = (failure: FailedImport) => !isResolvableError(failure); + export const CopyToSpaceFlyoutFooter = (props: Props) => { - const { copyInProgress, initialCopyFinished, copyResult, retries } = props; + const { + copyInProgress, + conflictResolutionInProgress, + initialCopyFinished, + copyResult, + retries, + } = props; let summarizedResults = { successCount: 0, - overwriteConflictCount: 0, - conflictCount: 0, - unresolvableErrorCount: 0, + pendingCount: 0, + skippedCount: 0, + errorCount: 0, }; if (copyResult) { summarizedResults = Object.entries(copyResult).reduce((acc, result) => { const [spaceId, spaceResult] = result; - const overwriteCount = (retries[spaceId] || []).filter((c) => c.overwrite).length; + let successCount = 0; + let pendingCount = 0; + let skippedCount = 0; + let errorCount = 0; + if (spaceResult.status === 'success') { + successCount = spaceResult.importCount; + } else { + const uniqueResolvableErrors = spaceResult.failedImports + .filter(isResolvableError) + .reduce((set, { obj: { type, id } }) => set.add(`${type}:${id}`), new Set()); + pendingCount = (retries[spaceId] || []).length; + skippedCount = + uniqueResolvableErrors.size + spaceResult.successfulImports.length - pendingCount; + errorCount = spaceResult.failedImports.filter(isUnresolvableError).length; + } return { loading: false, - successCount: acc.successCount + spaceResult.importCount, - overwriteConflictCount: acc.overwriteConflictCount + overwriteCount, - conflictCount: - acc.conflictCount + - spaceResult.failedImports.filter((i) => i.error.type === 'conflict').length - - overwriteCount, - unresolvableErrorCount: - acc.unresolvableErrorCount + - spaceResult.failedImports.filter((i) => i.error.type !== 'conflict').length, + successCount: acc.successCount + successCount, + pendingCount: acc.pendingCount + pendingCount, + skippedCount: acc.skippedCount + skippedCount, + errorCount: acc.errorCount + errorCount, }; }, summarizedResults); } @@ -52,13 +80,13 @@ export const CopyToSpaceFlyoutFooter = (props: Props) => { const getButton = () => { let actionButton; if (initialCopyFinished) { - const hasPendingOverwrites = summarizedResults.overwriteConflictCount > 0; + const hasPendingRetries = summarizedResults.pendingCount > 0; - const buttonText = hasPendingOverwrites ? ( + const buttonText = hasPendingRetries ? ( ) : ( { actionButton = ( { } return ( - + + + props.onClose()} + data-test-subj="cts-cancel-button" + disabled={ + // Cannot cancel while the operation is in progress, or after some objects have already been created + (copyInProgress && !initialCopyFinished) || + conflictResolutionInProgress || + summarizedResults.successCount > 0 + } + > + + + {actionButton} ); @@ -141,35 +186,33 @@ export const CopyToSpaceFlyoutFooter = (props: Props) => { } />
- {summarizedResults.overwriteConflictCount > 0 && ( - - 0 ? 'primary' : 'subdued'} - isLoading={!initialCopyFinished} - textAlign="center" - description={ - - } - /> - - )} 0 ? 'primary' : 'subdued'} + isLoading={!initialCopyFinished} + textAlign="center" + description={ + + } + /> + + + 0 ? 'primary' : 'subdued'} + titleColor={summarizedResults.skippedCount > 0 ? 'primary' : 'subdued'} isLoading={!initialCopyFinished} textAlign="center" description={ } @@ -178,9 +221,9 @@ export const CopyToSpaceFlyoutFooter = (props: Props) => { 0 ? 'danger' : 'subdued'} + titleColor={summarizedResults.errorCount > 0 ? 'danger' : 'subdued'} isLoading={!initialCopyFinished} textAlign="center" description={ diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx index 0df2a7720e587..fdc8d8c73e324 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx @@ -4,78 +4,62 @@ * you may not use this file except in compliance with the Elastic License. */ -import './copy_to_space_form.scss'; import React from 'react'; -import { - EuiSwitch, - EuiSpacer, - EuiHorizontalRule, - EuiFormRow, - EuiListGroup, - EuiListGroupItem, -} from '@elastic/eui'; +import { EuiSpacer, EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { CopyOptions } from '../types'; +import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; import { SelectableSpacesControl } from './selectable_spaces_control'; +import { CopyModeControl, CopyMode } from './copy_mode_control'; interface Props { + savedObject: SavedObjectsManagementRecord; spaces: Space[]; onUpdate: (copyOptions: CopyOptions) => void; copyOptions: CopyOptions; } export const CopyToSpaceForm = (props: Props) => { - const setOverwrite = (overwrite: boolean) => props.onUpdate({ ...props.copyOptions, overwrite }); + const { savedObject, spaces, onUpdate, copyOptions } = props; + + // if the user is not creating new copies, prevent them from copying objects an object into a space where it already exists + const getDisabledSpaceIds = (createNewCopies: boolean) => + createNewCopies + ? new Set() + : (savedObject.namespaces ?? []).reduce((acc, cur) => acc.add(cur), new Set()); + + const changeCopyMode = ({ createNewCopies, overwrite }: CopyMode) => { + const disabled = getDisabledSpaceIds(createNewCopies); + const selectedSpaceIds = copyOptions.selectedSpaceIds.filter((x) => !disabled.has(x)); + onUpdate({ ...copyOptions, createNewCopies, overwrite, selectedSpaceIds }); + }; const setSelectedSpaceIds = (selectedSpaceIds: string[]) => - props.onUpdate({ ...props.copyOptions, selectedSpaceIds }); + onUpdate({ ...copyOptions, selectedSpaceIds }); return (
- - - - - } - /> - - - - - - } - checked={props.copyOptions.overwrite} - onChange={(e) => setOverwrite(e.target.checked)} + changeCopyMode(newValues)} /> - + } fullWidth > setSelectedSpaceIds(selection)} /> diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx index 255268d388eb8..ceaa1dc9f5e21 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx @@ -19,7 +19,7 @@ import { } from 'src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; import { CopyOptions, ImportRetry } from '../types'; -import { SpaceResult } from './space_result'; +import { SpaceResult, SpaceResultProcessing } from './space_result'; import { summarizeCopyResult } from '..'; interface Props { @@ -33,6 +33,52 @@ interface Props { copyOptions: CopyOptions; } +const renderCopyOptions = ({ createNewCopies, overwrite, includeRelated }: CopyOptions) => { + const createNewCopiesLabel = createNewCopies ? ( + + ) : ( + + ); + const overwriteLabel = overwrite ? ( + + ) : ( + + ); + const includeRelatedLabel = includeRelated ? ( + + ) : ( + + ); + + return ( + + + {!createNewCopies && ( + + )} + + + ); +}; + export const ProcessingCopyToSpace = (props: Props) => { function updateRetries(spaceId: string, updatedRetries: ImportRetry[]) { props.onRetriesChange({ @@ -43,46 +89,13 @@ export const ProcessingCopyToSpace = (props: Props) => { return (
- - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - + {renderCopyOptions(props.copyOptions)}
@@ -90,22 +103,22 @@ export const ProcessingCopyToSpace = (props: Props) => { {props.copyOptions.selectedSpaceIds.map((id) => { const space = props.spaces.find((s) => s.id === id) as Space; const spaceCopyResult = props.copyResult[space.id]; - const summarizedSpaceCopyResult = summarizeCopyResult( - props.savedObject, - spaceCopyResult, - props.copyOptions.includeRelated - ); + const summarizedSpaceCopyResult = summarizeCopyResult(props.savedObject, spaceCopyResult); return ( - updateRetries(space.id, retries)} - conflictResolutionInProgress={props.conflictResolutionInProgress} - /> + {summarizedSpaceCopyResult.processing ? ( + + ) : ( + updateRetries(space.id, retries)} + conflictResolutionInProgress={props.conflictResolutionInProgress} + /> + )} ); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.scss b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.scss new file mode 100644 index 0000000000000..ce019d17ceaf7 --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.scss @@ -0,0 +1,4 @@ +.spcCopyToSpace__resolveAllConflictsLink { + font-size: $euiFontSizeS; + margin-right: $euiSizeS; +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.test.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.test.tsx new file mode 100644 index 0000000000000..7da265d8f9958 --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.test.tsx @@ -0,0 +1,158 @@ +/* + * 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 { ReactWrapper } from 'enzyme'; +import { act } from '@testing-library/react'; +import { shallowWithIntl, mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { ResolveAllConflicts, ResolveAllConflictsProps } from './resolve_all_conflicts'; +import { SummarizedCopyToSpaceResult } from '..'; +import { ImportRetry } from '../types'; +describe('ResolveAllConflicts', () => { + const summarizedCopyResult = ({ + objects: [ + // these objects have minimal attributes to exercise test scenarios; these are not fully realistic results + { type: 'type-1', id: 'id-1', conflict: undefined }, // not a conflict + { type: 'type-2', id: 'id-2', conflict: { error: { type: 'conflict' } } }, // conflict without a destinationId + { + // conflict with a destinationId + type: 'type-3', + id: 'id-3', + conflict: { error: { type: 'conflict', destinationId: 'dest-3' } }, + }, + { + // ambiguous conflict with two destinations + type: 'type-4', + id: 'id-4', + conflict: { + error: { + type: 'ambiguous_conflict', + destinations: [{ id: 'dest-4a' }, { id: 'dest-4b' }], + }, + }, + }, + { + // ambiguous conflict with two destinations (a retry already exists for dest-5b) + type: 'type-5', + id: 'id-5', + conflict: { + error: { + type: 'ambiguous_conflict', + destinations: [{ id: 'dest-5a' }, { id: 'dest-5b' }], + }, + }, + }, + ], + } as unknown) as SummarizedCopyToSpaceResult; + const retries: ImportRetry[] = [ + { type: 'type-1', id: 'id-1', overwrite: false }, + { type: 'type-5', id: 'id-5', overwrite: true, destinationId: 'dest-5b' }, + ]; + const onRetriesChange = jest.fn(); + const onDestinationMapChange = jest.fn(); + + const getOverwriteOption = (wrapper: ReactWrapper) => + findTestSubject(wrapper, 'cts-resolve-all-conflicts-overwrite'); + const getSkipOption = (wrapper: ReactWrapper) => + findTestSubject(wrapper, 'cts-resolve-all-conflicts-skip'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const props: ResolveAllConflictsProps = { + summarizedCopyResult, + retries, + onRetriesChange, + onDestinationMapChange, + }; + const openPopover = async (wrapper: ReactWrapper) => { + await act(async () => { + wrapper.setState({ isPopoverOpen: true }); + await nextTick(); + wrapper.update(); + }); + }; + + it('should render as expected', async () => { + const wrapper = shallowWithIntl(); + + expect(wrapper).toMatchInlineSnapshot(` + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="resolveAllConflictsVisibilityPopover" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + > + + Overwrite all + , + + Skip all + , + ] + } + /> + + `); + }); + + it('should add overwrite retries when "Overwrite all" is selected', async () => { + const wrapper = mountWithIntl(); + await openPopover(wrapper); + expect(onRetriesChange).not.toHaveBeenCalled(); + + getOverwriteOption(wrapper).simulate('click'); + expect(onRetriesChange).toHaveBeenCalledWith([ + { type: 'type-1', id: 'id-1', overwrite: false }, // unchanged + { type: 'type-5', id: 'id-5', overwrite: true, destinationId: 'dest-5b' }, // unchanged + { type: 'type-2', id: 'id-2', overwrite: true }, // added without a destinationId + { type: 'type-3', id: 'id-3', overwrite: true, destinationId: 'dest-3' }, // added with the destinationId + { type: 'type-4', id: 'id-4', overwrite: true, destinationId: 'dest-4a' }, // added with the first destinationId + ]); + expect(onDestinationMapChange).not.toHaveBeenCalled(); + }); + + it('should remove overwrite retries when "Skip all" is selected', async () => { + const wrapper = mountWithIntl(); + await openPopover(wrapper); + expect(onRetriesChange).not.toHaveBeenCalled(); + expect(onDestinationMapChange).not.toHaveBeenCalled(); + + getSkipOption(wrapper).simulate('click'); + expect(onRetriesChange).toHaveBeenCalledWith([ + { type: 'type-1', id: 'id-1', overwrite: false }, // unchanged + ]); + expect(onDestinationMapChange).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.tsx new file mode 100644 index 0000000000000..a4ded022debe8 --- /dev/null +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/resolve_all_conflicts.tsx @@ -0,0 +1,135 @@ +/* + * 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 './resolve_all_conflicts.scss'; + +import { EuiContextMenuItem, EuiContextMenuPanel, EuiLink, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { Component } from 'react'; +import { ImportRetry } from '../types'; +import { SummarizedCopyToSpaceResult } from '..'; + +export interface ResolveAllConflictsProps { + summarizedCopyResult: SummarizedCopyToSpaceResult; + retries: ImportRetry[]; + onRetriesChange: (retries: ImportRetry[]) => void; + onDestinationMapChange: (value?: Map) => void; +} + +interface State { + isPopoverOpen: boolean; +} + +interface ResolveOption { + id: 'overwrite' | 'skip'; + text: string; +} + +const options: ResolveOption[] = [ + { + id: 'overwrite', + text: i18n.translate('xpack.spaces.management.copyToSpace.overwriteAllConflictsText', { + defaultMessage: 'Overwrite all', + }), + }, + { + id: 'skip', + text: i18n.translate('xpack.spaces.management.copyToSpace.skipAllConflictsText', { + defaultMessage: 'Skip all', + }), + }, +]; + +export class ResolveAllConflicts extends Component { + public state = { + isPopoverOpen: false, + }; + + public render() { + const button = ( + + + + ); + + const items = options.map((item) => { + return ( + { + this.onSelect(item.id); + }} + > + {item.text} + + ); + }); + + return ( + + + + ); + } + + private onSelect = (selection: ResolveOption['id']) => { + const { summarizedCopyResult, retries, onRetriesChange, onDestinationMapChange } = this.props; + const overwrite = selection === 'overwrite'; + + if (overwrite) { + const existingOverwrites = retries.filter((retry) => retry.overwrite === true); + const newOverwrites = summarizedCopyResult.objects.reduce((acc, { type, id, conflict }) => { + if ( + conflict && + !existingOverwrites.some((retry) => retry.type === type && retry.id === id) + ) { + const { error } = conflict; + // if this is a regular conflict, use its destinationId if it has one; + // otherwise, this is an ambiguous conflict, so use the first destinationId available + const destinationId = + error.type === 'conflict' ? error.destinationId : error.destinations[0].id; + return [...acc, { type, id, overwrite, ...(destinationId && { destinationId }) }]; + } + return acc; + }, new Array()); + onRetriesChange([...retries, ...newOverwrites]); + } else { + const objectsToSkip = summarizedCopyResult.objects.reduce( + (acc, { type, id, conflict }) => (conflict ? acc.add(`${type}:${id}`) : acc), + new Set() + ); + const filtered = retries.filter(({ type, id }) => !objectsToSkip.has(`${type}:${id}`)); + onRetriesChange(filtered); + onDestinationMapChange(undefined); + } + + this.setState({ isPopoverOpen: false }); + }; + + private onButtonClick = () => { + this.setState({ + isPopoverOpen: !this.state.isPopoverOpen, + }); + }; + + private closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx index 9db045f4f068a..2a8b5e660f38c 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -5,42 +5,53 @@ */ import './selectable_spaces_control.scss'; -import React, { Fragment, useState } from 'react'; -import { EuiSelectable, EuiLoadingSpinner } from '@elastic/eui'; +import React, { Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSelectable, EuiSelectableOption, EuiLoadingSpinner, EuiIconTip } from '@elastic/eui'; import { SpaceAvatar } from '../../space_avatar'; import { Space } from '../../../common/model/space'; interface Props { spaces: Space[]; selectedSpaceIds: string[]; + disabledSpaceIds: Set; onChange: (selectedSpaceIds: string[]) => void; disabled?: boolean; } -interface SpaceOption { - label: string; - prepend?: any; - checked: 'on' | 'off' | null; - ['data-space-id']: string; - disabled?: boolean; -} +type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; export const SelectableSpacesControl = (props: Props) => { - const [options, setOptions] = useState([]); - - // TODO: update once https://github.com/elastic/eui/issues/2071 is fixed - if (options.length === 0) { - setOptions( - props.spaces.map((space) => ({ - label: space.name, - prepend: , - checked: props.selectedSpaceIds.includes(space.id) ? 'on' : null, - ['data-space-id']: space.id, - ['data-test-subj']: `cts-space-selector-row-${space.id}`, - })) - ); + if (props.spaces.length === 0) { + return ; } + const disabledIndicator = ( + + } + position="left" + type="iInCircle" + /> + ); + + const options = props.spaces.map((space) => { + const disabled = props.disabledSpaceIds.has(space.id); + return { + label: space.name, + prepend: , + append: disabled ? disabledIndicator : null, + checked: props.selectedSpaceIds.includes(space.id) ? 'on' : undefined, + disabled, + ['data-space-id']: space.id, + ['data-test-subj']: `cts-space-selector-row-${space.id}`, + }; + }); + function updateSelectedSpaces(selectedOptions: SpaceOption[]) { if (props.disabled) return; @@ -49,17 +60,11 @@ export const SelectableSpacesControl = (props: Props) => { .map((opt) => opt['data-space-id']); props.onChange(selectedSpaceIds); - // TODO: remove once https://github.com/elastic/eui/issues/2071 is fixed - setOptions(selectedOptions); - } - - if (options.length === 0) { - return ; } return ( updateSelectedSpaces(newOptions as SpaceOption[])} listProps={{ bordered: true, diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result.tsx index f1a8f64a61449..eefd9f8ea2467 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result.tsx @@ -5,8 +5,15 @@ */ import './space_result.scss'; -import React from 'react'; -import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui'; +import React, { useState } from 'react'; +import { + EuiAccordion, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer, + EuiLoadingSpinner, +} from '@elastic/eui'; import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; import { SummarizedCopyToSpaceResult } from '../index'; import { SpaceAvatar } from '../../space_avatar'; @@ -24,6 +31,39 @@ interface Props { conflictResolutionInProgress: boolean; } +const getInitialDestinationMap = (objects: SummarizedCopyToSpaceResult['objects']) => + objects.reduce((acc, { type, id, conflict }) => { + if (conflict?.error.type === 'ambiguous_conflict') { + acc.set(`${type}:${id}`, conflict.error.destinations[0].id); + } + return acc; + }, new Map()); + +export const SpaceResultProcessing = (props: Pick) => { + const { space } = props; + return ( + + + + + + {space.name} + + + } + extraAction={} + > + + + + ); +}; + export const SpaceResult = (props: Props) => { const { space, @@ -33,7 +73,12 @@ export const SpaceResult = (props: Props) => { savedObject, conflictResolutionInProgress, } = props; + const { objects } = summarizedCopyResult; const spaceHasPendingOverwrites = retries.some((r) => r.overwrite); + const [destinationMap, setDestinationMap] = useState(getInitialDestinationMap(objects)); + const onDestinationMapChange = (value?: Map) => { + setDestinationMap(value || getInitialDestinationMap(objects)); + }; return ( { extraAction={ @@ -65,6 +113,8 @@ export const SpaceResult = (props: Props) => { space={space} retries={retries} onRetriesChange={onRetriesChange} + destinationMap={destinationMap} + onDestinationMapChange={onDestinationMapChange} conflictResolutionInProgress={conflictResolutionInProgress && spaceHasPendingOverwrites} /> diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.scss b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.scss index 7702987220282..bca07da9eae42 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.scss +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.scss @@ -11,3 +11,28 @@ // Constrains name to the flex item, and allows for truncation when necessary min-width: 0; } + +.spcCopyToSpaceResultDetails__selectControl { + margin-left: $euiSizeL; +} + +.spcCopyToSpaceResultDetails__selectControl__childWrapper { + // Derived from euiAccordion + visibility: hidden; + opacity: 0; + height: 0; + overflow: hidden; + transform: translatez(0); + // sass-lint:disable-block indentation + transition: + height $euiAnimSpeedNormal $euiAnimSlightResistance, + opacity $euiAnimSpeedNormal $euiAnimSlightResistance; +} + +.spcCopyToSpaceResultDetails__selectControl.spcCopyToSpaceResultDetails__selectControl-isOpen { + .spcCopyToSpaceResultDetails__selectControl__childWrapper { + visibility: visible; + opacity: 1; + height: auto; + } +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.tsx index ef7931260e643..776ed99c41120 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/space_result_details.tsx @@ -5,9 +5,23 @@ */ import './space_result_details.scss'; -import React from 'react'; -import { EuiText, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import React, { Fragment } from 'react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiSwitchEvent, + EuiToolTip, + EuiIcon, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { + SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, +} from 'kibana/public'; +import { EuiSuperSelect } from '@elastic/eui'; +import moment from 'moment'; import { SummarizedCopyToSpaceResult } from '../index'; import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; @@ -20,104 +34,161 @@ interface Props { space: Space; retries: ImportRetry[]; onRetriesChange: (retries: ImportRetry[]) => void; + destinationMap: Map; + onDestinationMapChange: (value?: Map) => void; conflictResolutionInProgress: boolean; } -export const SpaceCopyResultDetails = (props: Props) => { - const onOverwriteClick = (object: { type: string; id: string }) => { - const retry = props.retries.find((r) => r.type === object.type && r.id === object.id); - - props.onRetriesChange([ - ...props.retries.filter((r) => r !== retry), - { - type: object.type, - id: object.id, - overwrite: retry ? !retry.overwrite : true, - }, - ]); - }; - - const hasPendingOverwrite = (object: { type: string; id: string }) => { - const retry = props.retries.find((r) => r.type === object.type && r.id === object.id); +function getSavedObjectLabel(type: string) { + switch (type) { + case 'index-pattern': + case 'index-patterns': + case 'indexPatterns': + return 'index patterns'; + default: + return type; + } +} - return Boolean(retry && retry.overwrite); - }; +const isAmbiguousConflictError = ( + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError +): error is SavedObjectsImportAmbiguousConflictError => error.type === 'ambiguous_conflict'; - const { objects } = props.summarizedCopyResult; +export const SpaceCopyResultDetails = (props: Props) => { + const { destinationMap, onDestinationMapChange, summarizedCopyResult } = props; + const { objects } = summarizedCopyResult; return (
{objects.map((object, index) => { - const objectOverwritePending = hasPendingOverwrite(object); + const { type, id, name, icon, conflict } = object; + const pendingObjectRetry = props.retries.find((r) => r.type === type && r.id === id); + const isOverwritePending = Boolean(pendingObjectRetry?.overwrite); + const switchProps = { + show: conflict && !props.conflictResolutionInProgress, + label: i18n.translate('xpack.spaces.management.copyToSpace.copyDetail.overwriteSwitch', { + defaultMessage: 'Overwrite?', + }), + onChange: ({ target: { checked } }: EuiSwitchEvent) => { + const filtered = props.retries.filter((r) => r.type !== type || r.id !== id); + const { error } = conflict!; - const showOverwriteButton = - object.conflicts.length > 0 && - !objectOverwritePending && - !props.conflictResolutionInProgress; - - const showSkipButton = - !showOverwriteButton && objectOverwritePending && !props.conflictResolutionInProgress; + if (!checked) { + props.onRetriesChange(filtered); + if (isAmbiguousConflictError(error)) { + // reset the selection to the first entry + const value = error.destinations[0].id; + onDestinationMapChange(new Map(destinationMap.set(`${type}:${id}`, value))); + } + } else { + const destinationId = isAmbiguousConflictError(error) + ? destinationMap.get(`${type}:${id}`) + : error.destinationId; + const retry = { type, id, overwrite: true, ...(destinationId && { destinationId }) }; + props.onRetriesChange([...filtered, retry]); + } + }, + }; + const selectProps = { + options: + conflict?.error && isAmbiguousConflictError(conflict.error) + ? conflict.error.destinations.map((destination) => { + const header = destination.title ?? `${type} [id=${destination.id}]`; + const lastUpdated = destination.updatedAt + ? moment(destination.updatedAt).fromNow() + : 'never'; + return { + value: destination.id, + inputDisplay: destination.id, + dropdownDisplay: ( + + {header} + +

+ ID: {destination.id} +
+ Last updated: {lastUpdated} +

+
+
+ ), + }; + }) + : [], + onChange: (value: string) => { + onDestinationMapChange(new Map(destinationMap.set(`${type}:${id}`, value))); + const filtered = props.retries.filter((r) => r.type !== type || r.id !== id); + const retry = { type, id, overwrite: true, destinationId: value }; + props.onRetriesChange([...filtered, retry]); + }, + }; + const selectContainerClass = + selectProps.options.length > 0 && isOverwritePending + ? ' spcCopyToSpaceResultDetails__selectControl-isOpen' + : ''; return ( - - - -

- {object.type}: {object.name || object.id} -

-
-
- {showOverwriteButton && ( - - - onOverwriteClick(object)} - size="xs" - data-test-subj={`cts-overwrite-conflict-${object.id}`} - > - - - + + + + + + - )} - {showSkipButton && ( - + - onOverwriteClick(object)} - size="xs" - data-test-subj={`cts-skip-conflict-${object.id}`} - > - - +

+ {name} +

- )} - -
- + + + )} + +
+ +
+
+ +
+
+
- - +
+ ); })}
diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_action.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_action.tsx index 28b48044a1783..9bbde31ff6fea 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_action.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/copy_saved_objects_to_space_action.tsx @@ -21,10 +21,13 @@ export class CopyToSpaceSavedObjectsManagementAction extends SavedObjectsManagem defaultMessage: 'Copy to space', }), description: i18n.translate('xpack.spaces.management.copyToSpace.actionDescription', { - defaultMessage: 'Copy this saved object to one or more spaces', + defaultMessage: 'Make a copy of this saved object in one or more spaces', }), - icon: 'spacesApp', + icon: 'copy', type: 'icon', + available: (object: SavedObjectsManagementRecord) => { + return object.meta.namespaceType !== 'agnostic'; + }, onClick: (object: SavedObjectsManagementRecord) => { this.start(object); }, diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts index a8ecd7c7b9d9f..b8fc89f47a3e0 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts @@ -5,50 +5,123 @@ */ import { summarizeCopyResult } from './summarize_copy_result'; -import { ProcessedImportResponse } from 'src/plugins/saved_objects_management/public'; +import { + ProcessedImportResponse, + FailedImport, + SavedObjectsManagementRecord, +} from 'src/plugins/saved_objects_management/public'; -const createSavedObjectsManagementRecord = () => ({ - type: 'dashboard', - id: 'foo', - meta: { icon: 'foo-icon', title: 'my-dashboard' }, - references: [ - { - type: 'visualization', - id: 'foo-viz', - name: 'Foo Viz', - }, - { - type: 'visualization', - id: 'bar-viz', - name: 'Bar Viz', - }, - ], -}); +// Sample data references: +// +// /-> Visualization bar -> Index pattern foo +// My dashboard +// \-> Visualization baz -> Index pattern bar +// +// Dashboard has references to visualizations, and transitive references to index patterns + +const OBJECTS = { + MY_DASHBOARD: { + type: 'dashboard', + id: 'foo', + meta: { title: 'my-dashboard-title', icon: 'dashboardApp', namespaceType: 'single' }, + references: [ + { type: 'visualization', id: 'foo', name: 'Visualization foo' }, + { type: 'visualization', id: 'bar', name: 'Visualization bar' }, + ], + } as SavedObjectsManagementRecord, + VISUALIZATION_FOO: { + type: 'visualization', + id: 'bar', + meta: { title: 'visualization-foo-title', icon: 'visualizeApp', namespaceType: 'single' }, + references: [{ type: 'index-pattern', id: 'foo', name: 'Index pattern foo' }], + } as SavedObjectsManagementRecord, + VISUALIZATION_BAR: { + type: 'visualization', + id: 'baz', + meta: { title: 'visualization-bar-title', icon: 'visualizeApp', namespaceType: 'single' }, + references: [{ type: 'index-pattern', id: 'bar', name: 'Index pattern bar' }], + } as SavedObjectsManagementRecord, + INDEX_PATTERN_FOO: { + type: 'index-pattern', + id: 'foo', + meta: { title: 'index-pattern-foo-title', icon: 'indexPatternApp', namespaceType: 'single' }, + references: [], + } as SavedObjectsManagementRecord, + INDEX_PATTERN_BAR: { + type: 'index-pattern', + id: 'bar', + meta: { title: 'index-pattern-bar-title', icon: 'indexPatternApp', namespaceType: 'single' }, + references: [], + } as SavedObjectsManagementRecord, +}; + +interface ObjectProperties { + type: string; + id: string; + meta: { title?: string; icon?: string }; +} +const createSuccessResult = ({ type, id, meta }: ObjectProperties) => { + return { type, id, meta }; +}; +const createFailureConflict = ({ type, id, meta }: ObjectProperties): FailedImport => { + return { obj: { type, id, meta }, error: { type: 'conflict' } }; +}; +const createFailureMissingReferences = ({ type, id, meta }: ObjectProperties): FailedImport => { + return { + obj: { type, id, meta }, + error: { type: 'missing_references', references: [] }, + }; +}; +const createFailureUnresolvable = ({ type, id, meta }: ObjectProperties): FailedImport => { + return { + obj: { type, id, meta }, + // currently, unresolvable errors are 'unsupported_type' and 'unknown'; either would work for this test case + error: { type: 'unknown', message: 'some error message', statusCode: 400 }, + }; +}; const createCopyResult = ( - opts: { withConflicts?: boolean; withUnresolvableError?: boolean } = {} + opts: { + withConflicts?: boolean; + withMissingReferencesError?: boolean; + withUnresolvableError?: boolean; + overwrite?: boolean; + } = {} ) => { - const failedImports: ProcessedImportResponse['failedImports'] = []; + let successfulImports: ProcessedImportResponse['successfulImports'] = [ + createSuccessResult(OBJECTS.MY_DASHBOARD), + ]; + let failedImports: ProcessedImportResponse['failedImports'] = []; if (opts.withConflicts) { - failedImports.push( - { - obj: { type: 'visualization', id: 'foo-viz' }, - error: { type: 'conflict' }, - }, - { - obj: { type: 'index-pattern', id: 'transient-index-pattern-conflict' }, - error: { type: 'conflict' }, - } - ); + failedImports.push(createFailureConflict(OBJECTS.VISUALIZATION_FOO)); + } else { + successfulImports.push(createSuccessResult(OBJECTS.VISUALIZATION_FOO)); } if (opts.withUnresolvableError) { - failedImports.push({ - obj: { type: 'visualization', id: 'bar-viz' }, - error: { type: 'missing_references', blocking: [], references: [] }, - }); + failedImports.push(createFailureUnresolvable(OBJECTS.INDEX_PATTERN_FOO)); + } else { + successfulImports.push(createSuccessResult(OBJECTS.INDEX_PATTERN_FOO)); + } + if (opts.withMissingReferencesError) { + failedImports.push(createFailureMissingReferences(OBJECTS.VISUALIZATION_BAR)); + // INDEX_PATTERN_BAR is not present in the source space, therefore VISUALIZATION_BAR resulted in a missing_references error + } else { + successfulImports.push( + createSuccessResult(OBJECTS.VISUALIZATION_BAR), + createSuccessResult(OBJECTS.INDEX_PATTERN_BAR) + ); + } + + if (opts.overwrite) { + failedImports = failedImports.map(({ obj, error }) => ({ + obj: { ...obj, overwrite: true }, + error, + })); + successfulImports = successfulImports.map((obj) => ({ ...obj, overwrite: true })); } const copyResult: ProcessedImportResponse = { + successfulImports, failedImports, } as ProcessedImportResponse; @@ -57,109 +130,101 @@ const createCopyResult = ( describe('summarizeCopyResult', () => { it('indicates the result is processing when not provided', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); const copyResult = undefined; - const includeRelated = true; - - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, - Object { - "conflicts": Array [], - "hasUnresolvableErrors": false, - "id": "foo-viz", - "name": "Foo Viz", - "type": "visualization", - }, - Object { - "conflicts": Array [], - "hasUnresolvableErrors": false, - "id": "bar-viz", - "name": "Bar Viz", - "type": "visualization", - }, ], "processing": true, } `); }); - it('processes failedImports to extract conflicts, including transient conflicts', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); + it('processes failedImports to extract conflicts, including transitive conflicts', () => { const copyResult = createCopyResult({ withConflicts: true }); - const includeRelated = true; + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); expect(summarizedResult).toMatchInlineSnapshot(` Object { "hasConflicts": true, + "hasMissingReferences": false, "hasUnresolvableErrors": false, "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, Object { - "conflicts": Array [ - Object { - "error": Object { - "type": "conflict", - }, - "obj": Object { - "id": "foo-viz", - "type": "visualization", + "conflict": Object { + "error": Object { + "type": "conflict", + }, + "obj": Object { + "id": "bar", + "meta": Object { + "icon": "visualizeApp", + "namespaceType": "single", + "title": "visualization-foo-title", }, + "type": "visualization", }, - ], + }, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "foo-viz", - "name": "Foo Viz", + "icon": "visualizeApp", + "id": "bar", + "name": "visualization-foo-title", + "overwrite": false, "type": "visualization", }, Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "bar-viz", - "name": "Bar Viz", - "type": "visualization", + "icon": "indexPatternApp", + "id": "foo", + "name": "index-pattern-foo-title", + "overwrite": false, + "type": "index-pattern", }, Object { - "conflicts": Array [ - Object { - "error": Object { - "type": "conflict", - }, - "obj": Object { - "id": "transient-index-pattern-conflict", - "type": "index-pattern", - }, - }, - ], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "transient-index-pattern-conflict", - "name": "transient-index-pattern-conflict", + "icon": "indexPatternApp", + "id": "bar", + "name": "index-pattern-bar-title", + "overwrite": false, "type": "index-pattern", }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "baz", + "name": "visualization-bar-title", + "overwrite": false, + "type": "visualization", + }, ], "processing": false, "successful": false, @@ -167,40 +232,54 @@ describe('summarizeCopyResult', () => { `); }); - it('processes failedImports to extract unresolvable errors', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); - const copyResult = createCopyResult({ withUnresolvableError: true }); - const includeRelated = true; + it('processes failedImports to extract missing references errors', () => { + const copyResult = createCopyResult({ withMissingReferencesError: true }); + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); expect(summarizedResult).toMatchInlineSnapshot(` Object { "hasConflicts": false, - "hasUnresolvableErrors": true, + "hasMissingReferences": true, + "hasUnresolvableErrors": false, "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": true, "hasUnresolvableErrors": false, - "id": "foo-viz", - "name": "Foo Viz", + "icon": "visualizeApp", + "id": "baz", + "name": "visualization-bar-title", + "overwrite": false, "type": "visualization", }, Object { - "conflicts": Array [], - "hasUnresolvableErrors": true, - "id": "bar-viz", - "name": "Bar Viz", + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "indexPatternApp", + "id": "foo", + "name": "index-pattern-foo-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "bar", + "name": "visualization-foo-title", + "overwrite": false, "type": "visualization", }, ], @@ -210,75 +289,147 @@ describe('summarizeCopyResult', () => { `); }); - it('processes a result without errors', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); - const copyResult = createCopyResult(); - const includeRelated = true; + it('processes failedImports to extract unresolvable errors', () => { + const copyResult = createCopyResult({ withUnresolvableError: true }); + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); expect(summarizedResult).toMatchInlineSnapshot(` Object { "hasConflicts": false, - "hasUnresolvableErrors": false, + "hasMissingReferences": false, + "hasUnresolvableErrors": true, "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": true, + "icon": "indexPatternApp", + "id": "foo", + "name": "index-pattern-foo-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "foo-viz", - "name": "Foo Viz", + "icon": "indexPatternApp", + "id": "bar", + "name": "index-pattern-bar-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "bar", + "name": "visualization-foo-title", + "overwrite": false, "type": "visualization", }, Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, - "id": "bar-viz", - "name": "Bar Viz", + "icon": "visualizeApp", + "id": "baz", + "name": "visualization-bar-title", + "overwrite": false, "type": "visualization", }, ], "processing": false, - "successful": true, + "successful": false, } `); }); - it('does not include references unless requested', () => { - const SavedObjectsManagementRecord = createSavedObjectsManagementRecord(); + it('processes a result without errors', () => { const copyResult = createCopyResult(); - const includeRelated = false; + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); - const summarizedResult = summarizeCopyResult( - SavedObjectsManagementRecord, - copyResult, - includeRelated - ); expect(summarizedResult).toMatchInlineSnapshot(` Object { "hasConflicts": false, + "hasMissingReferences": false, "hasUnresolvableErrors": false, "objects": Array [ Object { - "conflicts": Array [], + "conflict": undefined, + "hasMissingReferences": false, "hasUnresolvableErrors": false, + "icon": "dashboardApp", "id": "foo", - "name": "my-dashboard", + "name": "my-dashboard-title", + "overwrite": false, "type": "dashboard", }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "indexPatternApp", + "id": "foo", + "name": "index-pattern-foo-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "indexPatternApp", + "id": "bar", + "name": "index-pattern-bar-title", + "overwrite": false, + "type": "index-pattern", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "bar", + "name": "visualization-foo-title", + "overwrite": false, + "type": "visualization", + }, + Object { + "conflict": undefined, + "hasMissingReferences": false, + "hasUnresolvableErrors": false, + "icon": "visualizeApp", + "id": "baz", + "name": "visualization-bar-title", + "overwrite": false, + "type": "visualization", + }, ], "processing": false, "successful": true, } `); }); + + it('indicates when successes and failures have been overwritten', () => { + const copyResult = createCopyResult({ withMissingReferencesError: true, overwrite: true }); + const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + + expect(summarizedResult.objects).toHaveLength(4); + for (const obj of summarizedResult.objects) { + expect(obj.overwrite).toBe(true); + } + }); }); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts index 518e89df579a6..0c07d1a5da7eb 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts @@ -7,19 +7,28 @@ import { SavedObjectsManagementRecord, ProcessedImportResponse, + FailedImport, } from 'src/plugins/saved_objects_management/public'; +import { + SavedObjectsImportConflictError, + SavedObjectsImportAmbiguousConflictError, +} from 'kibana/public'; export interface SummarizedSavedObjectResult { type: string; id: string; name: string; - conflicts: ProcessedImportResponse['failedImports']; + icon: string; + conflict?: FailedImportConflict; + hasMissingReferences: boolean; hasUnresolvableErrors: boolean; + overwrite: boolean; } interface SuccessfulResponse { successful: true; hasConflicts: false; + hasMissingReferences: false; hasUnresolvableErrors: false; objects: SummarizedSavedObjectResult[]; processing: false; @@ -27,6 +36,7 @@ interface SuccessfulResponse { interface UnsuccessfulResponse { successful: false; hasConflicts: boolean; + hasMissingReferences: boolean; hasUnresolvableErrors: boolean; objects: SummarizedSavedObjectResult[]; processing: false; @@ -37,6 +47,19 @@ interface ProcessingResponse { processing: true; } +interface FailedImportConflict { + obj: FailedImport['obj']; + error: SavedObjectsImportConflictError | SavedObjectsImportAmbiguousConflictError; +} + +const isAnyConflict = (failure: FailedImport): failure is FailedImportConflict => + failure.error.type === 'conflict' || failure.error.type === 'ambiguous_conflict'; +const isMissingReferences = (failure: FailedImport) => failure.error.type === 'missing_references'; +const isUnresolvableError = (failure: FailedImport) => + !isAnyConflict(failure) && !isMissingReferences(failure); +const typeComparator = (a: { type: string }, b: { type: string }) => + a.type > b.type ? 1 : a.type < b.type ? -1 : 0; + export type SummarizedCopyToSpaceResult = | SuccessfulResponse | UnsuccessfulResponse @@ -44,69 +67,61 @@ export type SummarizedCopyToSpaceResult = export function summarizeCopyResult( savedObject: SavedObjectsManagementRecord, - copyResult: ProcessedImportResponse | undefined, - includeRelated: boolean + copyResult: ProcessedImportResponse | undefined ): SummarizedCopyToSpaceResult { - const successful = Boolean(copyResult && copyResult.failedImports.length === 0); - - const conflicts = copyResult - ? copyResult.failedImports.filter((failed) => failed.error.type === 'conflict') - : []; - - const unresolvableErrors = copyResult - ? copyResult.failedImports.filter((failed) => failed.error.type !== 'conflict') - : []; - - const hasConflicts = conflicts.length > 0; - - const hasUnresolvableErrors = Boolean( - copyResult && copyResult.failedImports.some((failed) => failed.error.type !== 'conflict') - ); + const conflicts = copyResult?.failedImports.filter(isAnyConflict) ?? []; + const missingReferences = copyResult?.failedImports.filter(isMissingReferences) ?? []; + const unresolvableErrors = + copyResult?.failedImports.filter((failed) => isUnresolvableError(failed)) ?? []; + const getExtraFields = ({ type, id }: { type: string; id: string }) => { + const conflict = conflicts.find(({ obj }) => obj.type === type && obj.id === id); + const missingReference = missingReferences.find( + ({ obj }) => obj.type === type && obj.id === id + ); + const hasMissingReferences = missingReference !== undefined; + const hasUnresolvableErrors = unresolvableErrors.some( + ({ obj }) => obj.type === type && obj.id === id + ); + const overwrite = conflict + ? false + : missingReference + ? missingReference.obj.overwrite === true + : copyResult?.successfulImports.some( + (obj) => obj.type === type && obj.id === id && obj.overwrite + ) === true; + + return { conflict, hasMissingReferences, hasUnresolvableErrors, overwrite }; + }; - const objectMap = new Map(); + const objectMap = new Map(); objectMap.set(`${savedObject.type}:${savedObject.id}`, { type: savedObject.type, id: savedObject.id, name: savedObject.meta.title, - conflicts: conflicts.filter( - (c) => c.obj.type === savedObject.type && c.obj.id === savedObject.id - ), - hasUnresolvableErrors: unresolvableErrors.some( - (e) => e.obj.type === savedObject.type && e.obj.id === savedObject.id - ), + icon: savedObject.meta.icon, + ...getExtraFields(savedObject), }); - if (includeRelated) { - savedObject.references.forEach((ref) => { - objectMap.set(`${ref.type}:${ref.id}`, { - type: ref.type, - id: ref.id, - name: ref.name, - conflicts: conflicts.filter((c) => c.obj.type === ref.type && c.obj.id === ref.id), - hasUnresolvableErrors: unresolvableErrors.some( - (e) => e.obj.type === ref.type && e.obj.id === ref.id - ), - }); - }); - - // The `savedObject.references` array only includes the direct references. It does not include any references of references. - // Therefore, if there are conflicts detected in these transitive references, we need to include them here so that they are visible - // in the UI as resolvable conflicts. - const transitiveConflicts = conflicts.filter( - (c) => !objectMap.has(`${c.obj.type}:${c.obj.id}`) - ); - transitiveConflicts.forEach((conflict) => { - objectMap.set(`${conflict.obj.type}:${conflict.obj.id}`, { - type: conflict.obj.type, - id: conflict.obj.id, - name: conflict.obj.title || conflict.obj.id, - conflicts: conflicts.filter((c) => c.obj.type === conflict.obj.type && conflict.obj.id), - hasUnresolvableErrors: unresolvableErrors.some( - (e) => e.obj.type === conflict.obj.type && e.obj.id === conflict.obj.id - ), + const addObjectsToMap = ( + objects: Array<{ id: string; type: string; meta: { title?: string; icon?: string } }> + ) => { + objects.forEach((obj) => { + const { type, id, meta } = obj; + objectMap.set(`${type}:${id}`, { + type, + id, + name: meta.title || `${type} [id=${id}]`, + icon: meta.icon || 'apps', + ...getExtraFields(obj), }); }); - } + }; + const failedImports = (copyResult?.failedImports ?? []) + .map(({ obj }) => obj) + .sort(typeComparator); + addObjectsToMap(failedImports); + const successfulImports = (copyResult?.successfulImports ?? []).sort(typeComparator); + addObjectsToMap(successfulImports); if (typeof copyResult === 'undefined') { return { @@ -115,20 +130,26 @@ export function summarizeCopyResult( }; } + const successful = Boolean(copyResult && copyResult.failedImports.length === 0); if (successful) { return { successful, hasConflicts: false, objects: Array.from(objectMap.values()), + hasMissingReferences: false, hasUnresolvableErrors: false, processing: false, }; } + const hasConflicts = conflicts.length > 0; + const hasMissingReferences = missingReferences.length > 0; + const hasUnresolvableErrors = unresolvableErrors.length > 0; return { successful, hasConflicts, objects: Array.from(objectMap.values()), + hasMissingReferences, hasUnresolvableErrors, processing: false, }; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts index 9fcc5a89736cc..2310f6c96937c 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts @@ -8,6 +8,7 @@ import { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/pu export interface CopyOptions { includeRelated: boolean; + createNewCopies: boolean; overwrite: boolean; selectedSpaceIds: string[]; } diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 8589993a97e02..cd31a4aa17fc3 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -15,6 +15,7 @@ import { SpacesManager } from './spaces_manager'; import { initSpacesNavControl } from './nav_control'; import { createSpacesFeatureCatalogueEntry } from './create_feature_catalogue_entry'; import { CopySavedObjectsToSpaceService } from './copy_saved_objects_to_space'; +import { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space'; import { AdvancedSettingsService } from './advanced_settings'; import { ManagementService } from './management'; import { spaceSelectorApp } from './space_selector'; @@ -67,6 +68,12 @@ export class SpacesPlugin implements Plugin void; + disabled?: boolean; +} + +type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; + +const activeSpaceProps = { + append: Current, + disabled: true, + checked: 'on' as 'on', +}; + +export const SelectableSpacesControl = (props: Props) => { + if (props.spaces.length === 0) { + return ; + } + + const options = props.spaces + .sort((a, b) => (a.isActiveSpace ? -1 : b.isActiveSpace ? 1 : 0)) + .map((space) => ({ + label: space.name, + prepend: , + checked: props.selectedSpaceIds.includes(space.id) ? 'on' : undefined, + ['data-space-id']: space.id, + ['data-test-subj']: `sts-space-selector-row-${space.id}`, + ...(space.isActiveSpace ? activeSpaceProps : {}), + })); + + function updateSelectedSpaces(selectedOptions: SpaceOption[]) { + if (props.disabled) return; + + const selectedSpaceIds = selectedOptions + .filter((opt) => opt.checked && !opt.disabled) + .map((opt) => opt['data-space-id']); + + props.onChange(selectedSpaceIds); + } + + return ( + updateSelectedSpaces(newOptions as SpaceOption[])} + listProps={{ + bordered: true, + rowHeight: 40, + className: 'spcShareToSpace__spacesList', + 'data-test-subj': 'sts-form-space-selector', + }} + searchable + > + {(list, search) => { + return ( + + {search} + {list} + + ); + }} + + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx new file mode 100644 index 0000000000000..c17a2dcb1a831 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx @@ -0,0 +1,371 @@ +/* + * 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 Boom from 'boom'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; +import { ShareToSpaceForm } from './share_to_space_form'; +import { EuiLoadingSpinner, EuiEmptyPrompt } from '@elastic/eui'; +import { Space } from '../../../common/model/space'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { SelectableSpacesControl } from './selectable_spaces_control'; +import { act } from '@testing-library/react'; +import { spacesManagerMock } from '../../spaces_manager/mocks'; +import { SpacesManager } from '../../spaces_manager'; +import { ToastsApi } from 'src/core/public'; +import { EuiCallOut } from '@elastic/eui'; +import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; + +interface SetupOpts { + mockSpaces?: Space[]; + namespaces?: string[]; + returnBeforeSpacesLoad?: boolean; +} + +const setup = async (opts: SetupOpts = {}) => { + const onClose = jest.fn(); + const onObjectUpdated = jest.fn(); + + const mockSpacesManager = spacesManagerMock.create(); + + mockSpacesManager.getActiveSpace.mockResolvedValue({ + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }); + + mockSpacesManager.getSpaces.mockResolvedValue( + opts.mockSpaces || [ + { + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Space 2', + disabledFeatures: [], + }, + { + id: 'space-3', + name: 'Space 3', + disabledFeatures: [], + }, + { + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }, + ] + ); + + const mockToastNotifications = { + addError: jest.fn(), + addSuccess: jest.fn(), + }; + const savedObjectToShare = { + type: 'dashboard', + id: 'my-dash', + references: [ + { + type: 'visualization', + id: 'my-viz', + name: 'My Viz', + }, + ], + meta: { icon: 'dashboard', title: 'foo' }, + namespaces: opts.namespaces || ['my-active-space', 'space-1'], + } as SavedObjectsManagementRecord; + + const wrapper = mountWithIntl( + + ); + + if (!opts.returnBeforeSpacesLoad) { + // Wait for spaces manager to complete and flyout to rerender + await act(async () => { + await nextTick(); + wrapper.update(); + }); + } + + return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare }; +}; + +describe('ShareToSpaceFlyout', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + it('waits for spaces to load', async () => { + const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + }); + + it('shows a message within an EuiEmptyPrompt when no spaces are available', async () => { + const { wrapper, onClose } = await setup({ mockSpaces: [] }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows a message within an EuiEmptyPrompt when only the active space is available', async () => { + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('does not show a warning callout when the saved object has multiple namespaces', async () => { + const { wrapper, onClose } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows a warning callout when the saved object only has one namespace', async () => { + const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('does not show the Copy flyout by default', async () => { + const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows the Copy flyout if the the "Make a copy" button is clicked', async () => { + const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + const copyButton = findTestSubject(wrapper, 'sts-copy-button'); // this button is only present in the warning callout + + await act(async () => { + copyButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('handles errors thrown from shareSavedObjectsAdd API call', async () => { + const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); + + mockSpacesManager.shareSavedObjectAdd.mockImplementation(() => { + return Promise.reject(Boom.serverUnavailable('Something bad happened')); + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); + expect(mockSpacesManager.shareSavedObjectRemove).not.toHaveBeenCalled(); + expect(mockToastNotifications.addError).toHaveBeenCalled(); + }); + + it('handles errors thrown from shareSavedObjectsRemove API call', async () => { + const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); + + mockSpacesManager.shareSavedObjectRemove.mockImplementation(() => { + return Promise.reject(Boom.serverUnavailable('Something bad happened')); + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); + expect(mockSpacesManager.shareSavedObjectRemove).toHaveBeenCalled(); + expect(mockToastNotifications.addError).toHaveBeenCalled(); + }); + + it('allows the form to be filled out to add a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); + expect(shareSavedObjectRemove).not.toHaveBeenCalled(); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('allows the form to be filled out to remove a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange([]); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).not.toHaveBeenCalled(); + expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('allows the form to be filled out to add and remove a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); + expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(2); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx new file mode 100644 index 0000000000000..10cc5777cdcff --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx @@ -0,0 +1,268 @@ +/* + * 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 { + EuiFlyout, + EuiIcon, + EuiFlyoutHeader, + EuiTitle, + EuiText, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiEmptyPrompt, + EuiButton, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ToastsStart } from 'src/core/public'; +import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { Space } from '../../../common/model/space'; +import { SpacesManager } from '../../spaces_manager'; +import { ShareToSpaceForm } from './share_to_space_form'; +import { ShareOptions, SpaceTarget } from '../types'; +import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; + +interface Props { + onClose: () => void; + onObjectUpdated: () => void; + savedObject: SavedObjectsManagementRecord; + spacesManager: SpacesManager; + toastNotifications: ToastsStart; +} + +const arraysAreEqual = (a: unknown[], b: unknown[]) => + a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); + +export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { + const { onClose, onObjectUpdated, savedObject, spacesManager, toastNotifications } = props; + const { namespaces: currentNamespaces = [] } = savedObject; + const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [] }); + const [showMakeCopy, setShowMakeCopy] = useState(false); + + const [{ isLoading, spaces }, setSpacesState] = useState<{ + isLoading: boolean; + spaces: SpaceTarget[]; + }>({ isLoading: true, spaces: [] }); + useEffect(() => { + const getSpaces = spacesManager.getSpaces('shareSavedObjectsIntoSpace'); + const getActiveSpace = spacesManager.getActiveSpace(); + Promise.all([getSpaces, getActiveSpace]) + .then(([allSpaces, activeSpace]) => { + const createSpaceTarget = (space: Space): SpaceTarget => ({ + ...space, + isActiveSpace: space.id === activeSpace.id, + }); + setSpacesState({ + isLoading: false, + spaces: allSpaces.map((space) => createSpaceTarget(space)), + }); + setShareOptions({ + selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id), + }); + }) + .catch((e) => { + toastNotifications.addError(e, { + title: i18n.translate('xpack.spaces.management.shareToSpace.spacesLoadErrorTitle', { + defaultMessage: 'Error loading available spaces', + }), + }); + }); + }, [currentNamespaces, spacesManager, toastNotifications]); + + const getSelectionChanges = () => { + const activeSpace = spaces.find((space) => space.isActiveSpace); + if (!activeSpace) { + return { changed: false, spacesToAdd: [], spacesToRemove: [] }; + } + const initialSelection = currentNamespaces.filter( + (spaceId) => spaceId !== activeSpace.id && spaceId !== '?' + ); + const { selectedSpaceIds } = shareOptions; + const changed = !arraysAreEqual(initialSelection, selectedSpaceIds); + const spacesToAdd = selectedSpaceIds.filter((spaceId) => !initialSelection.includes(spaceId)); + const spacesToRemove = initialSelection.filter( + (spaceId) => !selectedSpaceIds.includes(spaceId) + ); + return { changed, spacesToAdd, spacesToRemove }; + }; + const { changed: isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); + + const [shareInProgress, setShareInProgress] = useState(false); + + async function startShare() { + setShareInProgress(true); + try { + const { type, id, meta } = savedObject; + const title = + currentNamespaces.length === 1 + ? i18n.translate('xpack.spaces.management.shareToSpace.shareNewSuccessTitle', { + defaultMessage: 'Saved Object is now shared!', + }) + : i18n.translate('xpack.spaces.management.shareToSpace.shareEditSuccessTitle', { + defaultMessage: 'Saved Object updated', + }); + if (spacesToAdd.length > 0) { + await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); + const spaceNames = spacesToAdd.map( + (spaceId) => spaces.find((space) => space.id === spaceId)!.name + ); + const text = i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessText', { + defaultMessage: `'{object}' was added to the following spaces:\n{spaces}`, + values: { object: meta.title, spaces: spaceNames.join(', ') }, + }); + toastNotifications.addSuccess({ title, text }); + } + if (spacesToRemove.length > 0) { + await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); + const spaceNames = spacesToRemove.map( + (spaceId) => spaces.find((space) => space.id === spaceId)!.name + ); + const text = i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessText', { + defaultMessage: `'{object}' was removed from the following spaces:\n{spaces}`, + values: { object: meta.title, spaces: spaceNames.join(', ') }, + }); + toastNotifications.addSuccess({ title, text }); + } + onObjectUpdated(); + onClose(); + } catch (e) { + setShareInProgress(false); + toastNotifications.addError(e, { + title: i18n.translate('xpack.spaces.management.shareToSpace.shareErrorTitle', { + defaultMessage: 'Error updating saved object', + }), + }); + } + } + + const getFlyoutBody = () => { + // Step 1: loading assets for main form + if (isLoading) { + return ; + } + + // Step 1a: assets loaded, but no spaces are available for share. + // The `spaces` array includes the current space, so at minimum it will have a length of 1. + if (spaces.length < 2) { + return ( + + +

+ } + title={ +

+ +

+ } + /> + ); + } + + const showShareWarning = currentNamespaces.length === 1; + // Step 2: Share has not been initiated yet; User must fill out form to continue. + return ( + setShowMakeCopy(true)} + /> + ); + }; + + if (showMakeCopy) { + return ( + + ); + } + + return ( + + + + + + + + +

+ +

+
+
+
+
+ + + + + + + +

{savedObject.meta.title}

+
+
+
+ + + + {getFlyoutBody()} +
+ + + + + onClose()} + data-test-subj="sts-cancel-button" + disabled={shareInProgress} + > + + + + + startShare()} + data-test-subj="sts-initiate-button" + disabled={!isSelectionChanged || shareInProgress} + > + + + + + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.scss b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.scss similarity index 74% rename from x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.scss rename to x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.scss index 87af5d83629a9..41a9c907de745 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.scss +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.scss @@ -1,10 +1,10 @@ // make icon occupy the same space as an EuiSwitch // icon is size m, which is the native $euiSize value // see @elastic/eui/src/components/icon/_variables.scss -.spcCopyToSpaceIncludeRelated .euiIcon { +.spcShareToSpaceIncludeRelated .euiIcon { margin-right: $euiSwitchWidth - $euiSize; } -.spcCopyToSpaceIncludeRelated__label { +.spcShareToSpaceIncludeRelated__label { font-size: $euiFontSizeS; } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx new file mode 100644 index 0000000000000..24402fec8d771 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx @@ -0,0 +1,94 @@ +/* + * 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 './share_to_space_form.scss'; +import React, { Fragment } from 'react'; +import { EuiHorizontalRule, EuiFormRow, EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ShareOptions, SpaceTarget } from '../types'; +import { SelectableSpacesControl } from './selectable_spaces_control'; + +interface Props { + spaces: SpaceTarget[]; + onUpdate: (shareOptions: ShareOptions) => void; + shareOptions: ShareOptions; + showShareWarning: boolean; + makeCopy: () => void; +} + +export const ShareToSpaceForm = (props: Props) => { + const setSelectedSpaceIds = (selectedSpaceIds: string[]) => + props.onUpdate({ ...props.shareOptions, selectedSpaceIds }); + + const getShareWarning = () => { + if (!props.showShareWarning) { + return null; + } + + return ( + + + } + color="warning" + > + + + props.makeCopy()} + color="warning" + data-test-subj="sts-copy-button" + size="s" + > + + + + + + + ); + }; + + return ( +
+ {getShareWarning()} + + + } + labelAppend={ + + } + fullWidth + > + setSelectedSpaceIds(selection)} + /> + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts new file mode 100644 index 0000000000000..037fcb684b47d --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/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 { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space_service'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx new file mode 100644 index 0000000000000..ba9a6473999df --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx @@ -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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'src/core/public'; +import { + SavedObjectsManagementAction, + SavedObjectsManagementRecord, +} from '../../../../../src/plugins/saved_objects_management/public'; +import { ShareSavedObjectsToSpaceFlyout } from './components'; +import { SpacesManager } from '../spaces_manager'; + +export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManagementAction { + public id: string = 'share_saved_objects_to_space'; + + public euiAction = { + name: i18n.translate('xpack.spaces.management.shareToSpace.actionTitle', { + defaultMessage: 'Share to space', + }), + description: i18n.translate('xpack.spaces.management.shareToSpace.actionDescription', { + defaultMessage: 'Share this saved object to one or more spaces', + }), + icon: 'share', + type: 'icon', + available: (object: SavedObjectsManagementRecord) => { + return object.meta.namespaceType === 'multiple'; + }, + onClick: (object: SavedObjectsManagementRecord) => { + this.isDataChanged = false; + this.start(object); + }, + }; + public refreshOnFinish = () => this.isDataChanged; + + private isDataChanged: boolean = false; + + constructor( + private readonly spacesManager: SpacesManager, + private readonly notifications: NotificationsStart + ) { + super(); + } + + public render = () => { + if (!this.record) { + throw new Error('No record available! `render()` was likely called before `start()`.'); + } + + return ( + (this.isDataChanged = true)} + savedObject={this.record} + spacesManager={this.spacesManager} + toastNotifications={this.notifications.toasts} + /> + ); + }; + + private onClose = () => { + this.finish(); + }; +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx new file mode 100644 index 0000000000000..e8649faa120be --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx @@ -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 React, { useState, ReactNode } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBadge } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + SavedObjectsManagementColumn, + SavedObjectsManagementRecord, +} from '../../../../../src/plugins/saved_objects_management/public'; +import { SpaceTarget } from './types'; +import { SpacesManager } from '../spaces_manager'; +import { getSpaceColor } from '..'; + +const SPACES_DISPLAY_COUNT = 5; + +type SpaceMap = Map; +interface ColumnDataProps { + namespaces?: string[]; + data?: SpaceMap; +} + +const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + if (!data) { + return null; + } + + const authorized = namespaces?.filter((namespace) => namespace !== '?') ?? []; + const authorizedSpaceTargets: SpaceTarget[] = []; + authorized.forEach((namespace) => { + const spaceTarget = data.get(namespace); + if (spaceTarget === undefined) { + // in the event that a new space was created after this page has loaded, fall back to displaying the space ID + authorizedSpaceTargets.push({ + id: namespace, + name: namespace, + disabledFeatures: [], + isActiveSpace: false, + }); + } else if (!spaceTarget.isActiveSpace) { + authorizedSpaceTargets.push(spaceTarget); + } + }); + const unauthorizedCount = (namespaces?.filter((namespace) => namespace === '?') ?? []).length; + const unauthorizedTooltip = i18n.translate( + 'xpack.spaces.management.shareToSpace.columnUnauthorizedLabel', + { defaultMessage: 'You do not have permission to view these spaces' } + ); + + const displayedSpaces = isExpanded + ? authorizedSpaceTargets + : authorizedSpaceTargets.slice(0, SPACES_DISPLAY_COUNT); + const showButton = authorizedSpaceTargets.length > SPACES_DISPLAY_COUNT; + + const unauthorizedCountBadge = + (isExpanded || !showButton) && unauthorizedCount > 0 ? ( + + + +{unauthorizedCount} + + + ) : null; + + let button: ReactNode = null; + if (showButton) { + button = isExpanded ? ( + setIsExpanded(false)}> + + + ) : ( + setIsExpanded(true)}> + + + ); + } + + return ( + + {displayedSpaces.map(({ id, name, color }) => ( + + {name} + + ))} + {unauthorizedCountBadge} + {button} + + ); +}; + +export class ShareToSpaceSavedObjectsManagementColumn + implements SavedObjectsManagementColumn { + public id: string = 'share_saved_objects_to_space'; + public data: Map | undefined; + + public euiColumn = { + field: 'namespaces', + name: i18n.translate('xpack.spaces.management.shareToSpace.columnTitle', { + defaultMessage: 'Shared spaces', + }), + description: i18n.translate('xpack.spaces.management.shareToSpace.columnDescription', { + defaultMessage: 'The other spaces that this object is currently shared to', + }), + render: (namespaces: string[] | undefined, _object: SavedObjectsManagementRecord) => ( + + ), + }; + + constructor(private readonly spacesManager: SpacesManager) {} + + public loadData = () => { + this.data = undefined; + return Promise.all([this.spacesManager.getSpaces(), this.spacesManager.getActiveSpace()]).then( + ([spaces, activeSpace]) => { + this.data = spaces + .map((space) => ({ + ...space, + isActiveSpace: space.id === activeSpace.id, + color: getSpaceColor(space), + })) + .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); + return this.data; + } + ); + }; +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts new file mode 100644 index 0000000000000..0f0fa7d22214f --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.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 { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; +// import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; +import { spacesManagerMock } from '../spaces_manager/mocks'; +import { ShareSavedObjectsToSpaceService } from '.'; +import { notificationServiceMock } from 'src/core/public/mocks'; +import { savedObjectsManagementPluginMock } from '../../../../../src/plugins/saved_objects_management/public/mocks'; + +describe('ShareSavedObjectsToSpaceService', () => { + describe('#setup', () => { + it('registers the ShareToSpaceSavedObjectsManagement Action and Column', () => { + const deps = { + spacesManager: spacesManagerMock.create(), + notificationsSetup: notificationServiceMock.createSetupContract(), + savedObjectsManagementSetup: savedObjectsManagementPluginMock.createSetupContract(), + }; + + const service = new ShareSavedObjectsToSpaceService(); + service.setup(deps); + + expect(deps.savedObjectsManagementSetup.actions.register).toHaveBeenCalledTimes(1); + expect(deps.savedObjectsManagementSetup.actions.register).toHaveBeenCalledWith( + expect.any(ShareToSpaceSavedObjectsManagementAction) + ); + + // expect(deps.savedObjectsManagementSetup.columns.register).toHaveBeenCalledTimes(1); + // expect(deps.savedObjectsManagementSetup.columns.register).toHaveBeenCalledWith( + // expect.any(ShareToSpaceSavedObjectsManagementColumn) + // ); + expect(deps.savedObjectsManagementSetup.columns.register).not.toHaveBeenCalled(); // ensure this test fails after column code is uncommented + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts new file mode 100644 index 0000000000000..9f6e57c355380 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.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 { NotificationsSetup } from 'src/core/public'; +import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; +import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; +// import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; +import { SpacesManager } from '../spaces_manager'; + +interface SetupDeps { + spacesManager: SpacesManager; + savedObjectsManagementSetup: SavedObjectsManagementPluginSetup; + notificationsSetup: NotificationsSetup; +} + +export class ShareSavedObjectsToSpaceService { + public setup({ spacesManager, savedObjectsManagementSetup, notificationsSetup }: SetupDeps) { + const action = new ShareToSpaceSavedObjectsManagementAction(spacesManager, notificationsSetup); + savedObjectsManagementSetup.actions.register(action); + // Note: this column is hidden for now because no saved objects are shareable. It should be uncommented when at least one saved object type is multi-namespace. + // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); + // savedObjectsManagementSetup.columns.register(column); + } +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts new file mode 100644 index 0000000000000..fe41f4a5fadc8 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/public'; +import { Space } from '..'; + +export interface ShareOptions { + selectedSpaceIds: string[]; +} + +export type ImportRetry = Omit; + +export interface ShareSavedObjectsToSpaceResponse { + [spaceId: string]: SavedObjectsImportResponse; +} + +export interface SpaceTarget extends Space { + isActiveSpace: boolean; +} diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts index 6186ac7fd93be..f666c823bd365 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts @@ -18,6 +18,8 @@ function createSpacesManagerMock() { updateSpace: jest.fn().mockResolvedValue(undefined), deleteSpace: jest.fn().mockResolvedValue(undefined), copySavedObjects: jest.fn().mockResolvedValue(undefined), + shareSavedObjectAdd: jest.fn().mockResolvedValue(undefined), + shareSavedObjectRemove: jest.fn().mockResolvedValue(undefined), resolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(undefined), redirectToSpaceSelector: jest.fn().mockResolvedValue(undefined), } as unknown) as jest.Mocked; diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts index ac5cb56084cfc..2daf9ab420efc 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts @@ -11,6 +11,8 @@ import { Space } from '../../common/model/space'; import { GetSpacePurpose } from '../../common/model/types'; import { CopySavedObjectsToSpaceResponse } from '../copy_saved_objects_to_space/types'; +type SavedObject = Pick; + export class SpacesManager { private activeSpace$: BehaviorSubject = new BehaviorSubject(null); @@ -72,9 +74,10 @@ export class SpacesManager { } public async copySavedObjects( - objects: Array>, + objects: SavedObject[], spaces: string[], includeReferences: boolean, + createNewCopies: boolean, overwrite: boolean ): Promise { return this.http.post('/api/spaces/_copy_saved_objects', { @@ -82,25 +85,39 @@ export class SpacesManager { objects, spaces, includeReferences, - overwrite, + ...(createNewCopies ? { createNewCopies } : { overwrite }), }), }); } public async resolveCopySavedObjectsErrors( - objects: Array>, + objects: SavedObject[], retries: unknown, - includeReferences: boolean + includeReferences: boolean, + createNewCopies: boolean ): Promise { return this.http.post(`/api/spaces/_resolve_copy_saved_objects_errors`, { body: JSON.stringify({ objects, includeReferences, + createNewCopies, retries, }), }); } + public async shareSavedObjectAdd(object: SavedObject, spaces: string[]): Promise { + return this.http.post(`/api/spaces/_share_saved_object_add`, { + body: JSON.stringify({ object, spaces }), + }); + } + + public async shareSavedObjectRemove(object: SavedObject, spaces: string[]): Promise { + return this.http.post(`/api/spaces/_share_saved_object_remove`, { + body: JSON.stringify({ object, spaces }), + }); + } + public redirectToSpaceSelector() { window.location.href = `${this.serverBasePath}/spaces/space_selector`; } diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts index 9679dd8c52523..d49dfa2015dc6 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts @@ -3,14 +3,20 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { Readable } from 'stream'; import { SavedObjectsImportResponse, SavedObjectsImportOptions, SavedObjectsExportOptions, + SavedObjectsImportSuccess, } from 'src/core/server'; +import { + coreMock, + httpServerMock, + savedObjectsTypeRegistryMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; import { copySavedObjectsToSpacesFactory } from './copy_to_spaces'; -import { Readable } from 'stream'; -import { coreMock, savedObjectsTypeRegistryMock, httpServerMock } from 'src/core/server/mocks'; jest.mock('../../../../../../src/core/server', () => { return { @@ -31,6 +37,8 @@ interface SetupOpts { ) => Promise; } +const EXPORT_LIMIT = 1000; + const expectStreamToContainObjects = async ( stream: Readable, expectedObjects: SetupOpts['objects'] @@ -50,23 +58,29 @@ const expectStreamToContainObjects = async ( }; describe('copySavedObjectsToSpaces', () => { + const mockExportResults = [ + { type: 'dashboard', id: 'my-dashboard', attributes: {} }, + { type: 'visualization', id: 'my-viz', attributes: {} }, + { type: 'index-pattern', id: 'my-index-pattern', attributes: {} }, + { type: 'globaltype', id: 'my-globaltype', attributes: {} }, + ]; + const setup = (setupOpts: SetupOpts) => { const coreStart = coreMock.createStart(); + const savedObjectsClient = savedObjectsClientMock.create(); const typeRegistry = savedObjectsTypeRegistryMock.create(); - typeRegistry.getAllTypes.mockReturnValue([ + coreStart.savedObjects.getScopedClient.mockReturnValue(savedObjectsClient); + coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); + + typeRegistry.getImportableAndExportableTypes.mockReturnValue([ + // don't need to include all types, just need a positive case (agnostic) and a negative case (non-agnostic) { name: 'dashboard', namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, - { - name: 'visualization', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, { name: 'globaltype', namespaceType: 'agnostic', @@ -74,13 +88,12 @@ describe('copySavedObjectsToSpaces', () => { mappings: { properties: {} }, }, ]); - typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some((t) => t.name === type && t.namespaceType === 'agnostic') + typeRegistry + .getImportableAndExportableTypes() + .some((t) => t.name === type && t.namespaceType === 'agnostic') ); - coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); - (exportSavedObjectsToStream as jest.Mock).mockImplementation( async (opts: SavedObjectsExportOptions) => { return ( @@ -100,10 +113,15 @@ describe('copySavedObjectsToSpaces', () => { (importSavedObjectsFromStream as jest.Mock).mockImplementation( async (opts: SavedObjectsImportOptions) => { const defaultImpl = async () => { - await expectStreamToContainObjects(opts.readStream, setupOpts.objects); + // namespace-agnostic types should be filtered out before import + const filteredObjects = setupOpts.objects.filter(({ type }) => type !== 'globaltype'); + await expectStreamToContainObjects(opts.readStream, filteredObjects); const response: SavedObjectsImportResponse = { success: true, - successCount: setupOpts.objects.length, + successCount: filteredObjects.length, + successResults: [ + ('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess, + ], }; return Promise.resolve(response); @@ -115,261 +133,95 @@ describe('copySavedObjectsToSpaces', () => { return { savedObjects: coreStart.savedObjects, + savedObjectsClient, + typeRegistry, }; }; it('uses the Saved Objects Service to perform an export followed by a series of imports', async () => { - const { savedObjects } = setup({ - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ], + const { savedObjects, savedObjectsClient, typeRegistry } = setup({ + objects: mockExportResults, }); const request = httpServerMock.createKibanaRequest(); const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); - const result = await copySavedObjectsToSpaces('sourceSpace', ['destination1', 'destination2'], { + const namespace = 'sourceSpace'; + const objects = [{ type: 'dashboard', id: 'my-dashboard' }]; + const result = await copySavedObjectsToSpaces(namespace, ['destination1', 'destination2'], { includeReferences: true, overwrite: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + objects, + createNewCopies: false, }); expect(result).toMatchInlineSnapshot(` - Object { - "destination1": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - "destination2": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - } - `); - - expect((exportSavedObjectsToStream as jest.Mock).mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "excludeExportDetails": true, - "exportSizeLimit": 1000, - "includeReferencesDeep": true, - "namespace": "sourceSpace", - "objects": Array [ - Object { - "id": "my-dashboard", - "type": "dashboard", - }, - ], - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - }, - ], - ] + Object { + "destination1": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + "destination2": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + } `); - expect((importSavedObjectsFromStream as jest.Mock).mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "namespace": "destination1", - "objectLimit": 1000, - "overwrite": true, - "readStream": Readable { - "_events": Object { - "data": [Function], - "end": [Function], - "error": [Function], - }, - "_eventsCount": 3, - "_maxListeners": undefined, - "_read": [Function], - "_readableState": ReadableState { - "autoDestroy": false, - "awaitDrain": 0, - "buffer": BufferList { - "head": null, - "length": 0, - "tail": null, - }, - "decoder": null, - "defaultEncoding": "utf8", - "destroyed": false, - "emitClose": true, - "emittedReadable": false, - "encoding": null, - "endEmitted": true, - "ended": true, - "flowing": true, - "highWaterMark": 16, - "length": 0, - "needReadable": false, - "objectMode": true, - "paused": false, - "pipes": null, - "pipesCount": 0, - "readableListening": false, - "reading": false, - "readingMore": false, - "resumeScheduled": false, - "sync": false, - }, - "readable": false, - }, - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], - }, - ], - Array [ - Object { - "namespace": "destination2", - "objectLimit": 1000, - "overwrite": true, - "readStream": Readable { - "_events": Object { - "data": [Function], - "end": [Function], - "error": [Function], - }, - "_eventsCount": 3, - "_maxListeners": undefined, - "_read": [Function], - "_readableState": ReadableState { - "autoDestroy": false, - "awaitDrain": 0, - "buffer": BufferList { - "head": null, - "length": 0, - "tail": null, - }, - "decoder": null, - "defaultEncoding": "utf8", - "destroyed": false, - "emitClose": true, - "emittedReadable": false, - "encoding": null, - "endEmitted": true, - "ended": true, - "flowing": true, - "highWaterMark": 16, - "length": 0, - "needReadable": false, - "objectMode": true, - "paused": false, - "pipes": null, - "pipesCount": 0, - "readableListening": false, - "reading": false, - "readingMore": false, - "resumeScheduled": false, - "sync": false, - }, - "readable": false, - }, - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], - }, - ], - ] - `); + expect(exportSavedObjectsToStream).toHaveBeenCalledWith({ + excludeExportDetails: true, + exportSizeLimit: EXPORT_LIMIT, + includeReferencesDeep: true, + namespace, + objects, + savedObjectsClient, + }); + + const importOptions = { + createNewCopies: false, + objectLimit: EXPORT_LIMIT, + overwrite: true, + readStream: expect.any(Readable), + savedObjectsClient, + typeRegistry, + }; + expect(importSavedObjectsFromStream).toHaveBeenNthCalledWith(1, { + ...importOptions, + namespace: 'destination1', + }); + expect(importSavedObjectsFromStream).toHaveBeenNthCalledWith(2, { + ...importOptions, + namespace: 'destination2', + }); }); it(`doesn't stop copy if some spaces fail`, async () => { - const objects = [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ]; - const { savedObjects } = setup({ - objects, + objects: mockExportResults, importSavedObjectsFromStreamImpl: async (opts) => { if (opts.namespace === 'failure-space') { throw new Error(`Some error occurred!`); } - await expectStreamToContainObjects(opts.readStream, objects); + // namespace-agnostic types should be filtered out before import + const filteredObjects = mockExportResults.filter(({ type }) => type !== 'globaltype'); + await expectStreamToContainObjects(opts.readStream, filteredObjects); return Promise.resolve({ success: true, - successCount: 3, + successCount: filteredObjects.length, + successResults: [('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess], }); }, }); @@ -378,7 +230,7 @@ describe('copySavedObjectsToSpaces', () => { const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); @@ -388,58 +240,44 @@ describe('copySavedObjectsToSpaces', () => { { includeReferences: true, overwrite: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + objects: [{ type: 'dashboard', id: 'my-dashboard' }], + createNewCopies: false, } ); expect(result).toMatchInlineSnapshot(` - Object { - "failure-space": Object { - "errors": Array [ - [Error: Some error occurred!], - ], - "success": false, - "successCount": 0, - }, - "marketing": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - "non-existent-space": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - } - `); + Object { + "failure-space": Object { + "errors": Array [ + [Error: Some error occurred!], + ], + "success": false, + "successCount": 0, + }, + "marketing": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + "non-existent-space": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + } + `); }); it(`handles stream read errors`, async () => { const { savedObjects } = setup({ - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ], - exportSavedObjectsToStreamImpl: (opts) => { + objects: mockExportResults, + exportSavedObjectsToStreamImpl: (_opts) => { return Promise.resolve( new Readable({ objectMode: true, @@ -455,7 +293,7 @@ describe('copySavedObjectsToSpaces', () => { const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); @@ -466,12 +304,8 @@ describe('copySavedObjectsToSpaces', () => { { includeReferences: true, overwrite: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + objects: [{ type: 'dashboard', id: 'my-dashboard' }], + createNewCopies: false, } ) ).rejects.toThrowErrorMatchingInlineSnapshot( diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts index dca6f2a6206ab..5575052d7bbb8 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts @@ -12,11 +12,11 @@ import { } from '../../../../../../src/core/server'; import { spaceIdToNamespace } from '../utils/namespace'; import { CopyOptions, CopyResponse } from './types'; -import { getEligibleTypes } from './lib/get_eligible_types'; import { createReadableStreamFromArray } from './lib/readable_stream_from_array'; import { createEmptyFailureResponse } from './lib/create_empty_failure_response'; import { readStreamToCompletion } from './lib/read_stream_to_completion'; import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from './lib/saved_objects_client_opts'; +import { getIneligibleTypes } from './lib/get_ineligible_types'; export function copySavedObjectsToSpacesFactory( savedObjects: CoreStart['savedObjects'], @@ -27,8 +27,6 @@ export function copySavedObjectsToSpacesFactory( const savedObjectsClient = getScopedClient(request, COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS); - const eligibleTypes = getEligibleTypes(getTypeRegistry()); - const exportRequestedObjects = async ( sourceSpaceId: string, options: Pick @@ -56,13 +54,15 @@ export function copySavedObjectsToSpacesFactory( objectLimit: getImportExportObjectLimit(), overwrite: options.overwrite, savedObjectsClient, - supportedTypes: eligibleTypes, + typeRegistry: getTypeRegistry(), readStream: objectsStream, + createNewCopies: options.createNewCopies, }); return { success: importResponse.success, successCount: importResponse.successCount, + successResults: importResponse.successResults, errors: importResponse.errors, }; } catch (error) { @@ -78,11 +78,15 @@ export function copySavedObjectsToSpacesFactory( const response: CopyResponse = {}; const exportedSavedObjects = await exportRequestedObjects(sourceSpaceId, options); + const ineligibleTypes = getIneligibleTypes(getTypeRegistry()); + const filteredObjects = exportedSavedObjects.filter( + ({ type }) => !ineligibleTypes.includes(type) + ); for (const spaceId of destinationSpaceIds) { response[spaceId] = await importObjectsToSpace( spaceId, - createReadableStreamFromArray(exportedSavedObjects), + createReadableStreamFromArray(filteredObjects), options ); } diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts deleted file mode 100644 index e5f2c5b18bd00..0000000000000 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.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. - */ - -import { SavedObjectTypeRegistry } from 'src/core/server'; - -export function getEligibleTypes( - typeRegistry: Pick -) { - return typeRegistry - .getAllTypes() - .filter((type) => !typeRegistry.isNamespaceAgnostic(type.name)) - .map((type) => type.name); -} diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_ineligible_types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_ineligible_types.ts new file mode 100644 index 0000000000000..91d4cb13b98eb --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_ineligible_types.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 { SavedObjectTypeRegistry } from 'src/core/server'; + +/** + * This function returns any importable/exportable saved object types that are namespace-agnostic. Even if these are eligible for + * import/export, we should not include them in the copy operation because it will result in a conflict that needs to overwrite itself to be + * resolved. + */ +export function getIneligibleTypes( + typeRegistry: Pick< + SavedObjectTypeRegistry, + 'getImportableAndExportableTypes' | 'isNamespaceAgnostic' + > +) { + return typeRegistry + .getImportableAndExportableTypes() + .filter((type) => typeRegistry.isNamespaceAgnostic(type.name)) + .map((type) => type.name); +} diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts index 7bb4c61ed51a0..6a77bf7397cb5 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts @@ -3,13 +3,19 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { Readable } from 'stream'; import { SavedObjectsImportResponse, SavedObjectsResolveImportErrorsOptions, SavedObjectsExportOptions, + SavedObjectsImportSuccess, } from 'src/core/server'; -import { coreMock, savedObjectsTypeRegistryMock, httpServerMock } from 'src/core/server/mocks'; -import { Readable } from 'stream'; +import { + coreMock, + httpServerMock, + savedObjectsTypeRegistryMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; import { resolveCopySavedObjectsToSpacesConflictsFactory } from './resolve_copy_conflicts'; jest.mock('../../../../../../src/core/server', () => { @@ -31,6 +37,8 @@ interface SetupOpts { ) => Promise; } +const EXPORT_LIMIT = 1000; + const expectStreamToContainObjects = async ( stream: Readable, expectedObjects: SetupOpts['objects'] @@ -50,23 +58,28 @@ const expectStreamToContainObjects = async ( }; describe('resolveCopySavedObjectsToSpacesConflicts', () => { + const mockExportResults = [ + { type: 'dashboard', id: 'my-dashboard', attributes: {} }, + { type: 'visualization', id: 'my-viz', attributes: {} }, + { type: 'index-pattern', id: 'my-index-pattern', attributes: {} }, + ]; + const setup = (setupOpts: SetupOpts) => { const coreStart = coreMock.createStart(); + const savedObjectsClient = savedObjectsClientMock.create(); const typeRegistry = savedObjectsTypeRegistryMock.create(); - typeRegistry.getAllTypes.mockReturnValue([ + coreStart.savedObjects.getScopedClient.mockReturnValue(savedObjectsClient); + coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); + + typeRegistry.getImportableAndExportableTypes.mockReturnValue([ + // don't need to include all types, just need a positive case (agnostic) and a negative case (non-agnostic) { name: 'dashboard', namespaceType: 'single', hidden: false, mappings: { properties: {} }, }, - { - name: 'visualization', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, { name: 'globaltype', namespaceType: 'agnostic', @@ -74,13 +87,12 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { mappings: { properties: {} }, }, ]); - typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some((t) => t.name === type && t.namespaceType === 'agnostic') + typeRegistry + .getImportableAndExportableTypes() + .some((t) => t.name === type && t.namespaceType === 'agnostic') ); - coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); - (exportSavedObjectsToStream as jest.Mock).mockImplementation( async (opts: SavedObjectsExportOptions) => { return ( @@ -100,11 +112,16 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { (resolveSavedObjectsImportErrors as jest.Mock).mockImplementation( async (opts: SavedObjectsResolveImportErrorsOptions) => { const defaultImpl = async () => { - await expectStreamToContainObjects(opts.readStream, setupOpts.objects); + // namespace-agnostic types should be filtered out before import + const filteredObjects = setupOpts.objects.filter(({ type }) => type !== 'globaltype'); + await expectStreamToContainObjects(opts.readStream, filteredObjects); const response: SavedObjectsImportResponse = { success: true, - successCount: setupOpts.objects.length, + successCount: filteredObjects.length, + successResults: [ + ('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess, + ], }; return response; @@ -116,290 +133,100 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { return { savedObjects: coreStart.savedObjects, + savedObjectsClient, + typeRegistry, }; }; it('uses the Saved Objects Service to perform an export followed by a series of conflict resolution calls', async () => { - const { savedObjects } = setup({ - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ], + const { savedObjects, savedObjectsClient, typeRegistry } = setup({ + objects: mockExportResults, }); const request = httpServerMock.createKibanaRequest(); const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); - const result = await resolveCopySavedObjectsToSpacesConflicts('sourceSpace', { + const namespace = 'sourceSpace'; + const objects = [{ type: 'dashboard', id: 'my-dashboard' }]; + const retries = { + destination1: [{ type: 'visualization', id: 'my-visualization', overwrite: true }], + destination2: [{ type: 'visualization', id: 'my-visualization', overwrite: false }], + }; + const result = await resolveCopySavedObjectsToSpacesConflicts(namespace, { includeReferences: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], - retries: { - destination1: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: true, - }, - ], - destination2: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: false, - }, - ], - }, + objects, + retries, + createNewCopies: false, }); expect(result).toMatchInlineSnapshot(` - Object { - "destination1": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - "destination2": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - } - `); - - expect((exportSavedObjectsToStream as jest.Mock).mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "excludeExportDetails": true, - "exportSizeLimit": 1000, - "includeReferencesDeep": true, - "namespace": "sourceSpace", - "objects": Array [ - Object { - "id": "my-dashboard", - "type": "dashboard", - }, - ], - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - }, - ], - ] + Object { + "destination1": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + "destination2": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + } `); - expect((resolveSavedObjectsImportErrors as jest.Mock).mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "namespace": "destination1", - "objectLimit": 1000, - "readStream": Readable { - "_events": Object { - "data": [Function], - "end": [Function], - "error": [Function], - }, - "_eventsCount": 3, - "_maxListeners": undefined, - "_read": [Function], - "_readableState": ReadableState { - "autoDestroy": false, - "awaitDrain": 0, - "buffer": BufferList { - "head": null, - "length": 0, - "tail": null, - }, - "decoder": null, - "defaultEncoding": "utf8", - "destroyed": false, - "emitClose": true, - "emittedReadable": false, - "encoding": null, - "endEmitted": true, - "ended": true, - "flowing": true, - "highWaterMark": 16, - "length": 0, - "needReadable": false, - "objectMode": true, - "paused": false, - "pipes": null, - "pipesCount": 0, - "readableListening": false, - "reading": false, - "readingMore": false, - "resumeScheduled": false, - "sync": false, - }, - "readable": false, - }, - "retries": Array [ - Object { - "id": "my-visualization", - "overwrite": true, - "replaceReferences": Array [], - "type": "visualization", - }, - ], - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], - }, - ], - Array [ - Object { - "namespace": "destination2", - "objectLimit": 1000, - "readStream": Readable { - "_events": Object { - "data": [Function], - "end": [Function], - "error": [Function], - }, - "_eventsCount": 3, - "_maxListeners": undefined, - "_read": [Function], - "_readableState": ReadableState { - "autoDestroy": false, - "awaitDrain": 0, - "buffer": BufferList { - "head": null, - "length": 0, - "tail": null, - }, - "decoder": null, - "defaultEncoding": "utf8", - "destroyed": false, - "emitClose": true, - "emittedReadable": false, - "encoding": null, - "endEmitted": true, - "ended": true, - "flowing": true, - "highWaterMark": 16, - "length": 0, - "needReadable": false, - "objectMode": true, - "paused": false, - "pipes": null, - "pipesCount": 0, - "readableListening": false, - "reading": false, - "readingMore": false, - "resumeScheduled": false, - "sync": false, - }, - "readable": false, - }, - "retries": Array [ - Object { - "id": "my-visualization", - "overwrite": false, - "replaceReferences": Array [], - "type": "visualization", - }, - ], - "savedObjectsClient": Object { - "addToNamespaces": [MockFunction], - "bulkCreate": [MockFunction], - "bulkGet": [MockFunction], - "bulkUpdate": [MockFunction], - "create": [MockFunction], - "delete": [MockFunction], - "deleteFromNamespaces": [MockFunction], - "errors": [Function], - "find": [MockFunction], - "get": [MockFunction], - "update": [MockFunction], - }, - "supportedTypes": Array [ - "dashboard", - "visualization", - ], - }, - ], - ] - `); + expect(exportSavedObjectsToStream).toHaveBeenCalledWith({ + excludeExportDetails: true, + exportSizeLimit: EXPORT_LIMIT, + includeReferencesDeep: true, + namespace, + objects, + savedObjectsClient, + }); + + const importOptions = { + createNewCopies: false, + objectLimit: EXPORT_LIMIT, + readStream: expect.any(Readable), + savedObjectsClient, + typeRegistry, + }; + expect(resolveSavedObjectsImportErrors).toHaveBeenNthCalledWith(1, { + ...importOptions, + namespace: 'destination1', + retries: [{ ...retries.destination1[0], replaceReferences: [] }], + }); + expect(resolveSavedObjectsImportErrors).toHaveBeenNthCalledWith(2, { + ...importOptions, + namespace: 'destination2', + retries: [{ ...retries.destination2[0], replaceReferences: [] }], + }); }); it(`doesn't stop resolution if some spaces fail`, async () => { - const objects = [ - { - type: 'dashboard', - id: 'my-dashboard', - attributes: {}, - }, - { - type: 'visualization', - id: 'my-viz', - attributes: {}, - }, - { - type: 'index-pattern', - id: 'my-index-pattern', - attributes: {}, - }, - ]; - const { savedObjects } = setup({ - objects, + objects: mockExportResults, resolveSavedObjectsImportErrorsImpl: async (opts) => { if (opts.namespace === 'failure-space') { throw new Error(`Some error occurred!`); } - await expectStreamToContainObjects(opts.readStream, objects); + // namespace-agnostic types should be filtered out before import + const filteredObjects = mockExportResults.filter(({ type }) => type !== 'globaltype'); + await expectStreamToContainObjects(opts.readStream, filteredObjects); return Promise.resolve({ success: true, - successCount: 3, + successCount: filteredObjects.length, + successResults: [('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess], }); }, }); @@ -408,64 +235,50 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); const result = await resolveCopySavedObjectsToSpacesConflicts('sourceSpace', { includeReferences: true, - objects: [ - { - type: 'dashboard', - id: 'my-dashboard', - }, - ], + objects: [{ type: 'dashboard', id: 'my-dashboard' }], retries: { - ['failure-space']: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: true, - }, - ], + ['failure-space']: [{ type: 'visualization', id: 'my-visualization', overwrite: true }], ['non-existent-space']: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: false, - }, - ], - ['marketing']: [ - { - type: 'visualization', - id: 'my-visualization', - overwrite: true, - }, + { type: 'visualization', id: 'my-visualization', overwrite: false }, ], + marketing: [{ type: 'visualization', id: 'my-visualization', overwrite: true }], }, + createNewCopies: false, }); expect(result).toMatchInlineSnapshot(` - Object { - "failure-space": Object { - "errors": Array [ - [Error: Some error occurred!], - ], - "success": false, - "successCount": 0, - }, - "marketing": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - "non-existent-space": Object { - "errors": undefined, - "success": true, - "successCount": 3, - }, - } - `); + Object { + "failure-space": Object { + "errors": Array [ + [Error: Some error occurred!], + ], + "success": false, + "successCount": 0, + }, + "marketing": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + "non-existent-space": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + } + `); }); it(`handles stream read errors`, async () => { @@ -487,7 +300,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( savedObjects, - () => 1000, + () => EXPORT_LIMIT, request ); @@ -496,6 +309,7 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { includeReferences: true, objects: [], retries: {}, + createNewCopies: false, }) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Something went wrong while reading this stream"` diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts index a355d19b305a3..d433712bb9412 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts @@ -5,18 +5,18 @@ */ import { Readable } from 'stream'; -import { SavedObject, CoreStart, KibanaRequest } from 'src/core/server'; +import { SavedObject, CoreStart, KibanaRequest, SavedObjectsImportRetry } from 'src/core/server'; import { exportSavedObjectsToStream, resolveSavedObjectsImportErrors, } from '../../../../../../src/core/server'; import { spaceIdToNamespace } from '../utils/namespace'; import { CopyOptions, ResolveConflictsOptions, CopyResponse } from './types'; -import { getEligibleTypes } from './lib/get_eligible_types'; import { createEmptyFailureResponse } from './lib/create_empty_failure_response'; import { readStreamToCompletion } from './lib/read_stream_to_completion'; import { createReadableStreamFromArray } from './lib/readable_stream_from_array'; import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from './lib/saved_objects_client_opts'; +import { getIneligibleTypes } from './lib/get_ineligible_types'; export function resolveCopySavedObjectsToSpacesConflictsFactory( savedObjects: CoreStart['savedObjects'], @@ -27,8 +27,6 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( const savedObjectsClient = getScopedClient(request, COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS); - const eligibleTypes = getEligibleTypes(getTypeRegistry()); - const exportRequestedObjects = async ( sourceSpaceId: string, options: Pick @@ -47,26 +45,24 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( const resolveConflictsForSpace = async ( spaceId: string, objectsStream: Readable, - retries: Array<{ - type: string; - id: string; - overwrite: boolean; - replaceReferences: Array<{ type: string; from: string; to: string }>; - }> + retries: SavedObjectsImportRetry[], + createNewCopies: boolean ) => { try { const importResponse = await resolveSavedObjectsImportErrors({ namespace: spaceIdToNamespace(spaceId), objectLimit: getImportExportObjectLimit(), savedObjectsClient, - supportedTypes: eligibleTypes, + typeRegistry: getTypeRegistry(), readStream: objectsStream, retries, + createNewCopies, }); return { success: importResponse.success, successCount: importResponse.successCount, + successResults: importResponse.successResults, errors: importResponse.errors, }; } catch (error) { @@ -84,6 +80,10 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( includeReferences: options.includeReferences, objects: options.objects, }); + const ineligibleTypes = getIneligibleTypes(getTypeRegistry()); + const filteredObjects = exportedSavedObjects.filter( + ({ type }) => !ineligibleTypes.includes(type) + ); for (const entry of Object.entries(options.retries)) { const [spaceId, entryRetries] = entry; @@ -92,8 +92,9 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( response[spaceId] = await resolveConflictsForSpace( spaceId, - createReadableStreamFromArray(exportedSavedObjects), - retries + createReadableStreamFromArray(filteredObjects), + retries, + options.createNewCopies ); } diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts index 1bbe5aa6625b0..8d4169f972795 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts @@ -5,26 +5,33 @@ */ import { Payload } from 'boom'; -import { SavedObjectsImportError } from 'src/core/server'; +import { + SavedObjectsImportSuccess, + SavedObjectsImportError, + SavedObjectsImportRetry, +} from 'src/core/server'; export interface CopyOptions { objects: Array<{ type: string; id: string }>; overwrite: boolean; includeReferences: boolean; + createNewCopies: boolean; } export interface ResolveConflictsOptions { objects: Array<{ type: string; id: string }>; includeReferences: boolean; retries: { - [spaceId: string]: Array<{ type: string; id: string; overwrite: boolean }>; + [spaceId: string]: Array>; }; + createNewCopies: boolean; } export interface CopyResponse { [spaceId: string]: { success: boolean; successCount: number; + successResults?: SavedObjectsImportSuccess[]; errors?: Array; }; } diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap b/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap index c2df94a0a2936..9544d7e8bb481 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap +++ b/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap @@ -28,6 +28,8 @@ exports[`#getAll useRbacForRequest is true with purpose='copySavedObjectsIntoSpa exports[`#getAll useRbacForRequest is true with purpose='findSavedObjects' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; +exports[`#getAll useRbacForRequest is true with purpose='shareSavedObjectsIntoSpace' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; + exports[`#getAll useRbacForRequest is true with purpose='undefined' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; exports[`#update useRbacForRequest is true throws Boom.forbidden when user isn't authorized at space 1`] = `"Unauthorized to update spaces"`; diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index 61b1985c5a0b9..90ce2b01bfd20 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -242,6 +242,11 @@ describe('#getAll', () => { expectedPrivilege: (mockAuthorization: SecurityPluginSetup['authz']) => mockAuthorization.actions.savedObject.get('config', 'find'), }, + { + purpose: 'shareSavedObjectsIntoSpace' as GetSpacePurpose, + expectedPrivilege: (mockAuthorization: SecurityPluginSetup['authz']) => + mockAuthorization.actions.ui.get('savedObjectsManagement', 'shareIntoSpace'), + }, ].forEach((scenario) => { describe(`with purpose='${scenario.purpose}'`, () => { test(`throws Boom.forbidden when user isn't authorized for any spaces`, async () => { diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index dd2e0d40f31ed..b1d6e3200ab3a 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -17,6 +17,7 @@ const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = [ 'any', 'copySavedObjectsIntoSpace', 'findSavedObjects', + 'shareSavedObjectsIntoSpace', ]; const PURPOSE_PRIVILEGE_MAP: Record< @@ -30,6 +31,9 @@ const PURPOSE_PRIVILEGE_MAP: Record< findSavedObjects: (authorization) => { return [authorization.actions.savedObject.get('config', 'find')]; }, + shareSavedObjectsIntoSpace: (authorization) => [ + authorization.actions.ui.get('savedObjectsManagement', 'shareIntoSpace'), + ], }; export class SpacesClient { diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts index 034d212a33035..ce93591f492f1 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts @@ -43,41 +43,6 @@ export const createMockSavedObjectsService = (spaces: any[] = []) => { const { savedObjects } = coreMock.createStart(); const typeRegistry = savedObjectsTypeRegistryMock.create(); - typeRegistry.getAllTypes.mockReturnValue([ - { - name: 'visualization', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'dashboard', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'index-pattern', - namespaceType: 'single', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'globalType', - namespaceType: 'agnostic', - hidden: false, - mappings: { properties: {} }, - }, - { - name: 'space', - namespaceType: 'agnostic', - hidden: true, - mappings: { properties: {} }, - }, - ]); - typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => - typeRegistry.getAllTypes().some((t) => t.name === type && t.namespaceType === 'agnostic') - ); savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); savedObjects.getScopedClient.mockReturnValue(mockSavedObjectsClientContract); diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index b604554cbc59a..bec3a5dcb0b71 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -191,54 +191,35 @@ describe('copy to space', () => { ); }); - it(`requires objects to be unique`, async () => { + it(`does not allow "overwrite" to be used with "createNewCopies"`, async () => { const payload = { spaces: ['a-space'], - objects: [ - { type: 'foo', id: 'bar' }, - { type: 'foo', id: 'bar' }, - ], + objects: [{ type: 'foo', id: 'bar' }], + overwrite: true, + createNewCopies: true, }; const { copyToSpace } = await setup(); expect(() => (copyToSpace.routeValidation.body as ObjectType).validate(payload) - ).toThrowErrorMatchingInlineSnapshot(`"[objects]: duplicate objects are not allowed"`); + ).toThrowErrorMatchingInlineSnapshot(`"cannot use [overwrite] with [createNewCopies]"`); }); - it('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { + it(`requires objects to be unique`, async () => { const payload = { spaces: ['a-space'], objects: [ - { type: 'globalType', id: 'bar' }, - { type: 'visualization', id: 'bar' }, + { type: 'foo', id: 'bar' }, + { type: 'foo', id: 'bar' }, ], }; const { copyToSpace } = await setup(); - const request = httpServerMock.createKibanaRequest({ - body: payload, - method: 'post', - }); - - const response = await copyToSpace.routeHandler( - mockRouteContext, - request, - kibanaResponseFactory - ); - - const { status } = response; - - expect(status).toEqual(200); - expect(importSavedObjectsFromStream).toHaveBeenCalledTimes(1); - const [importCallOptions] = (importSavedObjectsFromStream as jest.Mock).mock.calls[0]; - - expect(importCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); + expect(() => + (copyToSpace.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[objects]: duplicate objects are not allowed"`); }); it('copies to multiple spaces', async () => { @@ -365,58 +346,6 @@ describe('copy to space', () => { ); }); - it('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { - const payload = { - retries: { - ['a-space']: [ - { - type: 'visualization', - id: 'bar', - overwrite: true, - }, - { - type: 'globalType', - id: 'bar', - overwrite: true, - }, - ], - }, - objects: [ - { - type: 'globalType', - id: 'bar', - }, - { type: 'visualization', id: 'bar' }, - ], - }; - - const { resolveConflicts } = await setup(); - - const request = httpServerMock.createKibanaRequest({ - body: payload, - method: 'post', - }); - - const response = await resolveConflicts.routeHandler( - mockRouteContext, - request, - kibanaResponseFactory - ); - - const { status } = response; - - expect(status).toEqual(200); - expect(resolveSavedObjectsImportErrors).toHaveBeenCalledTimes(1); - const [ - resolveImportErrorsCallOptions, - ] = (resolveSavedObjectsImportErrors as jest.Mock).mock.calls[0]; - - expect(resolveImportErrorsCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); - }); - it('resolves conflicts for multiple spaces', async () => { const payload = { objects: [{ type: 'visualization', id: 'bar' }], @@ -459,19 +388,13 @@ describe('copy to space', () => { resolveImportErrorsFirstCallOptions, ] = (resolveSavedObjectsImportErrors as jest.Mock).mock.calls[0]; - expect(resolveImportErrorsFirstCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); + expect(resolveImportErrorsFirstCallOptions).toMatchObject({ namespace: 'a-space' }); const [ resolveImportErrorsSecondCallOptions, ] = (resolveSavedObjectsImportErrors as jest.Mock).mock.calls[1]; - expect(resolveImportErrorsSecondCallOptions).toMatchObject({ - namespace: 'b-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); + expect(resolveImportErrorsSecondCallOptions).toMatchObject({ namespace: 'b-space' }); }); }); }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts index 87c2fee4ea9bf..fef1646067fde 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts @@ -30,39 +30,49 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { tags: ['access:copySavedObjectsToSpaces'], }, validate: { - body: schema.object({ - spaces: schema.arrayOf( - schema.string({ - validate: (value) => { - if (!SPACE_ID_REGEX.test(value)) { - return `lower case, a-z, 0-9, "_", and "-" are allowed`; - } - }, - }), - { - validate: (spaceIds) => { - if (_.uniq(spaceIds).length !== spaceIds.length) { - return 'duplicate space ids are not allowed'; - } - }, - } - ), - objects: schema.arrayOf( - schema.object({ - type: schema.string(), - id: schema.string(), - }), - { - validate: (objects) => { - if (!areObjectsUnique(objects)) { - return 'duplicate objects are not allowed'; - } - }, - } - ), - includeReferences: schema.boolean({ defaultValue: false }), - overwrite: schema.boolean({ defaultValue: false }), - }), + body: schema.object( + { + spaces: schema.arrayOf( + schema.string({ + validate: (value) => { + if (!SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed`; + } + }, + }), + { + validate: (spaceIds) => { + if (_.uniq(spaceIds).length !== spaceIds.length) { + return 'duplicate space ids are not allowed'; + } + }, + } + ), + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }), + { + validate: (objects) => { + if (!areObjectsUnique(objects)) { + return 'duplicate objects are not allowed'; + } + }, + } + ), + includeReferences: schema.boolean({ defaultValue: false }), + overwrite: schema.boolean({ defaultValue: false }), + createNewCopies: schema.boolean({ defaultValue: false }), + }, + { + validate: (object) => { + if (object.overwrite && object.createNewCopies) { + return 'cannot use [overwrite] with [createNewCopies]'; + } + }, + } + ), }, }, createLicensedRouteHandler(async (context, request, response) => { @@ -73,12 +83,19 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { getImportExportObjectLimit, request ); - const { spaces: destinationSpaceIds, objects, includeReferences, overwrite } = request.body; + const { + spaces: destinationSpaceIds, + objects, + includeReferences, + overwrite, + createNewCopies, + } = request.body; const sourceSpaceId = spacesService.getSpaceId(request); const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { objects, includeReferences, overwrite, + createNewCopies, }); return response.ok({ body: copyResponse }); }) @@ -105,6 +122,9 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { type: schema.string(), id: schema.string(), overwrite: schema.boolean({ defaultValue: false }), + destinationId: schema.maybe(schema.string()), + createNewCopy: schema.maybe(schema.boolean()), + ignoreMissingReferences: schema.maybe(schema.boolean()), }) ) ), @@ -122,6 +142,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { } ), includeReferences: schema.boolean({ defaultValue: false }), + createNewCopies: schema.boolean({ defaultValue: false }), }), }, }, @@ -133,7 +154,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { getImportExportObjectLimit, request ); - const { objects, includeReferences, retries } = request.body; + const { objects, includeReferences, retries, createNewCopies } = request.body; const sourceSpaceId = spacesService.getSpaceId(request); const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts( sourceSpaceId, @@ -141,6 +162,7 @@ export function initCopyToSpacesApi(deps: ExternalRouteDeps) { objects, includeReferences, retries, + createNewCopies, } ); return response.ok({ body: resolveConflictsResponse }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts index ec841808f771d..a9b701a8ea395 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts @@ -119,6 +119,22 @@ describe('GET /spaces/space', () => { expect(response.payload).toEqual(spaces); }); + it(`returns all available spaces with the 'shareSavedObjectsIntoSpace' purpose`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + query: { + purpose: 'shareSavedObjectsIntoSpace', + }, + method: 'get', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload).toEqual(spaces); + }); + it(`returns http/403 when the license is invalid`, async () => { const { routeHandler } = await setup(); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.ts index cd1e03eb10b0a..088409471fa55 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.ts @@ -19,7 +19,11 @@ export function initGetAllSpacesApi(deps: ExternalRouteDeps) { validate: { query: schema.object({ purpose: schema.oneOf( - [schema.literal('any'), schema.literal('copySavedObjectsIntoSpace')], + [ + schema.literal('any'), + schema.literal('copySavedObjectsIntoSpace'), + schema.literal('shareSavedObjectsIntoSpace'), + ], { defaultValue: 'any', } diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 51c59212bef16..c9c17d091cd55 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -90,7 +90,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const type = Symbol(); const id = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.get(type, id, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -117,7 +117,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const objects = [{ type: 'foo' }]; const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.bulkGet(objects, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -263,6 +263,34 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); + describe('#checkConflicts', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = await createSpacesSavedObjectsClient(); + + await expect( + // @ts-expect-error + client.checkConflicts(null, { namespace: 'bar' }) + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { errors: [] }; + baseClient.checkConflicts.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const objects = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.checkConflicts(objects, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.checkConflicts).toHaveBeenCalledWith(objects, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + describe('#create', () => { test(`throws error if options.namespace is specified`, async () => { const { client } = await createSpacesSavedObjectsClient(); @@ -280,7 +308,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const type = Symbol(); const attributes = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.create(type, attributes, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -307,7 +335,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const objects = [{ type: 'foo' }]; const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.bulkCreate(objects, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -323,7 +351,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.update(null, null, null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -337,7 +365,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const id = Symbol(); const attributes = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.update(type, id, attributes, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -353,7 +381,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.bulkUpdate(null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -387,7 +415,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.delete(null, null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -400,7 +428,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const type = Symbol(); const id = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.delete(type, id, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -416,7 +444,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.addToNamespaces(null, null, null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -430,7 +458,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const id = Symbol(); const namespaces = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.addToNamespaces(type, id, namespaces, options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -446,7 +474,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const { client } = await createSpacesSavedObjectsClient(); await expect( - // @ts-ignore + // @ts-expect-error client.deleteFromNamespaces(null, null, null, { namespace: 'bar' }) ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); @@ -460,7 +488,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; const id = Symbol(); const namespaces = Symbol(); const options = Object.freeze({ foo: 'bar' }); - // @ts-ignore + // @ts-expect-error const actualReturnValue = await client.deleteFromNamespaces(type, id, namespaces, options); expect(actualReturnValue).toBe(expectedReturnValue); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 7e2b302d7cff5..4e830d6149537 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -9,6 +9,7 @@ import { SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, + SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsFindOptions, @@ -59,6 +60,25 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { this.errors = baseClient.errors; } + /** + * Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are + * multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. + * + * @param objects + * @param options + */ + public async checkConflicts( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ) { + throwErrorIfNamespaceSpecified(options); + + return await this.client.checkConflicts(objects, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + /** * Persists an object * diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 92cc35e9e78ca..70e2b34d06ce6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2924,10 +2924,6 @@ "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage": "失敗したオブジェクトを再試行中…", "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage": "保存された検索が正しくリンクされていることを確認してください…", "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage": "矛盾を保存中…", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteBody": "{title}を上書きしてよろしいですか?", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteCancelButtonText": "キャンセル", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteOverwriteButtonText": "上書き", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteTitle": "{type}を上書きしますか?", "savedObjectsManagement.objectsTable.flyout.errorCalloutTitle": "申し訳ございません、エラーが発生しました", "savedObjectsManagement.objectsTable.flyout.import.cancelButtonLabel": "キャンセル", "savedObjectsManagement.objectsTable.flyout.import.confirmButtonLabel": "インポート", @@ -2950,7 +2946,6 @@ "savedObjectsManagement.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage": "保存されたオブジェクトのファイル形式が無効なため、インポートできません。", "savedObjectsManagement.objectsTable.flyout.legacyFileUsedBody": "最新のレポートでNDJSONファイルを作成すれば完了です。", "savedObjectsManagement.objectsTable.flyout.legacyFileUsedTitle": "JSONファイルのサポートが終了します", - "savedObjectsManagement.objectsTable.flyout.overwriteSavedObjectsLabel": "すべての保存されたオブジェクトを自動的に上書きしますか?", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "影響されるオブジェクトの数です", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "カウント", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "インデックスパターンのIDです", @@ -17868,16 +17863,8 @@ "xpack.spaces.management.confirmDeleteModal.deletingSpaceWarningMessage": "スペースを削除すると、スペースと {allContents} が永久に削除されます。この操作は元に戻すことができません。", "xpack.spaces.management.confirmDeleteModal.redirectAfterDeletingCurrentSpaceWarningMessage": "現在のスペース {name} を削除しようとしています。続行すると、別のスペースを選択する画面に移動します。", "xpack.spaces.management.confirmDeleteModal.spaceNamesDoNoMatchErrorMessage": "スペース名が一致していません。", - "xpack.spaces.management.copyToSpace.actionDescription": "この保存されたオブジェクトを1つまたは複数のスペースにコピーします。", - "xpack.spaces.management.copyToSpace.actionTitle": "スペースにコピー", - "xpack.spaces.management.copyToSpace.automaticallyOverwrite": "すべての保存されたオブジェクトを自動的に上書き", - "xpack.spaces.management.copyToSpace.copyDetail.overwriteButton": "上書き", - "xpack.spaces.management.copyToSpace.copyDetail.skipOverwriteButton": "スキップ", "xpack.spaces.management.copyToSpace.copyErrorTitle": "保存されたオブジェクトのコピー中にエラーが発生", - "xpack.spaces.management.copyToSpace.copyResultsLabel": "コピー結果", "xpack.spaces.management.copyToSpace.copyStatus.conflictsMessage": "このスペースには同じID({id})の保存されたオブジェクトが既に存在します。", - "xpack.spaces.management.copyToSpace.copyStatus.conflictsOverwriteMessage": "「上書き」をクリックしてこのバージョンをコピーされたバージョンに置き換えます。", - "xpack.spaces.management.copyToSpace.copyStatus.pendingOverwriteMessage": "保存されたオブジェクトは上書きされます。「スキップ」をクリックしてこの操作をキャンセルします。", "xpack.spaces.management.copyToSpace.copyStatus.successMessage": "保存されたオブジェクトがコピーされました。", "xpack.spaces.management.copyToSpace.copyStatus.unresolvableErrorMessage": "この保存されたオブジェクトのコピー中にエラーが発生しました。", "xpack.spaces.management.copyToSpace.copyStatusSummary.conflictsMessage": "{space}スペースに1つまたは複数の矛盾が検出されました。解決するにはこのセクションを拡張してください。", @@ -17885,26 +17872,17 @@ "xpack.spaces.management.copyToSpace.copyStatusSummary.successMessage": "{space}スペースにコピーされました。", "xpack.spaces.management.copyToSpace.copyToSpacesButton": "{spaceCount} {spaceCount, plural, one {スペース} other {スペース}}にコピー", "xpack.spaces.management.copyToSpace.disabledCopyToSpacesButton": "コピー", - "xpack.spaces.management.copyToSpace.dontIncludeRelatedLabel": "関連性のある保存されたオブジェクトを含みません", - "xpack.spaces.management.copyToSpace.dontOverwriteLabel": "保存されたオブジェクトを上書きしません", "xpack.spaces.management.copyToSpace.finishCopyToSpacesButton": "終了", "xpack.spaces.management.copyToSpace.finishedButtonLabel": "コピーが完了しました。", - "xpack.spaces.management.copyToSpace.finishPendingOverwritesCopyToSpacesButton": "{overwriteCount}件のオブジェクトを上書き", - "xpack.spaces.management.copyToSpace.includeRelatedFormLabel": "関連性のある保存されたオブジェクトを含みます", - "xpack.spaces.management.copyToSpace.includeRelatedLabel": "関連性のある保存されたオブジェクトを含みます", "xpack.spaces.management.copyToSpace.inProgressButtonLabel": "コピーが進行中です。お待ちください。", "xpack.spaces.management.copyToSpace.noSpacesBody": "コピーできるスペースがありません。", "xpack.spaces.management.copyToSpace.noSpacesTitle": "スペースがありません", "xpack.spaces.management.copyToSpace.overwriteLabel": "保存されたオブジェクトを自動的に上書きしています", "xpack.spaces.management.copyToSpace.resolveCopyErrorTitle": "保存されたオブジェクトの矛盾の解決中にエラーが発生", - "xpack.spaces.management.copyToSpace.resolveCopySuccessTitle": "上書き成功", - "xpack.spaces.management.copyToSpace.selectSpacesLabel": "コピー先のスペースを選択してください", "xpack.spaces.management.copyToSpace.spacesLoadErrorTitle": "利用可能なスペースを読み込み中にエラーが発生", - "xpack.spaces.management.copyToSpaceFlyoutFooter.conflictCount": "スキップ", "xpack.spaces.management.copyToSpaceFlyoutFooter.errorCount": "エラー", "xpack.spaces.management.copyToSpaceFlyoutFooter.pendingCount": "保留中", "xpack.spaces.management.copyToSpaceFlyoutFooter.successCount": "コピー完了", - "xpack.spaces.management.copyToSpaceFlyoutHeader": "保存されたオブジェクトのスペースへのコピー", "xpack.spaces.management.createSpaceBreadcrumb": "作成", "xpack.spaces.management.customizeSpaceAvatar.colorFormRowLabel": "色", "xpack.spaces.management.customizeSpaceAvatar.imageUrl": "カスタム画像", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 59d0e63ef2d4a..e682a12859c47 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2925,10 +2925,6 @@ "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.retryingFailedObjectsLoadingMessage": "正在重试失败的对象……", "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savedSearchAreLinkedProperlyLoadingMessage": "确保已保存搜索已正确链接……", "savedObjectsManagement.objectsTable.flyout.confirmLegacyImport.savingConflictsLoadingMessage": "正在保存冲突……", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteBody": "确定要覆盖“{title}”?", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteCancelButtonText": "取消", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteOverwriteButtonText": "覆盖", - "savedObjectsManagement.objectsTable.flyout.confirmOverwriteTitle": "覆盖“{type}”?", "savedObjectsManagement.objectsTable.flyout.errorCalloutTitle": "抱歉,有错误", "savedObjectsManagement.objectsTable.flyout.import.cancelButtonLabel": "取消", "savedObjectsManagement.objectsTable.flyout.import.confirmButtonLabel": "导入", @@ -2951,7 +2947,6 @@ "savedObjectsManagement.objectsTable.flyout.invalidFormatOfImportedFileErrorMessage": "已保存对象文件格式无效,无法导入。", "savedObjectsManagement.objectsTable.flyout.legacyFileUsedBody": "只需使用更新的导出功能生成 NDJSON 文件,便万事俱备。", "savedObjectsManagement.objectsTable.flyout.legacyFileUsedTitle": "将不再支持 JSON 文件", - "savedObjectsManagement.objectsTable.flyout.overwriteSavedObjectsLabel": "自动覆盖所有已保存对象?", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "受影响对象数目", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "计数", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "索引模式的 ID", @@ -17875,16 +17870,8 @@ "xpack.spaces.management.confirmDeleteModal.deletingSpaceWarningMessage": "删除空间会永久删除空间及其 {allContents}。此操作无法撤消。", "xpack.spaces.management.confirmDeleteModal.redirectAfterDeletingCurrentSpaceWarningMessage": "您即将删除当前空间 {name}。如果继续,系统会将您重定向到选择其他空间的位置。", "xpack.spaces.management.confirmDeleteModal.spaceNamesDoNoMatchErrorMessage": "空间名称不匹配。", - "xpack.spaces.management.copyToSpace.actionDescription": "将此已保存对象复制到一个或多个工作区", - "xpack.spaces.management.copyToSpace.actionTitle": "复制到工作区", - "xpack.spaces.management.copyToSpace.automaticallyOverwrite": "自动覆盖所有已保存对象", - "xpack.spaces.management.copyToSpace.copyDetail.overwriteButton": "覆盖", - "xpack.spaces.management.copyToSpace.copyDetail.skipOverwriteButton": "跳过", "xpack.spaces.management.copyToSpace.copyErrorTitle": "复制已保存对象时出错", - "xpack.spaces.management.copyToSpace.copyResultsLabel": "复制结果", "xpack.spaces.management.copyToSpace.copyStatus.conflictsMessage": "具有匹配 ID ({id}) 的已保存对象在此工作区中已存在。", - "xpack.spaces.management.copyToSpace.copyStatus.conflictsOverwriteMessage": "单击“覆盖”可将此版本替换为复制的版本。", - "xpack.spaces.management.copyToSpace.copyStatus.pendingOverwriteMessage": "已保存对象将被覆盖。单击“跳过”可取消此操作。", "xpack.spaces.management.copyToSpace.copyStatus.successMessage": "已保存对象成功复制。", "xpack.spaces.management.copyToSpace.copyStatus.unresolvableErrorMessage": "复制此已保存对象时出错。", "xpack.spaces.management.copyToSpace.copyStatusSummary.conflictsMessage": "在 {space} 工作区中检测到一个或多个冲突。展开此部分以进行解决。", @@ -17892,26 +17879,17 @@ "xpack.spaces.management.copyToSpace.copyStatusSummary.successMessage": "已成功复制到 {space} 工作区。", "xpack.spaces.management.copyToSpace.copyToSpacesButton": "复制到 {spaceCount} {spaceCount, plural, one {个工作区} other {个工作区}}", "xpack.spaces.management.copyToSpace.disabledCopyToSpacesButton": "复制", - "xpack.spaces.management.copyToSpace.dontIncludeRelatedLabel": "不包括相关已保存对象", - "xpack.spaces.management.copyToSpace.dontOverwriteLabel": "未覆盖已保存对象", "xpack.spaces.management.copyToSpace.finishCopyToSpacesButton": "完成", "xpack.spaces.management.copyToSpace.finishedButtonLabel": "复制已完成。", - "xpack.spaces.management.copyToSpace.finishPendingOverwritesCopyToSpacesButton": "覆盖 {overwriteCount} 个对象", - "xpack.spaces.management.copyToSpace.includeRelatedFormLabel": "包括相关已保存对象", - "xpack.spaces.management.copyToSpace.includeRelatedLabel": "包括相关已保存对象", "xpack.spaces.management.copyToSpace.inProgressButtonLabel": "复制正在进行中。请稍候。", "xpack.spaces.management.copyToSpace.noSpacesBody": "没有可向其中进行复制的合格工作区。", "xpack.spaces.management.copyToSpace.noSpacesTitle": "没有可用的工作区", "xpack.spaces.management.copyToSpace.overwriteLabel": "正在自动覆盖已保存对象", "xpack.spaces.management.copyToSpace.resolveCopyErrorTitle": "解决已保存对象冲突时出错", - "xpack.spaces.management.copyToSpace.resolveCopySuccessTitle": "覆盖成功", - "xpack.spaces.management.copyToSpace.selectSpacesLabel": "选择要向其中进行复制的工作区", "xpack.spaces.management.copyToSpace.spacesLoadErrorTitle": "加载可用工作区时出错", - "xpack.spaces.management.copyToSpaceFlyoutFooter.conflictCount": "已跳过", "xpack.spaces.management.copyToSpaceFlyoutFooter.errorCount": "错误", "xpack.spaces.management.copyToSpaceFlyoutFooter.pendingCount": "待处理", "xpack.spaces.management.copyToSpaceFlyoutFooter.successCount": "已复制", - "xpack.spaces.management.copyToSpaceFlyoutHeader": "将已保存对象复制到工作区", "xpack.spaces.management.createSpaceBreadcrumb": "创建", "xpack.spaces.management.customizeSpaceAvatar.colorFormRowLabel": "颜色", "xpack.spaces.management.customizeSpaceAvatar.imageUrl": "定制图像", diff --git a/x-pack/test/functional/apps/spaces/copy_saved_objects.ts b/x-pack/test/functional/apps/spaces/copy_saved_objects.ts index 05d497c235dad..2ee6b903cc3a9 100644 --- a/x-pack/test/functional/apps/spaces/copy_saved_objects.ts +++ b/x-pack/test/functional/apps/spaces/copy_saved_objects.ts @@ -65,10 +65,10 @@ export default function spaceSelectorFunctonalTests({ const summaryCounts = await PageObjects.copySavedObjectsToSpace.getSummaryCounts(); expect(summaryCounts).to.eql({ - copied: 3, + success: 3, + pending: 0, skipped: 0, errors: 0, - overwrite: undefined, }); await PageObjects.copySavedObjectsToSpace.finishCopy(); @@ -93,23 +93,23 @@ export default function spaceSelectorFunctonalTests({ const summaryCounts = await PageObjects.copySavedObjectsToSpace.getSummaryCounts(); expect(summaryCounts).to.eql({ - copied: 2, + success: 0, + pending: 2, skipped: 1, errors: 0, - overwrite: undefined, }); // Mark conflict for overwrite await testSubjects.click(`cts-space-result-${destinationSpaceId}`); - await testSubjects.click(`cts-overwrite-conflict-logstash-*`); + await testSubjects.click(`cts-overwrite-conflict-index-pattern:logstash-*`); // Verify summary changed - const updatedSummaryCounts = await PageObjects.copySavedObjectsToSpace.getSummaryCounts(true); + const updatedSummaryCounts = await PageObjects.copySavedObjectsToSpace.getSummaryCounts(); expect(updatedSummaryCounts).to.eql({ - copied: 2, + success: 0, + pending: 3, skipped: 0, - overwrite: 1, errors: 0, }); diff --git a/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts b/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts index 629a86520389d..6b8680271635b 100644 --- a/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts +++ b/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts @@ -35,7 +35,11 @@ export function CopySavedObjectsToSpacePageProvider({ destinationSpaceId: string; }) { if (!overwrite) { - await testSubjects.click('cts-form-overwrite'); + const radio = await testSubjects.find('cts-copyModeControl-overwriteRadioGroup'); + // a radio button consists of a div tag that contains an input, a div, and a label + // we can't click the input directly, need to go up one level and click the parent div + const div = await radio.findByXpath("//div[input[@id='overwriteDisabled']]"); + await div.click(); } await testSubjects.click(`cts-space-selector-row-${destinationSpaceId}`); }, @@ -49,31 +53,25 @@ export function CopySavedObjectsToSpacePageProvider({ await testSubjects.waitForDeleted('copy-to-space-flyout'); }, - async getSummaryCounts(includeOverwrite: boolean = false) { - const copied = extractCountFromSummary( + async getSummaryCounts() { + const success = extractCountFromSummary( await testSubjects.getVisibleText('cts-summary-success-count') ); + const pending = extractCountFromSummary( + await testSubjects.getVisibleText('cts-summary-pending-count') + ); const skipped = extractCountFromSummary( - await testSubjects.getVisibleText('cts-summary-conflict-count') + await testSubjects.getVisibleText('cts-summary-skipped-count') ); const errors = extractCountFromSummary( await testSubjects.getVisibleText('cts-summary-error-count') ); - let overwrite; - if (includeOverwrite) { - overwrite = extractCountFromSummary( - await testSubjects.getVisibleText('cts-summary-overwrite-count') - ); - } else { - await testSubjects.missingOrFail('cts-summary-overwrite-count', { timeout: 250 }); - } - return { - copied, + success, + pending, skipped, errors, - overwrite, }; }, }; diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index d2c14189e2529..4c0447c29c8f9 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -397,3 +397,91 @@ "type": "doc" } } + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_2a", + "index": ".kibana", + "source": { + "originId": "conflict_2", + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_2b", + "index": ".kibana", + "source": { + "originId": "conflict_2", + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_3", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_4a", + "index": ".kibana", + "source": { + "originId": "conflict_4", + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 7b5b1d86f6bcc..73f0e536b9295 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -182,6 +182,9 @@ "namespaces": { "type": "keyword" }, + "originId": { + "type": "keyword" + }, "search": { "properties": { "columns": { diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts index 0c15ab4bd2f80..45880635586a7 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts +++ b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts @@ -48,6 +48,7 @@ export class Plugin { name: 'sharedtype', hidden: false, namespaceType: 'multiple', + management, mappings, }); core.savedObjects.registerType({ diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts index 5d08421038d3f..595986c08efc1 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -168,7 +168,9 @@ export const expectResponses = { expect(actualNamespace).to.eql(spaceId); } if (isMultiNamespace(type)) { - if (id === CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1.id) { + if (['conflict_1', 'conflict_2a', 'conflict_2b', 'conflict_3', 'conflict_4a'].includes(id)) { + expect(actualNamespaces).to.eql([DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]); + } else if (id === CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1.id) { expect(actualNamespaces).to.eql([DEFAULT_SPACE_ID, SPACE_1_ID]); } else if (id === CASES.MULTI_NAMESPACE_ONLY_SPACE_1.id) { expect(actualNamespaces).to.eql([SPACE_1_ID]); diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index bc356927cc0af..e3163ef77d427 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -6,6 +6,7 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { @@ -23,6 +24,7 @@ export interface BulkCreateTestDefinition extends TestDefinition { export type BulkCreateTestSuite = TestSuite; export interface BulkCreateTestCase extends TestCase { failure?: 400 | 409; // only used for permitted response case + fail409Param?: string; } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake @@ -56,6 +58,15 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: for (let i = 0; i < savedObjects.length; i++) { const object = savedObjects[i]; const testCase = testCaseArray[i]; + if (testCase.failure === 409 && testCase.fail409Param === 'unresolvableConflict') { + const { type, id } = testCase; + const error = SavedObjectsErrorHelpers.createConflictError(type, id); + const payload = { ...error.output.payload, metadata: { isNotOverwritable: true } }; + expect(object.type).to.eql(type); + expect(object.id).to.eql(id); + expect(object.error).to.eql(payload); + continue; + } await expectResponses.permitted(object, testCase); if (!testCase.failure) { expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index ff22cdaeafd06..4a8eff1fb380c 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -8,7 +8,7 @@ import { SuperTest } from 'supertest'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix } from '../lib/saved_object_test_utils'; -import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; +import { ExpectResponseBody, TestDefinition, TestSuite } from '../lib/types'; const { DEFAULT: { spaceId: DEFAULT_SPACE_ID }, @@ -20,15 +20,28 @@ export interface ExportTestDefinition extends TestDefinition { request: ReturnType; } export type ExportTestSuite = TestSuite; +interface SuccessResult { + type: string; + id: string; + originId?: string; +} export interface ExportTestCase { title: string; type: string; id?: string; - successResult?: TestCase | TestCase[]; + successResult?: SuccessResult | SuccessResult[]; failure?: 400 | 403; } -export const getTestCases = (spaceId?: string) => ({ +// additional sharedtype objects that exist but do not have common test cases defined +const CID = 'conflict_'; +const CONFLICT_1_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}1` }); +const CONFLICT_2A_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}2a`, originId: `${CID}2` }); +const CONFLICT_2B_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}2b`, originId: `${CID}2` }); +const CONFLICT_3_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}3` }); +const CONFLICT_4A_OBJ = Object.freeze({ type: 'sharedtype', id: `${CID}4a`, originId: `${CID}4` }); + +export const getTestCases = (spaceId?: string): { [key: string]: ExportTestCase } => ({ singleNamespaceObject: { title: 'single-namespace object', ...(spaceId === SPACE_1_ID @@ -36,7 +49,7 @@ export const getTestCases = (spaceId?: string) => ({ : spaceId === SPACE_2_ID ? CASES.SINGLE_NAMESPACE_SPACE_2 : CASES.SINGLE_NAMESPACE_DEFAULT_SPACE), - } as ExportTestCase, + }, singleNamespaceType: { // this test explicitly ensures that single-namespace objects from other spaces are not returned title: 'single-namespace type', @@ -47,7 +60,7 @@ export const getTestCases = (spaceId?: string) => ({ : spaceId === SPACE_2_ID ? CASES.SINGLE_NAMESPACE_SPACE_2 : CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - } as ExportTestCase, + }, multiNamespaceObject: { title: 'multi-namespace object', ...(spaceId === SPACE_1_ID @@ -55,30 +68,30 @@ export const getTestCases = (spaceId?: string) => ({ : spaceId === SPACE_2_ID ? CASES.MULTI_NAMESPACE_ONLY_SPACE_2 : CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1), - failure: 400, // multi-namespace types cannot be exported yet - } as ExportTestCase, + }, multiNamespaceType: { title: 'multi-namespace type', type: 'sharedtype', - // successResult: - // spaceId === SPACE_1_ID - // ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] - // : spaceId === SPACE_2_ID - // ? CASES.MULTI_NAMESPACE_ONLY_SPACE_2 - // : CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - failure: 400, // multi-namespace types cannot be exported yet - } as ExportTestCase, + successResult: (spaceId === SPACE_1_ID + ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] + : spaceId === SPACE_2_ID + ? [CASES.MULTI_NAMESPACE_ONLY_SPACE_2] + : [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1] + ) + .concat([CONFLICT_1_OBJ, CONFLICT_2A_OBJ, CONFLICT_2B_OBJ, CONFLICT_3_OBJ, CONFLICT_4A_OBJ]) + .flat(), + }, namespaceAgnosticObject: { title: 'namespace-agnostic object', ...CASES.NAMESPACE_AGNOSTIC, - } as ExportTestCase, + }, namespaceAgnosticType: { title: 'namespace-agnostic type', type: 'globaltype', successResult: CASES.NAMESPACE_AGNOSTIC, - } as ExportTestCase, - hiddenObject: { title: 'hidden object', ...CASES.HIDDEN, failure: 400 } as ExportTestCase, - hiddenType: { title: 'hidden type', type: 'hiddentype', failure: 400 } as ExportTestCase, + }, + hiddenObject: { title: 'hidden object', ...CASES.HIDDEN, failure: 400 }, + hiddenType: { title: 'hidden type', type: 'hiddentype', failure: 400 }, }); export const createRequest = ({ type, id }: ExportTestCase) => id ? { objects: [{ type, id }] } : { type }; @@ -98,7 +111,7 @@ export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest async ( response: Record ) => { - const { type, id, successResult = { type, id }, failure } = testCase; + const { type, id, successResult = { type, id } as SuccessResult, failure } = testCase; if (failure === 403) { // In export only, the API uses "bulk_get" or "find" depending on the parameters it receives. // The best that could be done here is to have an if statement to ensure at least one of the @@ -125,11 +138,14 @@ export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest x.id === object.id)!; + expect(expected).not.to.be(undefined); + expect(object.type).to.eql(expected.type); + if (object.originId) { + expect(object.originId).to.eql(expected.originId); + } expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/); // don't test attributes, version, or references } diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index 882451c28bfe4..bab4a4d88534a 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -43,6 +43,36 @@ export interface FindTestCase { }; } +// additional sharedtype objects that exist but do not have common test cases defined +const CONFLICT_1_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'conflict_1', + namespaces: ['default', 'space_1', 'space_2'], +}); +const CONFLICT_2A_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'conflict_2a', + originId: 'conflict_2', + namespaces: ['default', 'space_1', 'space_2'], +}); +const CONFLICT_2B_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'conflict_2b', + originId: 'conflict_2', + namespaces: ['default', 'space_1', 'space_2'], +}); +const CONFLICT_3_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'conflict_3', + namespaces: ['default', 'space_1', 'space_2'], +}); +const CONFLICT_4A_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'conflict_4a', + originId: 'conflict_4', + namespaces: ['default', 'space_1', 'space_2'], +}); + const TEST_CASES = [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespaces: ['default'] }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, namespaces: ['space_1'] }, @@ -110,7 +140,13 @@ export const getTestCases = ( query: `type=sharedtype&fields=title${namespacesQueryParam}`, successResult: { // expected depends on which spaces the user is authorized against... - savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype'), + savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype').concat( + CONFLICT_1_OBJ, + CONFLICT_2A_OBJ, + CONFLICT_2B_OBJ, + CONFLICT_3_OBJ, + CONFLICT_4A_OBJ + ), }, } as FindTestCase, namespaceAgnosticType: { diff --git a/x-pack/test/saved_object_api_integration/common/suites/import.ts b/x-pack/test/saved_object_api_integration/common/suites/import.ts index ed57c6eb16b9a..5036d7b200881 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/import.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/import.ts @@ -8,33 +8,66 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; -import { - createRequest, - expectResponses, - getUrlPrefix, - getTestTitle, -} from '../lib/saved_object_test_utils'; +import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; export interface ImportTestDefinition extends TestDefinition { - request: Array<{ type: string; id: string }>; + request: Array<{ type: string; id: string; originId?: string }>; + overwrite: boolean; + createNewCopies: boolean; } export type ImportTestSuite = TestSuite; export interface ImportTestCase extends TestCase { + originId?: string; + expectedNewId?: string; + successParam?: string; failure?: 400 | 409; // only used for permitted response case + fail409Param?: string; } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; -const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); -const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); -const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); +// these five saved objects already exist in the sample data: +// * id: conflict_1 +// * id: conflict_2a, originId: conflict_2 +// * id: conflict_2b, originId: conflict_2 +// * id: conflict_3 +// * id: conflict_4a, originId: conflict_4 +// using the seven conflict test case objects below, we can exercise various permutations of exact/inexact/ambiguous conflict scenarios +const CID = 'conflict_'; export const TEST_CASES = Object.freeze({ ...CASES, - NEW_SINGLE_NAMESPACE_OBJ, - NEW_MULTI_NAMESPACE_OBJ, - NEW_NAMESPACE_AGNOSTIC_OBJ, + CONFLICT_1_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1` }), + CONFLICT_1A_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1a`, originId: `${CID}1` }), + CONFLICT_1B_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1b`, originId: `${CID}1` }), + CONFLICT_2C_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}2c`, originId: `${CID}2` }), + CONFLICT_2D_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}2d`, originId: `${CID}2` }), + CONFLICT_3A_OBJ: Object.freeze({ + type: 'sharedtype', + id: `${CID}3a`, + originId: `${CID}3`, + expectedNewId: `${CID}3`, + }), + CONFLICT_4_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}4`, expectedNewId: `${CID}4a` }), + NEW_SINGLE_NAMESPACE_OBJ: Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }), + NEW_MULTI_NAMESPACE_OBJ: Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }), + NEW_NAMESPACE_AGNOSTIC_OBJ: Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }), +}); + +/** + * Test cases have additional properties that we don't want to send in HTTP Requests + */ +const createRequest = ({ type, id, originId }: ImportTestCase) => ({ + type, + id, + ...(originId && { originId }), +}); + +const getConflictDest = (id: string) => ({ + id, + title: 'A shared saved-object in all spaces', + updatedAt: '2017-09-21T18:59:16.270Z', }); export function importTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { @@ -42,6 +75,9 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe const expectResponseBody = ( testCases: ImportTestCase | ImportTestCase[], statusCode: 200 | 403, + singleRequest: boolean, + overwrite: boolean, + createNewCopies: boolean, spaceId = SPACES.DEFAULT.spaceId ): ExpectResponseBody => async (response: Record) => { const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; @@ -50,7 +86,7 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe await expectForbidden(types)(response); } else { // permitted - const { success, successCount, errors } = response.body; + const { success, successCount, successResults, errors } = response.body; const expectedSuccesses = testCaseArray.filter((x) => !x.failure); const expectedFailures = testCaseArray.filter((x) => x.failure); expect(success).to.eql(expectedFailures.length === 0); @@ -61,12 +97,53 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe expect(response.body).not.to.have.property('errors'); } for (let i = 0; i < expectedSuccesses.length; i++) { - const { type, id } = expectedSuccesses[i]; - const { _source } = await expectResponses.successCreated(es, spaceId, type, id); - expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + const { type, id, successParam, expectedNewId } = expectedSuccesses[i]; + // we don't know the order of the returned successResults; search for each one + const object = (successResults as Array>).find( + (x) => x.type === type && x.id === id + ); + expect(object).not.to.be(undefined); + const destinationId = object!.destinationId as string; + if (successParam === 'destinationId') { + // Kibana created the object with a different ID than what was specified in the import + // This can happen due to an unresolvable conflict (so the new ID will be random), or due to an inexact match (so the new ID will + // be equal to the ID or originID of the existing object that it inexactly matched) + if (expectedNewId) { + expect(destinationId).to.be(expectedNewId); + } else { + // the new ID was randomly generated + expect(destinationId).to.match(/^[0-9a-f-]{36}$/); + } + } else if (successParam === 'createNewCopies' || successParam === 'createNewCopy') { + // the new ID was randomly generated + expect(destinationId).to.match(/^[0-9a-f-]{36}$/); + } else { + expect(destinationId).to.be(undefined); + } + + // This assertion is only needed for the case where `createNewCopies` mode is disabled and ambiguous source conflicts are detected. + // When `createNewCopies` mode is permanently enabled, this field will be removed, and this assertion will be redundant and can be + // removed too. + const createNewCopy = object!.createNewCopy as boolean | undefined; + if (successParam === 'createNewCopy') { + expect(createNewCopy).to.be(true); + } else { + expect(createNewCopy).to.be(undefined); + } + + if (!singleRequest || overwrite || createNewCopies) { + // even if the object result was a "success" result, it may not have been created if other resolvable errors were returned + const { _source } = await expectResponses.successCreated( + es, + spaceId, + type, + destinationId ?? id + ); + expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + } } for (let i = 0; i < expectedFailures.length; i++) { - const { type, id, failure } = expectedFailures[i]; + const { type, id, failure, fail409Param, expectedNewId } = expectedFailures[i]; // we don't know the order of the returned errors; search for each one const object = (errors as Array>).find( (x) => x.type === type && x.id === id @@ -76,7 +153,24 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe expect(object!.error).to.eql({ type: 'unsupported_type' }); } else { // 409 - expect(object!.error).to.eql({ type: 'conflict' }); + let error: Record = { + type: 'conflict', + ...(expectedNewId && { destinationId: expectedNewId }), + }; + if (fail409Param === 'ambiguous_conflict_1a1b') { + // "ambiguous source" conflict + error = { + type: 'ambiguous_conflict', + destinations: [getConflictDest(`${CID}1`)], + }; + } else if (fail409Param === 'ambiguous_conflict_2c') { + // "ambiguous destination" conflict + error = { + type: 'ambiguous_conflict', + destinations: [getConflictDest(`${CID}2a`), getConflictDest(`${CID}2b`)], + }; + } + expect(object!.error).to.eql(error); } } } @@ -84,7 +178,9 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe const createTestDefinitions = ( testCases: ImportTestCase | ImportTestCase[], forbidden: boolean, - options?: { + options: { + overwrite?: boolean; + createNewCopies?: boolean; spaceId?: string; singleRequest?: boolean; responseBodyOverride?: ExpectResponseBody; @@ -92,7 +188,14 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe ): ImportTestDefinition[] => { const cases = Array.isArray(testCases) ? testCases : [testCases]; const responseStatusCode = forbidden ? 403 : 200; - if (!options?.singleRequest) { + const { + overwrite = false, + createNewCopies = false, + spaceId, + singleRequest, + responseBodyOverride, + } = options; + if (!singleRequest) { // if we are testing cases that should result in a forbidden response, we can do each case individually // this ensures that multiple test cases of a single type will each result in a forbidden error return cases.map((x) => ({ @@ -100,8 +203,10 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe request: [createRequest(x)], responseStatusCode, responseBody: - options?.responseBodyOverride || - expectResponseBody(x, responseStatusCode, options?.spaceId), + responseBodyOverride || + expectResponseBody(x, responseStatusCode, false, overwrite, createNewCopies, spaceId), + overwrite, + createNewCopies, })); } // batch into a single request to save time during test execution @@ -111,8 +216,10 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe request: cases.map((x) => createRequest(x)), responseStatusCode, responseBody: - options?.responseBodyOverride || - expectResponseBody(cases, responseStatusCode, options?.spaceId), + responseBodyOverride || + expectResponseBody(cases, responseStatusCode, true, overwrite, createNewCopies, spaceId), + overwrite, + createNewCopies, }, ]; }; @@ -134,8 +241,13 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe const requestBody = test.request .map((obj) => JSON.stringify({ ...obj, ...attrs })) .join('\n'); + const query = test.overwrite + ? '?overwrite=true' + : test.createNewCopies + ? '?createNewCopies=true' + : ''; await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_import`) + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_import${query}`) .auth(user?.username, user?.password) .attach('file', Buffer.from(requestBody, 'utf8'), 'export.ndjson') .expect(test.responseStatusCode) diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts index 822214cd6dc6a..6d294aed9b4de 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts @@ -8,34 +8,85 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; -import { - createRequest, - expectResponses, - getUrlPrefix, - getTestTitle, -} from '../lib/saved_object_test_utils'; +import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; export interface ResolveImportErrorsTestDefinition extends TestDefinition { - request: Array<{ type: string; id: string }>; + request: { + objects: Array<{ type: string; id: string; originId?: string }>; + retries: Array<{ type: string; id: string; overwrite: boolean; destinationId?: string }>; + }; overwrite: boolean; + createNewCopies: boolean; } export type ResolveImportErrorsTestSuite = TestSuite; export interface ResolveImportErrorsTestCase extends TestCase { + originId?: string; + expectedNewId?: string; + successParam?: string; failure?: 400 | 409; // only used for permitted response case } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; -const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); -const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); -const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); +// these five saved objects already exist in the sample data: +// * id: conflict_1 +// * id: conflict_2a, originId: conflict_2 +// * id: conflict_2b, originId: conflict_2 +// * id: conflict_3 +// * id: conflict_4a, originId: conflict_4 +// using the five conflict test case objects below, we can exercise various permutations of exact/inexact/ambiguous conflict scenarios export const TEST_CASES = Object.freeze({ ...CASES, - NEW_SINGLE_NAMESPACE_OBJ, - NEW_MULTI_NAMESPACE_OBJ, - NEW_NAMESPACE_AGNOSTIC_OBJ, + CONFLICT_1A_OBJ: Object.freeze({ + type: 'sharedtype', + id: `conflict_1a`, + originId: `conflict_1`, + expectedNewId: 'some-random-id', + }), + CONFLICT_1B_OBJ: Object.freeze({ + type: 'sharedtype', + id: `conflict_1b`, + originId: `conflict_1`, + expectedNewId: 'another-random-id', + }), + CONFLICT_2C_OBJ: Object.freeze({ + type: 'sharedtype', + id: `conflict_2c`, + originId: `conflict_2`, + expectedNewId: `conflict_2a`, + }), + CONFLICT_3A_OBJ: Object.freeze({ + type: 'sharedtype', + id: `conflict_3a`, + originId: `conflict_3`, + expectedNewId: `conflict_3`, + }), + CONFLICT_4_OBJ: Object.freeze({ + type: 'sharedtype', + id: `conflict_4`, + expectedNewId: `conflict_4a`, + }), +}); + +/** + * Test cases have additional properties that we don't want to send in HTTP Requests + */ +const createRequest = ( + { type, id, originId, expectedNewId, successParam }: ResolveImportErrorsTestCase, + overwrite: boolean +): ResolveImportErrorsTestDefinition['request'] => ({ + objects: [{ type, id, ...(originId && { originId }) }], + retries: [ + { + type, + id, + overwrite, + ...(expectedNewId && { destinationId: expectedNewId }), + ...(successParam === 'createNewCopy' && { createNewCopy: true }), + }, + ], }); export function resolveImportErrorsTestSuiteFactory( @@ -47,6 +98,9 @@ export function resolveImportErrorsTestSuiteFactory( const expectResponseBody = ( testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[], statusCode: 200 | 403, + singleRequest: boolean, + overwrite: boolean, + createNewCopies: boolean, spaceId = SPACES.DEFAULT.spaceId ): ExpectResponseBody => async (response: Record) => { const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; @@ -55,7 +109,7 @@ export function resolveImportErrorsTestSuiteFactory( await expectForbidden(types)(response); } else { // permitted - const { success, successCount, errors } = response.body; + const { success, successCount, successResults, errors } = response.body; const expectedSuccesses = testCaseArray.filter((x) => !x.failure); const expectedFailures = testCaseArray.filter((x) => x.failure); expect(success).to.eql(expectedFailures.length === 0); @@ -66,12 +120,51 @@ export function resolveImportErrorsTestSuiteFactory( expect(response.body).not.to.have.property('errors'); } for (let i = 0; i < expectedSuccesses.length; i++) { - const { type, id } = expectedSuccesses[i]; - const { _source } = await expectResponses.successCreated(es, spaceId, type, id); - expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + const { type, id, successParam, expectedNewId } = expectedSuccesses[i]; + // we don't know the order of the returned successResults; search for each one + const object = (successResults as Array>).find( + (x) => x.type === type && x.id === id + ); + expect(object).not.to.be(undefined); + const destinationId = object!.destinationId as string; + if (successParam === 'destinationId') { + // Kibana created the object with a different ID than what was specified in the import + // This can happen due to an unresolvable conflict (so the new ID will be random), or due to an inexact match (so the new ID will + // be equal to the ID or originID of the existing object that it inexactly matched) + if (expectedNewId) { + expect(destinationId).to.be(expectedNewId); + } else { + // the new ID was randomly generated + expect(destinationId).to.match(/^[0-9a-f-]{36}$/); + } + } else if (successParam === 'createNewCopies' || successParam === 'createNewCopy') { + expect(destinationId).to.be(expectedNewId!); + } else { + expect(destinationId).to.be(undefined); + } + + // This assertion is only needed for the case where `createNewCopies` mode is disabled and ambiguous source conflicts are detected. + // When `createNewCopies` mode is permanently enabled, this field will be removed, and this assertion will be redundant and can be + // removed too. + const createNewCopy = object!.createNewCopy as boolean | undefined; + if (successParam === 'createNewCopy') { + expect(createNewCopy).to.be(true); + } else { + expect(createNewCopy).to.be(undefined); + } + + if (!singleRequest || overwrite || createNewCopies) { + const { _source } = await expectResponses.successCreated( + es, + spaceId, + type, + destinationId ?? id + ); + expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + } } for (let i = 0; i < expectedFailures.length; i++) { - const { type, id, failure } = expectedFailures[i]; + const { type, id, failure, expectedNewId } = expectedFailures[i]; // we don't know the order of the returned errors; search for each one const object = (errors as Array>).find( (x) => x.type === type && x.id === id @@ -81,7 +174,10 @@ export function resolveImportErrorsTestSuiteFactory( expect(object!.error).to.eql({ type: 'unsupported_type' }); } else { // 409 - expect(object!.error).to.eql({ type: 'conflict' }); + expect(object!.error).to.eql({ + type: 'conflict', + ...(expectedNewId && { destinationId: expectedNewId }), + }); } } } @@ -89,8 +185,9 @@ export function resolveImportErrorsTestSuiteFactory( const createTestDefinitions = ( testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[], forbidden: boolean, - overwrite: boolean, - options?: { + options: { + overwrite?: boolean; + createNewCopies?: boolean; spaceId?: string; singleRequest?: boolean; responseBodyOverride?: ExpectResponseBody; @@ -98,29 +195,43 @@ export function resolveImportErrorsTestSuiteFactory( ): ResolveImportErrorsTestDefinition[] => { const cases = Array.isArray(testCases) ? testCases : [testCases]; const responseStatusCode = forbidden ? 403 : 200; - if (!options?.singleRequest) { + const { + overwrite = false, + createNewCopies = false, + spaceId, + singleRequest, + responseBodyOverride, + } = options; + if (!singleRequest) { // if we are testing cases that should result in a forbidden response, we can do each case individually // this ensures that multiple test cases of a single type will each result in a forbidden error return cases.map((x) => ({ title: getTestTitle(x, responseStatusCode), - request: [createRequest(x)], + request: createRequest(x, overwrite), responseStatusCode, responseBody: - options?.responseBodyOverride || - expectResponseBody(x, responseStatusCode, options?.spaceId), + responseBodyOverride || + expectResponseBody(x, responseStatusCode, false, overwrite, createNewCopies, spaceId), overwrite, + createNewCopies, })); } // batch into a single request to save time during test execution return [ { title: getTestTitle(cases, responseStatusCode), - request: cases.map((x) => createRequest(x)), + request: cases + .map((x) => createRequest(x, overwrite)) + .reduce((acc, cur) => ({ + objects: [...acc.objects, ...cur.objects], + retries: [...acc.retries, ...cur.retries], + })), responseStatusCode, responseBody: - options?.responseBodyOverride || - expectResponseBody(cases, responseStatusCode, options?.spaceId), + responseBodyOverride || + expectResponseBody(cases, responseStatusCode, true, overwrite, createNewCopies, spaceId), overwrite, + createNewCopies, }, ]; }; @@ -139,17 +250,14 @@ export function resolveImportErrorsTestSuiteFactory( for (const test of tests) { it(`should return ${test.responseStatusCode} ${test.title}`, async () => { - const retryAttrs = test.overwrite ? { overwrite: true } : {}; - const retries = JSON.stringify( - test.request.map(({ type, id }) => ({ type, id, ...retryAttrs })) - ); - const requestBody = test.request + const requestBody = test.request.objects .map((obj) => JSON.stringify({ ...obj, ...attrs })) .join('\n'); + const query = test.createNewCopies ? '?createNewCopies=true' : ''; await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_resolve_import_errors`) + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_resolve_import_errors${query}`) .auth(user?.username, user?.password) - .field('retries', retries) + .field('retries', JSON.stringify(test.request.retries)) .attach('file', Buffer.from(requestBody, 'utf8'), 'export.ndjson') .expect(test.responseStatusCode) .then(test.responseBody); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index d83f3449460ce..0cc5969e2b7ab 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -20,6 +20,8 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const unresolvableConflict = (condition?: boolean) => + condition !== false ? { fail409Param: 'unresolvableConflict' } : {}; const createTestCases = (overwrite: boolean, spaceId: string) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect @@ -34,9 +36,18 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite || spaceId !== SPACE_2_ID), + ...unresolvableConflict(spaceId !== SPACE_2_ID), }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, CASES.NEW_SINGLE_NAMESPACE_OBJ, CASES.NEW_MULTI_NAMESPACE_OBJ, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts index f85cd3a36c092..c581a1757565e 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts @@ -18,15 +18,12 @@ const createTestCases = (spaceId: string) => { const exportableTypes = [ cases.singleNamespaceObject, cases.singleNamespaceType, - cases.namespaceAgnosticObject, - cases.namespaceAgnosticType, - ]; - const nonExportableTypes = [ cases.multiNamespaceObject, cases.multiNamespaceType, - cases.hiddenObject, - cases.hiddenType, + cases.namespaceAgnosticObject, + cases.namespaceAgnosticType, ]; + const nonExportableTypes = [cases.hiddenObject, cases.hiddenType]; const allTypes = exportableTypes.concat(nonExportableTypes); return { exportableTypes, nonExportableTypes, allTypes }; }; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts index 6b4dfe1d05f72..0b531a3dccc1a 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -20,27 +20,78 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newCopy = () => ({ successParam: 'createNewCopy' }); +const ambiguousConflict = (suffix: string) => ({ + failure: 409 as 409, + fail409Param: `ambiguous_conflict_${suffix}`, +}); -const createTestCases = (spaceId: string) => { +const createNewCopiesTestCases = () => { + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); + const importable = cases.map(([, val]) => ({ ...val, successParam: 'createNewCopies' })); + const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const all = [...importable, ...nonImportable]; + return { importable, nonImportable, all }; +}; + +const createTestCases = (overwrite: boolean, spaceId: string) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect // to receive an error; otherwise, we expect to receive a success result - const importableTypes = [ - { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(spaceId === DEFAULT_SPACE_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(spaceId === SPACE_2_ID) }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409() }, + const group1Importable = [ + // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, CASES.NEW_SINGLE_NAMESPACE_OBJ, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const nonImportableTypes = [ - { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, - { ...CASES.HIDDEN, ...fail400() }, - { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const group1All = group1Importable.concat(group1NonImportable); + const group2 = [ + // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes + CASES.NEW_MULTI_NAMESPACE_OBJ, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), + ...destinationId(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...destinationId(spaceId !== SPACE_2_ID), + }, + { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + ]; + const group3 = [ + // when overwrite=true, all of the objects in this group are errors, so we cannot check the created object attributes + // grouping errors together simplifies the test suite code + { ...CASES.CONFLICT_2C_OBJ, ...ambiguousConflict('2c') }, // "ambiguous destination" conflict + ]; + const group4 = [ + // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes + { ...CASES.CONFLICT_1_OBJ, ...fail409(!overwrite) }, // "exact match" conflict + CASES.CONFLICT_1A_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match + CASES.CONFLICT_1B_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match + { ...CASES.CONFLICT_2C_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_2D_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID ]; - const allTypes = importableTypes.concat(nonImportableTypes); - return { importableTypes, nonImportableTypes, allTypes }; + return { group1Importable, group1NonImportable, group1All, group2, group3, group4 }; }; export default function ({ getService }: FtrProviderContext) { @@ -53,27 +104,77 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = (spaceId: string) => { - const { importableTypes, nonImportableTypes, allTypes } = createTestCases(spaceId); - // use singleRequest to reduce execution time and/or test combined cases + const createTests = (overwrite: boolean, createNewCopies: boolean, spaceId: string) => { + const singleRequest = true; + + if (createNewCopies) { + const { importable, nonImportable, all } = createNewCopiesTestCases(); + return { + unauthorized: [ + createTestDefinitions(importable, true, { createNewCopies, spaceId }), + createTestDefinitions(nonImportable, false, { createNewCopies, spaceId, singleRequest }), + createTestDefinitions(all, true, { + createNewCopies, + spaceId, + singleRequest, + responseBodyOverride: expectForbidden([ + 'dashboard', + 'globaltype', + 'isolatedtype', + 'sharedtype', + ]), + }), + ].flat(), + authorized: createTestDefinitions(all, false, { createNewCopies, spaceId, singleRequest }), + }; + } + + const { + group1Importable, + group1NonImportable, + group1All, + group2, + group3, + group4, + } = createTestCases(overwrite, spaceId); return { unauthorized: [ - createTestDefinitions(importableTypes, true, { spaceId }), - createTestDefinitions(nonImportableTypes, false, { spaceId, singleRequest: true }), - createTestDefinitions(allTypes, true, { + createTestDefinitions(group1Importable, true, { overwrite, spaceId }), + createTestDefinitions(group1NonImportable, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, spaceId, - singleRequest: true, + singleRequest, responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), }), + createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group3, true, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group4, true, { overwrite, spaceId, singleRequest }), + ].flat(), + authorized: [ + createTestDefinitions(group1All, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group3, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group4, false, { overwrite, spaceId, singleRequest }), ].flat(), - authorized: createTestDefinitions(allTypes, false, { spaceId, singleRequest: true }), }; }; describe('_import', () => { - getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { - const suffix = ` within the ${spaceId} space`; - const { unauthorized, authorized } = createTests(spaceId); + getTestScenarios([ + [false, false], + [false, true], + [true, false], + ]).securityAndSpaces.forEach(({ spaceId, users, modifier }) => { + const [overwrite, createNewCopies] = modifier!; + const suffix = ` within the ${spaceId} space${ + overwrite + ? ' with overwrite enabled' + : createNewCopies + ? ' with createNewCopies enabled' + : '' + }`; + const { unauthorized, authorized } = createTests(overwrite, createNewCopies, spaceId); const _addTests = (user: TestUser, tests: ImportTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts index 8c16e298c7df9..792fe63e5932d 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { v4 as uuidv4 } from 'uuid'; import { SPACES } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; @@ -20,30 +21,65 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newCopy = () => ({ successParam: 'createNewCopy' }); + +const createNewCopiesTestCases = () => { + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); + const importable = cases.map(([, val]) => ({ + ...val, + successParam: 'createNewCopies', + expectedNewId: uuidv4(), + })); + const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const all = [...importable, ...nonImportable]; + return { importable, nonImportable, all }; +}; const createTestCases = (overwrite: boolean, spaceId: string) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect // to receive an error; otherwise, we expect to receive a success result - const importableTypes = [ - { - ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), - }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + const singleNamespaceObject = + spaceId === DEFAULT_SPACE_ID + ? CASES.SINGLE_NAMESPACE_DEFAULT_SPACE + : spaceId === SPACE_1_ID + ? CASES.SINGLE_NAMESPACE_SPACE_1 + : CASES.SINGLE_NAMESPACE_SPACE_2; + const group1Importable = [ + { ...singleNamespaceObject, ...fail409(!overwrite) }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const nonImportableTypes = [ - { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, - { ...CASES.HIDDEN, ...fail400() }, - { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const group1All = [...group1Importable, ...group1NonImportable]; + const group2 = [ + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), + ...destinationId(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...destinationId(spaceId !== SPACE_2_ID), + }, + { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict + // if we call _resolve_import_errors and don't specify overwrite, each of these will result in a conflict because an object with that + // `expectedDestinationId` already exists + { ...CASES.CONFLICT_2C_OBJ, ...fail409(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' + { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' + { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' ]; - const allTypes = importableTypes.concat(nonImportableTypes); - return { importableTypes, nonImportableTypes, allTypes }; + return { group1Importable, group1NonImportable, group1All, group2 }; }; export default function ({ getService }: FtrProviderContext) { @@ -56,47 +92,82 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = (overwrite: boolean, spaceId: string) => { - const { importableTypes, nonImportableTypes, allTypes } = createTestCases(overwrite, spaceId); - const singleRequest = true; + const createTests = (overwrite: boolean, createNewCopies: boolean, spaceId: string) => { // use singleRequest to reduce execution time and/or test combined cases + const singleRequest = true; + + if (createNewCopies) { + const { importable, nonImportable, all } = createNewCopiesTestCases(); + return { + unauthorized: [ + createTestDefinitions(importable, true, { createNewCopies, spaceId }), + createTestDefinitions(nonImportable, false, { createNewCopies, spaceId, singleRequest }), + createTestDefinitions(all, true, { + createNewCopies, + spaceId, + singleRequest, + responseBodyOverride: expectForbidden(['globaltype', 'isolatedtype', 'sharedtype']), + }), + ].flat(), + authorized: createTestDefinitions(all, false, { createNewCopies, spaceId, singleRequest }), + }; + } + + const { group1Importable, group1NonImportable, group1All, group2 } = createTestCases( + overwrite, + spaceId + ); return { unauthorized: [ - createTestDefinitions(importableTypes, true, overwrite, { spaceId }), - createTestDefinitions(nonImportableTypes, false, overwrite, { spaceId, singleRequest }), - createTestDefinitions(allTypes, true, overwrite, { + createTestDefinitions(group1Importable, true, { overwrite, spaceId }), + createTestDefinitions(group1NonImportable, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, spaceId, singleRequest, - responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + responseBodyOverride: expectForbidden(['globaltype', 'isolatedtype']), }), + createTestDefinitions(group2, true, { overwrite, spaceId, singleRequest }), + ].flat(), + authorized: [ + createTestDefinitions(group1All, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), ].flat(), - authorized: createTestDefinitions(allTypes, false, overwrite, { spaceId, singleRequest }), }; }; describe('_resolve_import_errors', () => { - getTestScenarios([false, true]).securityAndSpaces.forEach( - ({ spaceId, users, modifier: overwrite }) => { - const suffix = ` within the ${spaceId} space${overwrite ? ' with overwrite enabled' : ''}`; - const { unauthorized, authorized } = createTests(overwrite!, spaceId); - const _addTests = (user: TestUser, tests: ResolveImportErrorsTestDefinition[]) => { - addTests(`${user.description}${suffix}`, { user, spaceId, tests }); - }; + getTestScenarios([ + [false, false], + [false, true], + [true, false], + ]).securityAndSpaces.forEach(({ spaceId, users, modifier }) => { + const [overwrite, createNewCopies] = modifier!; + const suffix = ` within the ${spaceId} space${ + overwrite + ? ' with overwrite enabled' + : createNewCopies + ? ' with createNewCopies enabled' + : '' + }`; + const { unauthorized, authorized } = createTests(overwrite, createNewCopies, spaceId); + const _addTests = (user: TestUser, tests: ResolveImportErrorsTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - [ - users.noAccess, - users.legacyAll, - users.dualRead, - users.readGlobally, - users.readAtSpace, - users.allAtOtherSpace, - ].forEach((user) => { - _addTests(user, unauthorized); - }); - [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach((user) => { - _addTests(user, authorized); - }); - } - ); + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach((user) => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach((user) => { + _addTests(user, authorized); + }); + }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index 464a5a1e76016..725120687c231 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -14,6 +14,7 @@ import { } from '../../common/suites/bulk_create'; const { fail400, fail409 } = testCaseFailures; +const unresolvableConflict = () => ({ fail409Param: 'unresolvableConflict' }); const createTestCases = (overwrite: boolean) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect @@ -23,8 +24,8 @@ const createTestCases = (overwrite: boolean) => { CASES.SINGLE_NAMESPACE_SPACE_1, CASES.SINGLE_NAMESPACE_SPACE_2, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(), ...unresolvableConflict() }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, CASES.NEW_SINGLE_NAMESPACE_OBJ, CASES.NEW_MULTI_NAMESPACE_OBJ, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts index 61ff6eeb4bd80..99babf683ccfa 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts @@ -18,15 +18,12 @@ const createTestCases = () => { const exportableTypes = [ cases.singleNamespaceObject, cases.singleNamespaceType, - cases.namespaceAgnosticObject, - cases.namespaceAgnosticType, - ]; - const nonExportableTypes = [ cases.multiNamespaceObject, cases.multiNamespaceType, - cases.hiddenObject, - cases.hiddenType, + cases.namespaceAgnosticObject, + cases.namespaceAgnosticType, ]; + const nonExportableTypes = [cases.hiddenObject, cases.hiddenType]; const allTypes = exportableTypes.concat(nonExportableTypes); return { exportableTypes, nonExportableTypes, allTypes }; }; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts index beec276b3bd73..34be3b7408432 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts @@ -14,27 +14,63 @@ import { } from '../../common/suites/import'; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newCopy = () => ({ successParam: 'createNewCopy' }); +const ambiguousConflict = (suffix: string) => ({ + failure: 409 as 409, + fail409Param: `ambiguous_conflict_${suffix}`, +}); -const createTestCases = () => { +const createNewCopiesTestCases = () => { + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); + const importable = cases.map(([, val]) => ({ ...val, successParam: 'createNewCopies' })); + const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const all = [...importable, ...nonImportable]; + return { importable, nonImportable, all }; +}; + +const createTestCases = (overwrite: boolean) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect // to receive an error; otherwise, we expect to receive a success result - const importableTypes = [ - { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409() }, + const group1Importable = [ + // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, CASES.SINGLE_NAMESPACE_SPACE_1, CASES.SINGLE_NAMESPACE_SPACE_2, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, CASES.NEW_SINGLE_NAMESPACE_OBJ, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const nonImportableTypes = [ - { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, - { ...CASES.HIDDEN, ...fail400() }, - { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const group1All = group1Importable.concat(group1NonImportable); + const group2 = [ + // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes + CASES.NEW_MULTI_NAMESPACE_OBJ, + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...destinationId() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...destinationId() }, + { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + ]; + const group3 = [ + // when overwrite=true, all of the objects in this group are errors, so we cannot check the created object attributes + // grouping errors together simplifies the test suite code + { ...CASES.CONFLICT_2C_OBJ, ...ambiguousConflict('2c') }, // "ambiguous destination" conflict + ]; + const group4 = [ + // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes + { ...CASES.CONFLICT_1_OBJ, ...fail409(!overwrite) }, // "exact match" conflict + CASES.CONFLICT_1A_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match + CASES.CONFLICT_1B_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match + { ...CASES.CONFLICT_2C_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_2D_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID ]; - const allTypes = importableTypes.concat(nonImportableTypes); - return { importableTypes, nonImportableTypes, allTypes }; + return { group1Importable, group1NonImportable, group1All, group2, group3, group4 }; }; export default function ({ getService }: FtrProviderContext) { @@ -47,27 +83,76 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = () => { - const { importableTypes, nonImportableTypes, allTypes } = createTestCases(); + const createTests = (overwrite: boolean, createNewCopies: boolean) => { // use singleRequest to reduce execution time and/or test combined cases + const singleRequest = true; + + if (createNewCopies) { + const { importable, nonImportable, all } = createNewCopiesTestCases(); + return { + unauthorized: [ + createTestDefinitions(importable, true, { createNewCopies }), + createTestDefinitions(nonImportable, false, { createNewCopies, singleRequest }), + createTestDefinitions(all, true, { + createNewCopies, + singleRequest, + responseBodyOverride: expectForbidden([ + 'dashboard', + 'globaltype', + 'isolatedtype', + 'sharedtype', + ]), + }), + ].flat(), + authorized: createTestDefinitions(all, false, { createNewCopies, singleRequest }), + }; + } + + const { + group1Importable, + group1NonImportable, + group1All, + group2, + group3, + group4, + } = createTestCases(overwrite); return { unauthorized: [ - createTestDefinitions(importableTypes, true), - createTestDefinitions(nonImportableTypes, false, { singleRequest: true }), - createTestDefinitions(allTypes, true, { - singleRequest: true, + createTestDefinitions(group1Importable, true, { overwrite }), + createTestDefinitions(group1NonImportable, false, { overwrite, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, + singleRequest, responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), }), + createTestDefinitions(group2, true, { overwrite, singleRequest }), + createTestDefinitions(group3, true, { overwrite, singleRequest }), + createTestDefinitions(group4, true, { overwrite, singleRequest }), + ].flat(), + authorized: [ + createTestDefinitions(group1All, false, { overwrite, singleRequest }), + createTestDefinitions(group2, false, { overwrite, singleRequest }), + createTestDefinitions(group3, false, { overwrite, singleRequest }), + createTestDefinitions(group4, false, { overwrite, singleRequest }), ].flat(), - authorized: createTestDefinitions(allTypes, false, { singleRequest: true }), }; }; describe('_import', () => { - getTestScenarios().security.forEach(({ users }) => { - const { unauthorized, authorized } = createTests(); + getTestScenarios([ + [false, false], + [false, true], + [true, false], + ]).security.forEach(({ users, modifier }) => { + const [overwrite, createNewCopies] = modifier!; + const suffix = overwrite + ? ' with overwrite enabled' + : createNewCopies + ? ' with createNewCopies enabled' + : ''; + const { unauthorized, authorized } = createTests(overwrite, createNewCopies); const _addTests = (user: TestUser, tests: ImportTestDefinition[]) => { - addTests(user.description, { user, tests }); + addTests(`${user.description}${suffix}`, { user, tests }); }; [ diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts index a0abe4b0483f8..91134dd14bd8a 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { v4 as uuidv4 } from 'uuid'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -14,27 +15,45 @@ import { } from '../../common/suites/resolve_import_errors'; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newCopy = () => ({ successParam: 'createNewCopy' }); + +const createNewCopiesTestCases = () => { + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); + const importable = cases.map(([, val]) => ({ + ...val, + successParam: 'createNewCopies', + expectedNewId: uuidv4(), + })); + const nonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const all = [...importable, ...nonImportable]; + return { importable, nonImportable, all }; +}; const createTestCases = (overwrite: boolean) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect // to receive an error; otherwise, we expect to receive a success result - const importableTypes = [ + const group1Importable = [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, - CASES.SINGLE_NAMESPACE_SPACE_1, - CASES.SINGLE_NAMESPACE_SPACE_2, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const nonImportableTypes = [ - { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, - { ...CASES.HIDDEN, ...fail400() }, - { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; + const group1All = [...group1Importable, ...group1NonImportable]; + const group2 = [ + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, + { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict + // if we call _resolve_import_errors and don't specify overwrite, each of these will result in a conflict because an object with that + // `expectedDestinationId` already exists + { ...CASES.CONFLICT_2C_OBJ, ...fail409(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' + { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' + { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' ]; - const allTypes = importableTypes.concat(nonImportableTypes); - return { importableTypes, nonImportableTypes, allTypes }; + return { group1Importable, group1NonImportable, group1All, group2 }; }; export default function ({ getService }: FtrProviderContext) { @@ -47,26 +66,58 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = (overwrite: boolean) => { - const { importableTypes, nonImportableTypes, allTypes } = createTestCases(overwrite); + const createTests = (overwrite: boolean, createNewCopies: boolean) => { // use singleRequest to reduce execution time and/or test combined cases + const singleRequest = true; + + if (createNewCopies) { + const { importable, nonImportable, all } = createNewCopiesTestCases(); + return { + unauthorized: [ + createTestDefinitions(importable, true, { createNewCopies }), + createTestDefinitions(nonImportable, false, { createNewCopies, singleRequest }), + createTestDefinitions(all, true, { + createNewCopies, + singleRequest, + responseBodyOverride: expectForbidden(['globaltype', 'isolatedtype', 'sharedtype']), + }), + ].flat(), + authorized: createTestDefinitions(all, false, { createNewCopies, singleRequest }), + }; + } + + const { group1Importable, group1NonImportable, group1All, group2 } = createTestCases(overwrite); return { unauthorized: [ - createTestDefinitions(importableTypes, true, overwrite), - createTestDefinitions(nonImportableTypes, false, overwrite, { singleRequest: true }), - createTestDefinitions(allTypes, true, overwrite, { - singleRequest: true, - responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + createTestDefinitions(group1Importable, true, { overwrite }), + createTestDefinitions(group1NonImportable, false, { overwrite, singleRequest }), + createTestDefinitions(group1All, true, { + overwrite, + singleRequest, + responseBodyOverride: expectForbidden(['globaltype', 'isolatedtype']), }), + createTestDefinitions(group2, true, { overwrite, singleRequest }), + ].flat(), + authorized: [ + createTestDefinitions(group1All, false, { overwrite, singleRequest }), + createTestDefinitions(group2, false, { overwrite, singleRequest }), ].flat(), - authorized: createTestDefinitions(allTypes, false, overwrite, { singleRequest: true }), }; }; describe('_resolve_import_errors', () => { - getTestScenarios([false, true]).security.forEach(({ users, modifier: overwrite }) => { - const suffix = overwrite ? ' with overwrite enabled' : ''; - const { unauthorized, authorized } = createTests(overwrite!); + getTestScenarios([ + [false, false], + [false, true], + [true, false], + ]).security.forEach(({ users, modifier }) => { + const [overwrite, createNewCopies] = modifier!; + const suffix = overwrite + ? ' with overwrite enabled' + : createNewCopies + ? ' with createNewCopies enabled' + : ''; + const { unauthorized, authorized } = createTests(overwrite, createNewCopies); const _addTests = (user: TestUser, tests: ResolveImportErrorsTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, tests }); }; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index f9edc56b8ffea..74fade39bf7a5 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -16,6 +16,8 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const unresolvableConflict = (condition?: boolean) => + condition !== false ? { fail409Param: 'unresolvableConflict' } : {}; const createTestCases = (overwrite: boolean, spaceId: string) => [ // for each outcome, if failure !== undefined then we expect to receive @@ -29,9 +31,18 @@ const createTestCases = (overwrite: boolean, spaceId: string) => [ { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite || spaceId !== SPACE_2_ID), + ...unresolvableConflict(spaceId !== SPACE_2_ID), }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, CASES.NEW_SINGLE_NAMESPACE_OBJ, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts index 45a76a2f39e37..a36249528540b 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -15,22 +15,75 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newCopy = () => ({ successParam: 'createNewCopy' }); +const ambiguousConflict = (suffix: string) => ({ + failure: 409 as 409, + fail409Param: `ambiguous_conflict_${suffix}`, +}); -const createTestCases = (spaceId: string) => [ +const createNewCopiesTestCases = () => { // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(spaceId === DEFAULT_SPACE_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(spaceId === SPACE_2_ID) }, - { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409() }, - { ...CASES.HIDDEN, ...fail400() }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, - CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, -]; + const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); + return [ + ...cases.map(([, val]) => ({ ...val, successParam: 'createNewCopies' })), + { ...CASES.HIDDEN, ...fail400() }, + ]; +}; + +const createTestCases = (overwrite: boolean, spaceId: string) => { + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + const group1 = [ + // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), + ...destinationId(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...destinationId(spaceId !== SPACE_2_ID), + }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const group2 = [ + // when overwrite=true, all of the objects in this group are errors, so we cannot check the created object attributes + // grouping errors together simplifies the test suite code + { ...CASES.CONFLICT_2C_OBJ, ...ambiguousConflict('2c') }, // "ambiguous destination" conflict + ]; + const group3 = [ + // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes + { ...CASES.CONFLICT_1_OBJ, ...fail409(!overwrite) }, // "exact match" conflict + CASES.CONFLICT_1A_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match + CASES.CONFLICT_1B_OBJ, // no conflict because CONFLICT_1_OBJ is an exact match + { ...CASES.CONFLICT_2C_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_2D_OBJ, ...newCopy() }, // "ambiguous source and destination" conflict which results in a new destination ID and empty origin ID + ]; + return { group1, group2, group3 }; +}; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -38,15 +91,35 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('legacyEs'); const { addTests, createTestDefinitions } = importTestSuiteFactory(es, esArchiver, supertest); - const createTests = (spaceId: string) => { - const testCases = createTestCases(spaceId); - return createTestDefinitions(testCases, false, { spaceId, singleRequest: true }); + const createTests = (overwrite: boolean, createNewCopies: boolean, spaceId: string) => { + const singleRequest = true; + if (createNewCopies) { + const cases = createNewCopiesTestCases(); + return createTestDefinitions(cases, false, { createNewCopies, spaceId, singleRequest }); + } + + const { group1, group2, group3 } = createTestCases(overwrite, spaceId); + return [ + createTestDefinitions(group1, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group2, false, { overwrite, spaceId, singleRequest }), + createTestDefinitions(group3, false, { overwrite, spaceId, singleRequest }), + ].flat(); }; describe('_import', () => { - getTestScenarios().spaces.forEach(({ spaceId }) => { - const tests = createTests(spaceId); - addTests(`within the ${spaceId} space`, { spaceId, tests }); + getTestScenarios([ + [false, false], + [false, true], + [true, false], + ]).spaces.forEach(({ spaceId, modifier }) => { + const [overwrite, createNewCopies] = modifier!; + const suffix = overwrite + ? ' with overwrite enabled' + : createNewCopies + ? ' with createNewCopies enabled' + : ''; + const tests = createTests(overwrite, createNewCopies, spaceId); + addTests(`within the ${spaceId} space${suffix}`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts index a6ef902e2e9eb..1431a61b1cbe0 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { v4 as uuidv4 } from 'uuid'; import { SPACES } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -18,25 +19,62 @@ const { SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; const { fail400, fail409 } = testCaseFailures; +const destinationId = (condition?: boolean) => + condition !== false ? { successParam: 'destinationId' } : {}; +const newCopy = () => ({ successParam: 'createNewCopy' }); -const createTestCases = (overwrite: boolean, spaceId: string) => [ +const createNewCopiesTestCases = () => { // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - { - ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), - }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, - { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - { ...CASES.HIDDEN, ...fail400() }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, - CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, -]; + const cases = Object.entries(CASES).filter(([key]) => key !== 'HIDDEN'); + return [ + ...cases.map(([, val]) => ({ + ...val, + successParam: 'createNewCopies', + expectedNewId: uuidv4(), + })), + { ...CASES.HIDDEN, ...fail400() }, + ]; +}; + +const createTestCases = (overwrite: boolean, spaceId: string) => { + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + const singleNamespaceObject = + spaceId === DEFAULT_SPACE_ID + ? CASES.SINGLE_NAMESPACE_DEFAULT_SPACE + : spaceId === SPACE_1_ID + ? CASES.SINGLE_NAMESPACE_SPACE_1 + : CASES.SINGLE_NAMESPACE_SPACE_2; + return [ + { ...singleNamespaceObject, ...fail409(!overwrite) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), + ...destinationId(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite && spaceId === SPACE_2_ID), + ...destinationId(spaceId !== SPACE_2_ID), + }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID + // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict + // if we call _resolve_import_errors and don't specify overwrite, each of these will result in a conflict because an object with that + // `expectedDestinationId` already exists + { ...CASES.CONFLICT_2C_OBJ, ...fail409(!overwrite), ...destinationId() }, // "ambiguous destination" conflict; if overwrite=true, will overwrite 'conflict_2a' + { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_3' + { ...CASES.CONFLICT_4_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict; if overwrite=true, will overwrite 'conflict_4a' + ]; +}; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -48,15 +86,32 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = (overwrite: boolean, spaceId: string) => { + const createTests = (overwrite: boolean, createNewCopies: boolean, spaceId: string) => { + const singleRequest = true; + if (createNewCopies) { + const cases = createNewCopiesTestCases(); + // The resolveImportErrors API doesn't actually have a flag for "createNewCopies" mode; rather, we create test cases as if we are resolving + // errors from a call to the import API that had createNewCopies mode enabled. + return createTestDefinitions(cases, false, { createNewCopies, spaceId, singleRequest }); + } + const testCases = createTestCases(overwrite, spaceId); - return createTestDefinitions(testCases, false, overwrite, { spaceId, singleRequest: true }); + return createTestDefinitions(testCases, false, { overwrite, spaceId, singleRequest }); }; describe('_resolve_import_errors', () => { - getTestScenarios([false, true]).spaces.forEach(({ spaceId, modifier: overwrite }) => { - const suffix = overwrite ? ' with overwrite enabled' : ''; - const tests = createTests(overwrite!, spaceId); + getTestScenarios([ + [false, false], + [false, true], + [true, false], + ]).spaces.forEach(({ spaceId, modifier }) => { + const [overwrite, createNewCopies] = modifier!; + const suffix = overwrite + ? ' with overwrite enabled' + : createNewCopies + ? ' with createNewCopies enabled' + : ''; + const tests = createTests(overwrite, createNewCopies, spaceId); addTests(`within the ${spaceId} space${suffix}`, { spaceId, tests }); }); }); diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 9a8a0a1fdda14..7e528c23c20a0 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -380,11 +380,11 @@ { "type": "doc", "value": { - "id": "sharedtype:default_space_only", + "id": "sharedtype:default_only", "index": ".kibana", "source": { "sharedtype": { - "title": "A shared saved-object in the default space" + "title": "A shared saved-object in one space" }, "type": "sharedtype", "namespaces": ["default"], @@ -401,7 +401,7 @@ "index": ".kibana", "source": { "sharedtype": { - "title": "A shared saved-object in the space_1 space" + "title": "A shared saved-object in one space" }, "type": "sharedtype", "namespaces": ["space_1"], @@ -418,7 +418,7 @@ "index": ".kibana", "source": { "sharedtype": { - "title": "A shared saved-object in the space_2 space" + "title": "A shared saved-object in one space" }, "type": "sharedtype", "namespaces": ["space_2"], @@ -496,3 +496,128 @@ } } +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1_default", + "index": ".kibana", + "source": { + "originId": "conflict_1", + "sharedtype": { + "title": "A shared saved-object in one space" + }, + "type": "sharedtype", + "namespaces": ["default"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1_space_1", + "index": ".kibana", + "source": { + "originId": "conflict_1", + "sharedtype": { + "title": "A shared saved-object in one space" + }, + "type": "sharedtype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_1_space_2", + "index": ".kibana", + "source": { + "originId": "conflict_1", + "sharedtype": { + "title": "A shared saved-object in one space" + }, + "type": "sharedtype", + "namespaces": ["space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_2_default", + "index": ".kibana", + "source": { + "originId": "conflict_2", + "sharedtype": { + "title": "A shared saved-object in one space" + }, + "type": "sharedtype", + "namespaces": ["default"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_2_space_1", + "index": ".kibana", + "source": { + "originId": "conflict_2", + "sharedtype": { + "title": "A shared saved-object in one space" + }, + "type": "sharedtype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_2_space_2", + "index": ".kibana", + "source": { + "originId": "conflict_2", + "sharedtype": { + "title": "A shared saved-object in one space" + }, + "type": "sharedtype", + "namespaces": ["space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:conflict_2_all", + "index": ".kibana", + "source": { + "originId": "conflict_2", + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 508de68c32f70..a2f8088ce0436 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -162,6 +162,9 @@ "namespaces": { "type": "keyword" }, + "originId": { + "type": "keyword" + }, "search": { "properties": { "columns": { diff --git a/x-pack/test/spaces_api_integration/common/fixtures/spaces_test_plugin/server/plugin.ts b/x-pack/test/spaces_api_integration/common/fixtures/spaces_test_plugin/server/plugin.ts index ee03fa6b648af..0e63e1bc19954 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/spaces_test_plugin/server/plugin.ts +++ b/x-pack/test/spaces_api_integration/common/fixtures/spaces_test_plugin/server/plugin.ts @@ -15,6 +15,13 @@ export class Plugin { name: 'sharedtype', hidden: false, namespaceType: 'multiple', + management: { + icon: 'beaker', + importableAndExportable: true, + getTitle(obj) { + return obj.attributes.title; + }, + }, mappings: { properties: { title: { type: 'text' }, diff --git a/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts index 67f5d737ba010..3b0f5f8570aa3 100644 --- a/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts +++ b/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts @@ -5,8 +5,8 @@ */ export const MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES = Object.freeze({ - DEFAULT_SPACE_ONLY: Object.freeze({ - id: 'default_space_only', + DEFAULT_ONLY: Object.freeze({ + id: 'default_only', existingNamespaces: ['default'], }), SPACE_1_ONLY: Object.freeze({ diff --git a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts index 2dd4484ffcde8..26c736034501f 100644 --- a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -19,6 +19,11 @@ interface CopyToSpaceTest { response: (resp: TestResponse) => Promise; } +interface CopyToSpaceMultiNamespaceTest extends CopyToSpaceTest { + testTitle: string; + objects: Array>; +} + interface CopyToSpaceTests { noConflictsWithoutReferences: CopyToSpaceTest; noConflictsWithReferences: CopyToSpaceTest; @@ -30,6 +35,7 @@ interface CopyToSpaceTests { withConflictsResponse: (resp: TestResponse) => Promise; noConflictsResponse: (resp: TestResponse) => Promise; }; + multiNamespaceTestCases: (overwrite: boolean) => CopyToSpaceMultiNamespaceTest[]; } interface CopyToSpaceTestDefinition { @@ -53,28 +59,14 @@ interface SpaceBucket { } const INITIAL_COUNTS: Record> = { - [DEFAULT_SPACE_ID]: { - dashboard: 2, - visualization: 3, - 'index-pattern': 1, - }, - space_1: { - dashboard: 2, - visualization: 3, - 'index-pattern': 1, - }, - space_2: { - dashboard: 1, - }, + [DEFAULT_SPACE_ID]: { dashboard: 2, visualization: 3, 'index-pattern': 1 }, + space_1: { dashboard: 2, visualization: 3, 'index-pattern': 1 }, + space_2: { dashboard: 1 }, }; const getDestinationWithoutConflicts = () => 'space_2'; -const getDestinationWithConflicts = (originSpaceId?: string) => { - if (!originSpaceId || originSpaceId === DEFAULT_SPACE_ID) { - return 'space_1'; - } - return DEFAULT_SPACE_ID; -}; +const getDestinationWithConflicts = (originSpaceId?: string) => + !originSpaceId || originSpaceId === DEFAULT_SPACE_ID ? 'space_1' : DEFAULT_SPACE_ID; export function copyToSpaceTestSuiteFactory( es: any, @@ -86,27 +78,11 @@ export function copyToSpaceTestSuiteFactory( index: '.kibana', body: { size: 0, - query: { - terms: { - type: ['visualization', 'dashboard', 'index-pattern'], - }, - }, + query: { terms: { type: ['visualization', 'dashboard', 'index-pattern'] } }, aggs: { count: { - terms: { - field: 'namespace', - missing: DEFAULT_SPACE_ID, - size: 10, - }, - aggs: { - countByType: { - terms: { - field: 'type', - missing: 'UNKNOWN', - size: 10, - }, - }, - }, + terms: { field: 'namespace', missing: DEFAULT_SPACE_ID, size: 10 }, + aggs: { countByType: { terms: { field: 'type', missing: 'UNKNOWN', size: 10 } } }, }, }, }, @@ -135,13 +111,7 @@ export function copyToSpaceTestSuiteFactory( const { countByType } = spaceBucket; const expectedBuckets = Object.entries(expectedCounts).reduce((acc, entry) => { const [type, count] = entry; - return [ - ...acc, - { - key: type, - doc_count: count, - }, - ]; + return [...acc, { key: type, doc_count: count }]; }, [] as CountByTypeBucket[]); expectedBuckets.sort(bucketSorter); @@ -154,14 +124,6 @@ export function copyToSpaceTestSuiteFactory( }); }; - const expectRbacForbiddenResponse = async (resp: TestResponse) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: 'Unable to bulk_get dashboard', - }); - }; - const expectNotFoundResponse = async (resp: TestResponse) => { expect(resp.body).to.eql({ statusCode: 404, @@ -172,39 +134,81 @@ export function copyToSpaceTestSuiteFactory( const createExpectNoConflictsWithoutReferencesForSpace = ( spaceId: string, + destination: string, expectedDashboardCount: number ) => async (resp: TestResponse) => { const result = resp.body as CopyResponse; expect(result).to.eql({ - [spaceId]: { + [destination]: { success: true, successCount: 1, + successResults: [ + { + id: 'cts_dashboard', + type: 'dashboard', + meta: { + title: `This is the ${spaceId} test space CTS dashboard`, + icon: 'dashboardApp', + }, + }, + ], }, } as CopyResponse); // Query ES to ensure that we copied everything we expected - await assertSpaceCounts(spaceId, { + await assertSpaceCounts(destination, { dashboard: expectedDashboardCount, }); }; - const expectNoConflictsWithoutReferencesResult = createExpectNoConflictsWithoutReferencesForSpace( - getDestinationWithoutConflicts(), - 2 - ); + const expectNoConflictsWithoutReferencesResult = (spaceId: string = DEFAULT_SPACE_ID) => + createExpectNoConflictsWithoutReferencesForSpace(spaceId, getDestinationWithoutConflicts(), 2); - const expectNoConflictsForNonExistentSpaceResult = createExpectNoConflictsWithoutReferencesForSpace( - 'non_existent_space', - 1 - ); + const expectNoConflictsForNonExistentSpaceResult = (spaceId: string = DEFAULT_SPACE_ID) => + createExpectNoConflictsWithoutReferencesForSpace(spaceId, 'non_existent_space', 1); - const expectNoConflictsWithReferencesResult = async (resp: TestResponse) => { + const expectNoConflictsWithReferencesResult = (spaceId: string = DEFAULT_SPACE_ID) => async ( + resp: TestResponse + ) => { const destination = getDestinationWithoutConflicts(); const result = resp.body as CopyResponse; expect(result).to.eql({ [destination]: { success: true, successCount: 5, + successResults: [ + { + id: 'cts_ip_1', + type: 'index-pattern', + meta: { + icon: 'indexPatternApp', + title: `Copy to Space index pattern 1 from ${spaceId} space`, + }, + }, + { + id: `cts_vis_1_${spaceId}`, + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 1 from ${spaceId} space` }, + }, + { + id: `cts_vis_2_${spaceId}`, + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 2 from ${spaceId} space` }, + }, + { + id: 'cts_vis_3', + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 3 from ${spaceId} space` }, + }, + { + id: 'cts_dashboard', + type: 'dashboard', + meta: { + icon: 'dashboardApp', + title: `This is the ${spaceId} test space CTS dashboard`, + }, + }, + ], }, } as CopyResponse); @@ -288,6 +292,42 @@ export function copyToSpaceTestSuiteFactory( [destination]: { success: true, successCount: 5, + successResults: [ + { + id: 'cts_ip_1', + type: 'index-pattern', + meta: { + icon: 'indexPatternApp', + title: `Copy to Space index pattern 1 from ${spaceId} space`, + }, + overwrite: true, + }, + { + id: `cts_vis_1_${spaceId}`, + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 1 from ${spaceId} space` }, + }, + { + id: `cts_vis_2_${spaceId}`, + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 2 from ${spaceId} space` }, + }, + { + id: 'cts_vis_3', + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 3 from ${spaceId} space` }, + overwrite: true, + }, + { + id: 'cts_dashboard', + type: 'dashboard', + meta: { + icon: 'dashboardApp', + title: `This is the ${spaceId} test space CTS dashboard`, + }, + overwrite: true, + }, + ], }, } as CopyResponse); @@ -309,30 +349,48 @@ export function copyToSpaceTestSuiteFactory( const result = resp.body as CopyResponse; result[destination].errors!.sort(errorSorter); + const expectedSuccessResults = [ + { + id: `cts_vis_1_${spaceId}`, + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 1 from ${spaceId} space` }, + }, + { + id: `cts_vis_2_${spaceId}`, + type: 'visualization', + meta: { icon: 'visualizeApp', title: `CTS vis 2 from ${spaceId} space` }, + }, + ]; const expectedErrors = [ { - error: { - type: 'conflict', - }, + error: { type: 'conflict' }, id: 'cts_dashboard', title: `This is the ${spaceId} test space CTS dashboard`, type: 'dashboard', + meta: { + title: `This is the ${spaceId} test space CTS dashboard`, + icon: 'dashboardApp', + }, }, { - error: { - type: 'conflict', - }, + error: { type: 'conflict' }, id: 'cts_ip_1', title: `Copy to Space index pattern 1 from ${spaceId} space`, type: 'index-pattern', + meta: { + title: `Copy to Space index pattern 1 from ${spaceId} space`, + icon: 'indexPatternApp', + }, }, { - error: { - type: 'conflict', - }, + error: { type: 'conflict' }, id: 'cts_vis_3', title: `CTS vis 3 from ${spaceId} space`, type: 'visualization', + meta: { + title: `CTS vis 3 from ${spaceId} space`, + icon: 'visualizeApp', + }, }, ]; expectedErrors.sort(errorSorter); @@ -341,16 +399,176 @@ export function copyToSpaceTestSuiteFactory( [destination]: { success: false, successCount: 2, + successResults: expectedSuccessResults, errors: expectedErrors, }, } as CopyResponse); - // Query ES to ensure that we copied everything we expected - await assertSpaceCounts(destination, { - dashboard: 2, - visualization: 5, - 'index-pattern': 1, - }); + // Query ES to ensure that no objects were created + await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); + }; + + /** + * Creates test cases for multi-namespace saved object types. + * Note: these are written with the assumption that test data will only be reloaded between each group of test cases, *not* before every + * single test case. This saves time during test execution. + */ + const createMultiNamespaceTestCases = ( + spaceId: string, + outcome: 'authorized' | 'unauthorizedRead' | 'unauthorizedWrite' | 'noAccess' = 'authorized' + ) => (overwrite: boolean): CopyToSpaceMultiNamespaceTest[] => { + // the status code of the HTTP response differs depending on the error type + // a 403 error actually comes back as an HTTP 200 response + const statusCode = outcome === 'noAccess' ? 404 : 200; + const type = 'sharedtype'; + const v4 = new RegExp(/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i); + const noConflictId = `${spaceId}_only`; + const exactMatchId = 'all_spaces'; + const inexactMatchId = `conflict_1_${spaceId}`; + const ambiguousConflictId = `conflict_2_${spaceId}`; + + const getResult = (response: TestResponse) => (response.body as CopyResponse).space_2; + const expectForbiddenResponse = (response: TestResponse) => { + expect(response.body).to.eql({ + space_2: { + success: false, + successCount: 0, + errors: [ + { statusCode: 403, error: 'Forbidden', message: `Unable to bulk_create sharedtype` }, + ], + }, + }); + }; + + return [ + { + testTitle: 'copying with no conflict', + objects: [{ type, id: noConflictId }], + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + const { success, successCount, successResults, errors } = getResult(response); + expect(success).to.eql(true); + expect(successCount).to.eql(1); + const destinationId = successResults![0].destinationId; + expect(destinationId).to.match(v4); + const meta = { title: 'A shared saved-object in one space', icon: 'beaker' }; + expect(successResults).to.eql([{ type, id: noConflictId, meta, destinationId }]); + expect(errors).to.be(undefined); + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an exact match conflict', + objects: [{ type, id: exactMatchId }], + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + const { success, successCount, successResults, errors } = getResult(response); + const title = 'A shared saved-object in the default, space_1, and space_2 spaces'; + const meta = { title, icon: 'beaker' }; + if (overwrite) { + expect(success).to.eql(true); + expect(successCount).to.eql(1); + expect(successResults).to.eql([{ type, id: exactMatchId, meta, overwrite: true }]); + expect(errors).to.be(undefined); + } else { + expect(success).to.eql(false); + expect(successCount).to.eql(0); + expect(successResults).to.be(undefined); + expect(errors).to.eql([ + { error: { type: 'conflict' }, type, id: exactMatchId, title, meta }, + ]); + } + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an inexact match conflict', + objects: [{ type, id: inexactMatchId }], + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + const { success, successCount, successResults, errors } = getResult(response); + const title = 'A shared saved-object in one space'; + const meta = { title, icon: 'beaker' }; + const destinationId = 'conflict_1_space_2'; + if (overwrite) { + expect(success).to.eql(true); + expect(successCount).to.eql(1); + expect(successResults).to.eql([ + { type, id: inexactMatchId, meta, overwrite: true, destinationId }, + ]); + expect(errors).to.be(undefined); + } else { + expect(success).to.eql(false); + expect(successCount).to.eql(0); + expect(successResults).to.be(undefined); + expect(errors).to.eql([ + { + error: { type: 'conflict', destinationId }, + type, + id: inexactMatchId, + title, + meta, + }, + ]); + } + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an ambiguous conflict', + objects: [{ type, id: ambiguousConflictId }], + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + const { success, successCount, successResults, errors } = getResult(response); + const updatedAt = '2017-09-21T18:59:16.270Z'; + const destinations = [ + // response should be sorted by updatedAt in descending order + { id: 'conflict_2_space_2', title: 'A shared saved-object in one space', updatedAt }, + { id: 'conflict_2_all', title: 'A shared saved-object in all spaces', updatedAt }, + ]; + expect(success).to.eql(false); + expect(successCount).to.eql(0); + expect(successResults).to.be(undefined); + expect(errors).to.eql([ + { + error: { type: 'ambiguous_conflict', destinations }, + type, + id: ambiguousConflictId, + title: 'A shared saved-object in one space', + meta: { + title: 'A shared saved-object in one space', + icon: 'beaker', + }, + }, + ]); + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + ]; }; const makeCopyToSpaceTest = (describeFn: DescribeFn) => ( @@ -363,162 +581,153 @@ export function copyToSpaceTestSuiteFactory( expect(['default', 'space_1']).to.contain(spaceId); }); - beforeEach(() => esArchiver.load('saved_objects/spaces')); - afterEach(() => esArchiver.unload('saved_objects/spaces')); - - it(`should return ${tests.noConflictsWithoutReferences.statusCode} when copying to space without conflicts or references`, async () => { - const destination = getDestinationWithoutConflicts(); - - await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); - - return supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - spaces: [destination], - includeReferences: false, - overwrite: false, - }) - .expect(tests.noConflictsWithoutReferences.statusCode) - .then(tests.noConflictsWithoutReferences.response); - }); - - it(`should return ${tests.noConflictsWithReferences.statusCode} when copying to space without conflicts with references`, async () => { - const destination = getDestinationWithoutConflicts(); - - await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); - - return supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - spaces: [destination], - includeReferences: true, - overwrite: false, - }) - .expect(tests.noConflictsWithReferences.statusCode) - .then(tests.noConflictsWithReferences.response); - }); - - it(`should return ${tests.withConflictsOverwriting.statusCode} when copying to space with conflicts when overwriting`, async () => { - const destination = getDestinationWithConflicts(spaceId); - - await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); - - return supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - spaces: [destination], - includeReferences: true, - overwrite: true, - }) - .expect(tests.withConflictsOverwriting.statusCode) - .then(tests.withConflictsOverwriting.response); - }); - - it(`should return ${tests.withConflictsWithoutOverwriting.statusCode} when copying to space with conflicts without overwriting`, async () => { - const destination = getDestinationWithConflicts(spaceId); - - await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); - - return supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - spaces: [destination], - includeReferences: true, - overwrite: false, - }) - .expect(tests.withConflictsWithoutOverwriting.statusCode) - .then(tests.withConflictsWithoutOverwriting.response); - }); - - it(`should return ${tests.multipleSpaces.statusCode} when copying to multiple spaces`, async () => { - const conflictDestination = getDestinationWithConflicts(spaceId); - const noConflictDestination = getDestinationWithoutConflicts(); - - return supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - spaces: [conflictDestination, noConflictDestination], - includeReferences: true, - overwrite: true, - }) - .expect(tests.multipleSpaces.statusCode) - .then((response: TestResponse) => { - if (tests.multipleSpaces.statusCode === 200) { - expect(Object.keys(response.body).length).to.eql(2); + describe('single-namespace types', () => { + beforeEach(() => esArchiver.load('saved_objects/spaces')); + afterEach(() => esArchiver.unload('saved_objects/spaces')); + + const dashboardObject = { type: 'dashboard', id: 'cts_dashboard' }; + + it(`should return ${tests.noConflictsWithoutReferences.statusCode} when copying to space without conflicts or references`, async () => { + const destination = getDestinationWithoutConflicts(); + + await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); + + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + spaces: [destination], + includeReferences: false, + overwrite: false, + }) + .expect(tests.noConflictsWithoutReferences.statusCode) + .then(tests.noConflictsWithoutReferences.response); + }); + + it(`should return ${tests.noConflictsWithReferences.statusCode} when copying to space without conflicts with references`, async () => { + const destination = getDestinationWithoutConflicts(); + + await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); + + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + spaces: [destination], + includeReferences: true, + overwrite: false, + }) + .expect(tests.noConflictsWithReferences.statusCode) + .then(tests.noConflictsWithReferences.response); + }); + + it(`should return ${tests.withConflictsOverwriting.statusCode} when copying to space with conflicts when overwriting`, async () => { + const destination = getDestinationWithConflicts(spaceId); + + await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); + + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + spaces: [destination], + includeReferences: true, + overwrite: true, + }) + .expect(tests.withConflictsOverwriting.statusCode) + .then(tests.withConflictsOverwriting.response); + }); + + it(`should return ${tests.withConflictsWithoutOverwriting.statusCode} when copying to space with conflicts without overwriting`, async () => { + const destination = getDestinationWithConflicts(spaceId); + + await assertSpaceCounts(destination, INITIAL_COUNTS[destination]); + + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + spaces: [destination], + includeReferences: true, + overwrite: false, + }) + .expect(tests.withConflictsWithoutOverwriting.statusCode) + .then(tests.withConflictsWithoutOverwriting.response); + }); + + it(`should return ${tests.multipleSpaces.statusCode} when copying to multiple spaces`, async () => { + const conflictDestination = getDestinationWithConflicts(spaceId); + const noConflictDestination = getDestinationWithoutConflicts(); + + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + spaces: [conflictDestination, noConflictDestination], + includeReferences: true, + overwrite: true, + }) + .expect(tests.multipleSpaces.statusCode) + .then((response: TestResponse) => { + if (tests.multipleSpaces.statusCode === 200) { + expect(Object.keys(response.body).length).to.eql(2); + return Promise.all([ + tests.multipleSpaces.noConflictsResponse({ + body: { [noConflictDestination]: response.body[noConflictDestination] }, + }), + tests.multipleSpaces.withConflictsResponse({ + body: { [conflictDestination]: response.body[conflictDestination] }, + }), + ]); + } + + // non-200 status codes will not have a response body broken out by space id, like above. return Promise.all([ - tests.multipleSpaces.noConflictsResponse({ - body: { - [noConflictDestination]: response.body[noConflictDestination], - }, - }), - tests.multipleSpaces.withConflictsResponse({ - body: { - [conflictDestination]: response.body[conflictDestination], - }, - }), + tests.multipleSpaces.noConflictsResponse(response), + tests.multipleSpaces.withConflictsResponse(response), ]); - } - - // non-200 status codes will not have a response body broken out by space id, like above. - return Promise.all([ - tests.multipleSpaces.noConflictsResponse(response), - tests.multipleSpaces.withConflictsResponse(response), - ]); - }); + }); + }); + + it(`should return ${tests.nonExistentSpace.statusCode} when copying to non-existent space`, async () => { + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + spaces: ['non_existent_space'], + includeReferences: false, + overwrite: true, + }) + .expect(tests.nonExistentSpace.statusCode) + .then(tests.nonExistentSpace.response); + }); }); - it(`should return ${tests.nonExistentSpace.statusCode} when copying to non-existent space`, async () => { - return supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - spaces: ['non_existent_space'], - includeReferences: false, - overwrite: true, - }) - .expect(tests.nonExistentSpace.statusCode) - .then(tests.nonExistentSpace.response); + [false, true].forEach((overwrite) => { + const spaces = ['space_2']; + const includeReferences = false; + describe(`multi-namespace types with overwrite=${overwrite}`, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + const testCases = tests.multiNamespaceTestCases(overwrite); + testCases.forEach(({ testTitle, objects, statusCode, response }) => { + it(`should return ${statusCode} when ${testTitle}`, async () => { + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_copy_saved_objects`) + .auth(user.username, user.password) + .send({ objects, spaces, includeReferences, overwrite }) + .expect(statusCode) + .then(response); + }); + }); + }); }); }); }; @@ -534,10 +743,10 @@ export function copyToSpaceTestSuiteFactory( expectNoConflictsForNonExistentSpaceResult, createExpectWithConflictsOverwritingResult, createExpectWithConflictsWithoutOverwritingResult, - expectRbacForbiddenResponse, expectNotFoundResponse, createExpectUnauthorizedAtSpaceWithReferencesResult, createExpectUnauthorizedAtSpaceWithoutReferencesResult, + createMultiNamespaceTestCases, originSpaces: ['default', 'space_1'], }; } diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.ts b/x-pack/test/spaces_api_integration/common/suites/delete.ts index 15a90092f5517..69b5697d8a9a8 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -130,7 +130,7 @@ export function deleteTestSuiteFactory(es: any, esArchiver: any, supertest: Supe expect(buckets).to.eql(expectedBuckets); - // There were seven multi-namespace objects. + // There were eleven multi-namespace objects. // Since Space 2 was deleted, any multi-namespace objects that existed in that space // are updated to remove it, and of those, any that don't exist in any space are deleted. const multiNamespaceResponse = await es.search({ @@ -138,16 +138,13 @@ export function deleteTestSuiteFactory(es: any, esArchiver: any, supertest: Supe body: { query: { terms: { type: ['sharedtype'] } } }, }); const docs: [Record] = multiNamespaceResponse.hits.hits; - expect(docs).length(6); // just six results, since spaces_2_only got deleted - Object.values(CASES).forEach(({ id, existingNamespaces }) => { - const remainingNamespaces = existingNamespaces.filter((x) => x !== 'space_2'); - const doc = docs.find((x) => x._id === `sharedtype:${id}`); - if (remainingNamespaces.length > 0) { - expect(doc?._source?.namespaces).to.eql(remainingNamespaces); - } else { - expect(doc).to.be(undefined); - } + expect(docs).length(10); // just ten results, since spaces_2_only got deleted + docs.forEach((doc) => () => { + const containsSpace2 = doc?._source?.namespaces.includes('space_2'); + expect(containsSpace2).to.eql(false); }); + const space2OnlyObjExists = docs.some((x) => x._id === CASES.SPACE_2_ONLY); + expect(space2OnlyObjExists).to.eql(false); }; const expectNotFound = (resp: { [key: string]: any }) => { diff --git a/x-pack/test/spaces_api_integration/common/suites/get_all.ts b/x-pack/test/spaces_api_integration/common/suites/get_all.ts index b6fb449e7b087..d41d73bba90bc 100644 --- a/x-pack/test/spaces_api_integration/common/suites/get_all.ts +++ b/x-pack/test/spaces_api_integration/common/suites/get_all.ts @@ -16,6 +16,7 @@ interface GetAllTest { interface GetAllTests { exists: GetAllTest; copySavedObjectsPurpose: GetAllTest; + shareSavedObjectsPurpose: GetAllTest; } interface GetAllTestDefinition { @@ -88,6 +89,17 @@ export function getAllTestSuiteFactory(esArchiver: any, supertest: SuperTest { + it(`should return ${tests.shareSavedObjectsPurpose.statusCode}`, async () => { + return supertest + .get(`${getUrlPrefix(spaceId)}/api/spaces/space`) + .query({ purpose: 'shareSavedObjectsIntoSpace' }) + .auth(user.username, user.password) + .expect(tests.copySavedObjectsPurpose.statusCode) + .then(tests.copySavedObjectsPurpose.response); + }); + }); }); }; diff --git a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts index 6d80688b7a703..cb9219b1ba2ed 100644 --- a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts @@ -20,12 +20,19 @@ interface ResolveCopyToSpaceTest { response: (resp: TestResponse) => Promise; } +interface ResolveCopyToSpaceMultiNamespaceTest extends ResolveCopyToSpaceTest { + testTitle: string; + objects: Array>; + retries: Record; +} + interface ResolveCopyToSpaceTests { withReferencesNotOverwriting: ResolveCopyToSpaceTest; withReferencesOverwriting: ResolveCopyToSpaceTest; withoutReferencesOverwriting: ResolveCopyToSpaceTest; withoutReferencesNotOverwriting: ResolveCopyToSpaceTest; nonExistentSpace: ResolveCopyToSpaceTest; + multiNamespaceTestCases: () => ResolveCopyToSpaceMultiNamespaceTest[]; } interface ResolveCopyToSpaceTestDefinition { @@ -76,6 +83,17 @@ export function resolveCopyToSpaceConflictsSuite( [destination]: { success: true, successCount: 1, + successResults: [ + { + id: 'cts_vis_3', + type: 'visualization', + meta: { + title: `CTS vis 3 from ${sourceSpaceId} space`, + icon: 'visualizeApp', + }, + overwrite: true, + }, + ], }, }); const [dashboard, visualization] = await getObjectsAtSpace(destination); @@ -94,6 +112,17 @@ export function resolveCopyToSpaceConflictsSuite( [destinationSpaceId]: { success: true, successCount: 1, + successResults: [ + { + id: 'cts_dashboard', + type: 'dashboard', + meta: { + title: `This is the ${sourceSpaceId} test space CTS dashboard`, + icon: 'dashboardApp', + }, + overwrite: true, + }, + ], }, }); const [dashboard, visualization] = await getObjectsAtSpace(destinationSpaceId); @@ -119,11 +148,13 @@ export function resolveCopyToSpaceConflictsSuite( successCount: 0, errors: [ { - error: { - type: 'conflict', - }, + error: { type: 'conflict' }, id: 'cts_vis_3', title: `CTS vis 3 from ${sourceSpaceId} space`, + meta: { + title: `CTS vis 3 from ${sourceSpaceId} space`, + icon: 'visualizeApp', + }, type: 'visualization', }, ], @@ -149,12 +180,14 @@ export function resolveCopyToSpaceConflictsSuite( successCount: 0, errors: [ { - error: { - type: 'conflict', - }, + error: { type: 'conflict' }, id: 'cts_dashboard', - title: `This is the ${sourceSpaceId} test space CTS dashboard`, type: 'dashboard', + title: `This is the ${sourceSpaceId} test space CTS dashboard`, + meta: { + title: `This is the ${sourceSpaceId} test space CTS dashboard`, + icon: 'dashboardApp', + }, }, ], }, @@ -264,6 +297,113 @@ export function resolveCopyToSpaceConflictsSuite( } }; + /** + * Creates test cases for multi-namespace saved object types. + * Note: these are written with the assumption that test data will only be reloaded between each group of test cases, *not* before every + * single test case. This saves time during test execution. + */ + const createMultiNamespaceTestCases = ( + spaceId: string, + outcome: 'authorized' | 'unauthorizedRead' | 'unauthorizedWrite' | 'noAccess' = 'authorized' + ) => (): ResolveCopyToSpaceMultiNamespaceTest[] => { + // the status code of the HTTP response differs depending on the error type + // a 403 error actually comes back as an HTTP 200 response + const statusCode = outcome === 'noAccess' ? 404 : 200; + const type = 'sharedtype'; + const exactMatchId = 'all_spaces'; + const inexactMatchId = `conflict_1_${spaceId}`; + const ambiguousConflictId = `conflict_2_${spaceId}`; + + const createRetries = (overwriteRetry: Record) => ({ space_2: [overwriteRetry] }); + const getResult = (response: TestResponse) => (response.body as CopyResponse).space_2; + const expectForbiddenResponse = (response: TestResponse) => { + expect(response.body).to.eql({ + space_2: { + success: false, + successCount: 0, + errors: [ + { statusCode: 403, error: 'Forbidden', message: `Unable to bulk_create sharedtype` }, + ], + }, + }); + }; + const expectSuccessResponse = (response: TestResponse, id: string, destinationId?: string) => { + const { success, successCount, successResults, errors } = getResult(response); + expect(success).to.eql(true); + expect(successCount).to.eql(1); + expect(errors).to.be(undefined); + const title = + id === exactMatchId + ? 'A shared saved-object in the default, space_1, and space_2 spaces' + : 'A shared saved-object in one space'; + const meta = { title, icon: 'beaker' }; + expect(successResults).to.eql([ + { type, id, meta, overwrite: true, ...(destinationId && { destinationId }) }, + ]); + }; + + return [ + { + testTitle: 'copying with an exact match conflict', + objects: [{ type, id: exactMatchId }], + retries: createRetries({ type, id: exactMatchId, overwrite: true }), + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + expectSuccessResponse(response, exactMatchId); + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an inexact match conflict', + objects: [{ type, id: inexactMatchId }], + retries: createRetries({ + type, + id: inexactMatchId, + overwrite: true, + destinationId: 'conflict_1_space_2', + }), + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + expectSuccessResponse(response, inexactMatchId, 'conflict_1_space_2'); + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + { + testTitle: 'copying with an ambiguous conflict', + objects: [{ type, id: ambiguousConflictId }], + retries: createRetries({ + type, + id: ambiguousConflictId, + overwrite: true, + destinationId: 'conflict_2_space_2', + }), + statusCode, + response: async (response: TestResponse) => { + if (outcome === 'authorized') { + expectSuccessResponse(response, ambiguousConflictId, 'conflict_2_space_2'); + } else if (outcome === 'noAccess') { + expectNotFoundResponse(response); + } else { + // unauthorized read/write + expectForbiddenResponse(response); + } + }, + }, + ]; + }; + const makeResolveCopyToSpaceConflictsTest = (describeFn: DescribeFn) => ( description: string, { user = {}, spaceId = DEFAULT_SPACE_ID, tests }: ResolveCopyToSpaceTestDefinition @@ -274,147 +414,105 @@ export function resolveCopyToSpaceConflictsSuite( expect(['default', 'space_1']).to.contain(spaceId); }); - beforeEach(() => esArchiver.load('saved_objects/spaces')); - afterEach(() => esArchiver.unload('saved_objects/spaces')); - - it(`should return ${tests.withReferencesNotOverwriting.statusCode} when not overwriting, with references`, async () => { - const destination = getDestinationSpace(spaceId); - - return supertestWithoutAuth - .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - includeReferences: true, - retries: { - [destination]: [ - { - type: 'visualization', - id: 'cts_vis_3', - overwrite: false, - }, - ], - }, - }) - .expect(tests.withReferencesNotOverwriting.statusCode) - .then(tests.withReferencesNotOverwriting.response); - }); - - it(`should return ${tests.withReferencesOverwriting.statusCode} when overwriting, with references`, async () => { - const destination = getDestinationSpace(spaceId); - - return supertestWithoutAuth - .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - includeReferences: true, - retries: { - [destination]: [ - { - type: 'visualization', - id: 'cts_vis_3', - overwrite: true, - }, - ], - }, - }) - .expect(tests.withReferencesOverwriting.statusCode) - .then(tests.withReferencesOverwriting.response); - }); - - it(`should return ${tests.withoutReferencesOverwriting.statusCode} when overwriting, without references`, async () => { - const destination = getDestinationSpace(spaceId); - - return supertestWithoutAuth - .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - includeReferences: false, - retries: { - [destination]: [ - { - type: 'dashboard', - id: 'cts_dashboard', - overwrite: true, - }, - ], - }, - }) - .expect(tests.withoutReferencesOverwriting.statusCode) - .then(tests.withoutReferencesOverwriting.response); + describe('single-namespace types', () => { + beforeEach(() => esArchiver.load('saved_objects/spaces')); + afterEach(() => esArchiver.unload('saved_objects/spaces')); + + const dashboardObject = { type: 'dashboard', id: 'cts_dashboard' }; + const visualizationObject = { type: 'visualization', id: 'cts_vis_3' }; + + it(`should return ${tests.withReferencesNotOverwriting.statusCode} when not overwriting, with references`, async () => { + const destination = getDestinationSpace(spaceId); + + return supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + includeReferences: true, + retries: { [destination]: [{ ...visualizationObject, overwrite: false }] }, + }) + .expect(tests.withReferencesNotOverwriting.statusCode) + .then(tests.withReferencesNotOverwriting.response); + }); + + it(`should return ${tests.withReferencesOverwriting.statusCode} when overwriting, with references`, async () => { + const destination = getDestinationSpace(spaceId); + + return supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + includeReferences: true, + retries: { [destination]: [{ ...visualizationObject, overwrite: true }] }, + }) + .expect(tests.withReferencesOverwriting.statusCode) + .then(tests.withReferencesOverwriting.response); + }); + + it(`should return ${tests.withoutReferencesOverwriting.statusCode} when overwriting, without references`, async () => { + const destination = getDestinationSpace(spaceId); + + return supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + includeReferences: false, + retries: { [destination]: [{ ...dashboardObject, overwrite: true }] }, + }) + .expect(tests.withoutReferencesOverwriting.statusCode) + .then(tests.withoutReferencesOverwriting.response); + }); + + it(`should return ${tests.withoutReferencesNotOverwriting.statusCode} when not overwriting, without references`, async () => { + const destination = getDestinationSpace(spaceId); + + return supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + includeReferences: false, + retries: { [destination]: [{ ...dashboardObject, overwrite: false }] }, + }) + .expect(tests.withoutReferencesNotOverwriting.statusCode) + .then(tests.withoutReferencesNotOverwriting.response); + }); + + it(`should return ${tests.nonExistentSpace.statusCode} when resolving within a non-existent space`, async () => { + const destination = NON_EXISTENT_SPACE_ID; + + return supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) + .auth(user.username, user.password) + .send({ + objects: [dashboardObject], + includeReferences: false, + retries: { [destination]: [{ ...dashboardObject, overwrite: true }] }, + }) + .expect(tests.nonExistentSpace.statusCode) + .then(tests.nonExistentSpace.response); + }); }); - it(`should return ${tests.withoutReferencesNotOverwriting.statusCode} when not overwriting, without references`, async () => { - const destination = getDestinationSpace(spaceId); - - return supertestWithoutAuth - .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - includeReferences: false, - retries: { - [destination]: [ - { - type: 'dashboard', - id: 'cts_dashboard', - overwrite: false, - }, - ], - }, - }) - .expect(tests.withoutReferencesNotOverwriting.statusCode) - .then(tests.withoutReferencesNotOverwriting.response); - }); - - it(`should return ${tests.nonExistentSpace.statusCode} when resolving within a non-existent space`, async () => { - const destination = NON_EXISTENT_SPACE_ID; - - return supertestWithoutAuth - .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) - .auth(user.username, user.password) - .send({ - objects: [ - { - type: 'dashboard', - id: 'cts_dashboard', - }, - ], - includeReferences: false, - retries: { - [destination]: [ - { - type: 'dashboard', - id: 'cts_dashboard', - overwrite: true, - }, - ], - }, - }) - .expect(tests.nonExistentSpace.statusCode) - .then(tests.nonExistentSpace.response); + const includeReferences = false; + describe(`multi-namespace types with "overwrite" retry`, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + const testCases = tests.multiNamespaceTestCases(); + testCases.forEach(({ testTitle, objects, retries, statusCode, response }) => { + it(`should return ${statusCode} when ${testTitle}`, async () => { + return supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/spaces/_resolve_copy_saved_objects_errors`) + .auth(user.username, user.password) + .send({ objects, includeReferences, retries }) + .expect(statusCode) + .then(response); + }); + }); }); }); }; @@ -433,6 +531,7 @@ export function resolveCopyToSpaceConflictsSuite( createExpectUnauthorizedAtSpaceWithReferencesResult, createExpectReadonlyAtSpaceWithReferencesResult, createExpectUnauthorizedAtSpaceWithoutReferencesResult, + createMultiNamespaceTestCases, originSpaces: ['default', 'space_1'], NON_EXISTENT_SPACE_ID, }; diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts index 08450f48567c8..0f1c27098af92 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/copy_to_space.ts @@ -25,6 +25,7 @@ export default function copyToSpaceSpacesAndSecuritySuite({ getService }: TestIn createExpectUnauthorizedAtSpaceWithReferencesResult, createExpectUnauthorizedAtSpaceWithoutReferencesResult, expectNotFoundResponse, + createMultiNamespaceTestCases, } = copyToSpaceTestSuiteFactory(es, esArchiver, supertestWithoutAuth); describe('copy to spaces', () => { @@ -55,325 +56,148 @@ export default function copyToSpaceSpacesAndSecuritySuite({ getService }: TestIn dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, }, }, - ].forEach((scenario) => { - copyToSpaceTest(`user with no access from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.noAccess, + ].forEach(({ spaceId, ...scenario }) => { + const definitionNoAccess = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { - noConflictsWithoutReferences: { - statusCode: 404, - response: expectNotFoundResponse, - }, - noConflictsWithReferences: { - statusCode: 404, - response: expectNotFoundResponse, - }, - withConflictsOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, - }, - withConflictsWithoutOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, - }, + noConflictsWithoutReferences: { statusCode: 404, response: expectNotFoundResponse }, + noConflictsWithReferences: { statusCode: 404, response: expectNotFoundResponse }, + withConflictsOverwriting: { statusCode: 404, response: expectNotFoundResponse }, + withConflictsWithoutOverwriting: { statusCode: 404, response: expectNotFoundResponse }, multipleSpaces: { statusCode: 404, withConflictsResponse: expectNotFoundResponse, noConflictsResponse: expectNotFoundResponse, }, - nonExistentSpace: { - statusCode: 404, - response: expectNotFoundResponse, - }, + nonExistentSpace: { statusCode: 404, response: expectNotFoundResponse }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'noAccess'), }, }); - - copyToSpaceTest(`superuser from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.superuser, - tests: { - noConflictsWithoutReferences: { - statusCode: 200, - response: expectNoConflictsWithoutReferencesResult, - }, - noConflictsWithReferences: { - statusCode: 200, - response: expectNoConflictsWithReferencesResult, - }, - withConflictsOverwriting: { - statusCode: 200, - response: createExpectWithConflictsOverwritingResult(scenario.spaceId), - }, - withConflictsWithoutOverwriting: { - statusCode: 200, - response: createExpectWithConflictsWithoutOverwritingResult(scenario.spaceId), - }, - multipleSpaces: { - statusCode: 200, - withConflictsResponse: createExpectWithConflictsOverwritingResult(scenario.spaceId), - noConflictsResponse: expectNoConflictsWithReferencesResult, - }, - nonExistentSpace: { - statusCode: 200, - response: expectNoConflictsForNonExistentSpaceResult, - }, + // In *this* test suite, a user who is unauthorized to write (but authorized to read) in the destination space will get the same exact + // results as a user who is unauthorized to read in the destination space. However, that may not *always* be the case depending on the + // input that is submitted, due to the `validateReferences` check that can trigger a `bulkGet` for the destination space. See also the + // integration tests in `./resolve_copy_to_space_conflicts`, which behave differently. + const commonUnauthorizedTests = { + noConflictsWithoutReferences: { + statusCode: 200, + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( + spaceId, + 'without-conflicts' + ), }, - }); - - copyToSpaceTest(`rbac user with all globally from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.allGlobally, - tests: { - noConflictsWithoutReferences: { - statusCode: 200, - response: expectNoConflictsWithoutReferencesResult, - }, - noConflictsWithReferences: { - statusCode: 200, - response: expectNoConflictsWithReferencesResult, - }, - withConflictsOverwriting: { - statusCode: 200, - response: createExpectWithConflictsOverwritingResult(scenario.spaceId), - }, - withConflictsWithoutOverwriting: { - statusCode: 200, - response: createExpectWithConflictsWithoutOverwritingResult(scenario.spaceId), - }, - multipleSpaces: { - statusCode: 200, - withConflictsResponse: createExpectWithConflictsOverwritingResult(scenario.spaceId), - noConflictsResponse: expectNoConflictsWithReferencesResult, - }, - nonExistentSpace: { - statusCode: 200, - response: expectNoConflictsForNonExistentSpaceResult, - }, + noConflictsWithReferences: { + statusCode: 200, + response: createExpectUnauthorizedAtSpaceWithReferencesResult( + spaceId, + 'without-conflicts' + ), }, - }); - - copyToSpaceTest(`dual-privileges user from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.dualAll, - tests: { - noConflictsWithoutReferences: { - statusCode: 200, - response: expectNoConflictsWithoutReferencesResult, - }, - noConflictsWithReferences: { - statusCode: 200, - response: expectNoConflictsWithReferencesResult, - }, - withConflictsOverwriting: { - statusCode: 200, - response: createExpectWithConflictsOverwritingResult(scenario.spaceId), - }, - withConflictsWithoutOverwriting: { - statusCode: 200, - response: createExpectWithConflictsWithoutOverwritingResult(scenario.spaceId), - }, - multipleSpaces: { - statusCode: 200, - withConflictsResponse: createExpectWithConflictsOverwritingResult(scenario.spaceId), - noConflictsResponse: expectNoConflictsWithReferencesResult, - }, - nonExistentSpace: { - statusCode: 200, - response: expectNoConflictsForNonExistentSpaceResult, - }, + withConflictsOverwriting: { + statusCode: 200, + response: createExpectUnauthorizedAtSpaceWithReferencesResult(spaceId, 'with-conflicts'), }, - }); - - copyToSpaceTest(`legacy user from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.legacyAll, - tests: { - noConflictsWithoutReferences: { - statusCode: 404, - response: expectNotFoundResponse, - }, - noConflictsWithReferences: { - statusCode: 404, - response: expectNotFoundResponse, - }, - withConflictsOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, - }, - withConflictsWithoutOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, - }, - multipleSpaces: { - statusCode: 404, - withConflictsResponse: expectNotFoundResponse, - noConflictsResponse: expectNotFoundResponse, - }, - nonExistentSpace: { - statusCode: 404, - response: expectNotFoundResponse, - }, + withConflictsWithoutOverwriting: { + statusCode: 200, + response: createExpectUnauthorizedAtSpaceWithReferencesResult(spaceId, 'with-conflicts'), }, - }); - - copyToSpaceTest(`rbac user with read globally from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.readGlobally, + multipleSpaces: { + statusCode: 200, + withConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( + spaceId, + 'with-conflicts' + ), + noConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( + spaceId, + 'without-conflicts' + ), + }, + nonExistentSpace: { + statusCode: 200, + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(spaceId, 'non-existent'), + }, + }; + const definitionUnauthorizedRead = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { - noConflictsWithoutReferences: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), - }, - noConflictsWithReferences: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), - }, - withConflictsOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - }, - withConflictsWithoutOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - }, - multipleSpaces: { - statusCode: 200, - withConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - noConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), - }, - nonExistentSpace: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - 'non-existent' - ), - }, + ...commonUnauthorizedTests, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'unauthorizedRead'), }, }); - - copyToSpaceTest(`dual-privileges readonly user from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.dualRead, + const definitionUnauthorizedWrite = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { - noConflictsWithoutReferences: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), - }, - noConflictsWithReferences: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), - }, - withConflictsOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - }, - withConflictsWithoutOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - }, - multipleSpaces: { - statusCode: 200, - withConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - noConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), - }, - nonExistentSpace: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - 'non-existent' - ), - }, + ...commonUnauthorizedTests, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'unauthorizedWrite'), }, }); - - copyToSpaceTest(`rbac user with all at space from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.allAtSpace, + const definitionAuthorized = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { noConflictsWithoutReferences: { statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), + response: expectNoConflictsWithoutReferencesResult(spaceId), }, noConflictsWithReferences: { statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), + response: expectNoConflictsWithReferencesResult(spaceId), }, withConflictsOverwriting: { statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), + response: createExpectWithConflictsOverwritingResult(spaceId), }, withConflictsWithoutOverwriting: { statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), + response: createExpectWithConflictsWithoutOverwritingResult(spaceId), }, multipleSpaces: { statusCode: 200, - withConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'with-conflicts' - ), - noConflictsResponse: createExpectUnauthorizedAtSpaceWithReferencesResult( - scenario.spaceId, - 'without-conflicts' - ), + withConflictsResponse: createExpectWithConflictsOverwritingResult(spaceId), + noConflictsResponse: expectNoConflictsWithReferencesResult(spaceId), }, nonExistentSpace: { statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - 'non-existent' - ), + response: expectNoConflictsForNonExistentSpaceResult(spaceId), }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'authorized'), }, }); + + copyToSpaceTest( + `user with no access from the ${spaceId} space`, + definitionNoAccess(scenario.users.noAccess) + ); + copyToSpaceTest( + `superuser from the ${spaceId} space`, + definitionAuthorized(scenario.users.superuser) + ); + copyToSpaceTest( + `rbac user with all globally from the ${spaceId} space`, + definitionAuthorized(scenario.users.allGlobally) + ); + copyToSpaceTest( + `dual-privileges user from the ${spaceId} space`, + definitionAuthorized(scenario.users.dualAll) + ); + copyToSpaceTest( + `legacy user from the ${spaceId} space`, + definitionNoAccess(scenario.users.legacyAll) + ); + copyToSpaceTest( + `rbac user with read globally from the ${spaceId} space`, + definitionUnauthorizedWrite(scenario.users.readGlobally) + ); + copyToSpaceTest( + `dual-privileges readonly user from the ${spaceId} space`, + definitionUnauthorizedWrite(scenario.users.dualRead) + ); + copyToSpaceTest( + `rbac user with all at space from the ${spaceId} space`, + definitionUnauthorizedRead(scenario.users.allAtSpace) + ); }); }); } diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts index e64f721825089..bf1d90bfc3556 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts @@ -88,6 +88,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -103,6 +107,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, }, }); @@ -118,6 +126,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, }, }); @@ -133,6 +145,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, }, }); @@ -148,6 +164,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -163,6 +183,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -178,6 +202,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -193,6 +221,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('space_1'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('space_1'), + }, }, }); @@ -208,6 +240,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -225,6 +261,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default'), + }, }, } ); @@ -243,6 +283,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, } ); @@ -261,6 +305,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default'), + }, }, } ); @@ -279,6 +327,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, } ); @@ -297,6 +349,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('space_1'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('space_1'), + }, }, } ); @@ -315,6 +371,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, } ); @@ -331,6 +391,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -346,6 +410,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -361,6 +429,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); @@ -376,6 +448,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 403, response: expectRbacForbidden, }, + shareSavedObjectsPurpose: { + statusCode: 403, + response: expectRbacForbidden, + }, }, }); }); diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts index 472ec1a927126..b81f2965eba22 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/resolve_copy_to_space_conflicts.ts @@ -25,6 +25,7 @@ export default function resolveCopyToSpaceConflictsTestSuite({ getService }: Tes createExpectUnauthorizedAtSpaceWithReferencesResult, createExpectReadonlyAtSpaceWithReferencesResult, createExpectUnauthorizedAtSpaceWithoutReferencesResult, + createMultiNamespaceTestCases, NON_EXISTENT_SPACE_ID, } = resolveCopyToSpaceConflictsSuite(esArchiver, supertestWithAuth, supertestWithoutAuth); @@ -56,10 +57,10 @@ export default function resolveCopyToSpaceConflictsTestSuite({ getService }: Tes dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, }, }, - ].forEach((scenario) => { - resolveCopyToSpaceConflictsTest(`user with no access from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.noAccess, + ].forEach(({ spaceId, ...scenario }) => { + const definitionNoAccess = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { withReferencesNotOverwriting: { statusCode: 404, @@ -81,226 +82,131 @@ export default function resolveCopyToSpaceConflictsTestSuite({ getService }: Tes statusCode: 404, response: expectNotFoundResponse, }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'noAccess'), }, }); - - resolveCopyToSpaceConflictsTest(`superuser from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.superuser, + const definitionUnauthorizedRead = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { withReferencesNotOverwriting: { statusCode: 200, - response: createExpectNonOverriddenResponseWithReferences(scenario.spaceId), + response: createExpectUnauthorizedAtSpaceWithReferencesResult(spaceId), }, withReferencesOverwriting: { statusCode: 200, - response: createExpectOverriddenResponseWithReferences(scenario.spaceId), + response: createExpectUnauthorizedAtSpaceWithReferencesResult(spaceId), }, withoutReferencesOverwriting: { statusCode: 200, - response: createExpectOverriddenResponseWithoutReferences(scenario.spaceId), + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(spaceId), }, withoutReferencesNotOverwriting: { statusCode: 200, - response: createExpectNonOverriddenResponseWithoutReferences(scenario.spaceId), + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(spaceId), }, nonExistentSpace: { statusCode: 200, - response: createExpectOverriddenResponseWithoutReferences( - scenario.spaceId, + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( + spaceId, NON_EXISTENT_SPACE_ID ), }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'unauthorizedRead'), }, }); - - resolveCopyToSpaceConflictsTest( - `rbac user with all globally from the ${scenario.spaceId} space`, - { - spaceId: scenario.spaceId, - user: scenario.users.allGlobally, - tests: { - withReferencesNotOverwriting: { - statusCode: 200, - response: createExpectNonOverriddenResponseWithReferences(scenario.spaceId), - }, - withReferencesOverwriting: { - statusCode: 200, - response: createExpectOverriddenResponseWithReferences(scenario.spaceId), - }, - withoutReferencesOverwriting: { - statusCode: 200, - response: createExpectOverriddenResponseWithoutReferences(scenario.spaceId), - }, - withoutReferencesNotOverwriting: { - statusCode: 200, - response: createExpectNonOverriddenResponseWithoutReferences(scenario.spaceId), - }, - nonExistentSpace: { - statusCode: 200, - response: createExpectOverriddenResponseWithoutReferences( - scenario.spaceId, - NON_EXISTENT_SPACE_ID - ), - }, - }, - } - ); - - resolveCopyToSpaceConflictsTest(`dual-privileges user from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.dualAll, + const definitionUnauthorizedWrite = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { withReferencesNotOverwriting: { statusCode: 200, - response: createExpectNonOverriddenResponseWithReferences(scenario.spaceId), + response: createExpectReadonlyAtSpaceWithReferencesResult(spaceId), }, withReferencesOverwriting: { statusCode: 200, - response: createExpectOverriddenResponseWithReferences(scenario.spaceId), + response: createExpectReadonlyAtSpaceWithReferencesResult(spaceId), }, withoutReferencesOverwriting: { statusCode: 200, - response: createExpectOverriddenResponseWithoutReferences(scenario.spaceId), + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(spaceId), }, withoutReferencesNotOverwriting: { statusCode: 200, - response: createExpectNonOverriddenResponseWithoutReferences(scenario.spaceId), + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(spaceId), }, nonExistentSpace: { statusCode: 200, - response: createExpectOverriddenResponseWithoutReferences( - scenario.spaceId, + response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( + spaceId, NON_EXISTENT_SPACE_ID ), }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'unauthorizedWrite'), }, }); - - resolveCopyToSpaceConflictsTest(`legacy user from the ${scenario.spaceId} space`, { - spaceId: scenario.spaceId, - user: scenario.users.legacyAll, + const definitionAuthorized = (user: { username: string; password: string }) => ({ + spaceId, + user, tests: { withReferencesNotOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, + statusCode: 200, + response: createExpectNonOverriddenResponseWithReferences(spaceId), }, withReferencesOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, + statusCode: 200, + response: createExpectOverriddenResponseWithReferences(spaceId), }, withoutReferencesOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, + statusCode: 200, + response: createExpectOverriddenResponseWithoutReferences(spaceId), }, withoutReferencesNotOverwriting: { - statusCode: 404, - response: expectNotFoundResponse, + statusCode: 200, + response: createExpectNonOverriddenResponseWithoutReferences(spaceId), }, nonExistentSpace: { - statusCode: 404, - response: expectNotFoundResponse, + statusCode: 200, + response: createExpectOverriddenResponseWithoutReferences( + spaceId, + NON_EXISTENT_SPACE_ID + ), }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId, 'authorized'), }, }); resolveCopyToSpaceConflictsTest( - `rbac user with read globally from the ${scenario.spaceId} space`, - { - spaceId: scenario.spaceId, - user: scenario.users.readGlobally, - tests: { - withReferencesNotOverwriting: { - statusCode: 200, - response: createExpectReadonlyAtSpaceWithReferencesResult(scenario.spaceId), - }, - withReferencesOverwriting: { - statusCode: 200, - response: createExpectReadonlyAtSpaceWithReferencesResult(scenario.spaceId), - }, - withoutReferencesOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(scenario.spaceId), - }, - withoutReferencesNotOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(scenario.spaceId), - }, - nonExistentSpace: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - NON_EXISTENT_SPACE_ID - ), - }, - }, - } + `user with no access from the ${spaceId} space`, + definitionNoAccess(scenario.users.noAccess) ); - resolveCopyToSpaceConflictsTest( - `dual-privileges readonly user from the ${scenario.spaceId} space`, - { - spaceId: scenario.spaceId, - user: scenario.users.dualRead, - tests: { - withReferencesNotOverwriting: { - statusCode: 200, - response: createExpectReadonlyAtSpaceWithReferencesResult(scenario.spaceId), - }, - withReferencesOverwriting: { - statusCode: 200, - response: createExpectReadonlyAtSpaceWithReferencesResult(scenario.spaceId), - }, - withoutReferencesOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(scenario.spaceId), - }, - withoutReferencesNotOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(scenario.spaceId), - }, - nonExistentSpace: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - NON_EXISTENT_SPACE_ID - ), - }, - }, - } + `superuser from the ${spaceId} space`, + definitionAuthorized(scenario.users.superuser) ); - resolveCopyToSpaceConflictsTest( - `rbac user with all at space from the ${scenario.spaceId} space`, - { - spaceId: scenario.spaceId, - user: scenario.users.allAtSpace, - tests: { - withReferencesNotOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult(scenario.spaceId), - }, - withReferencesOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithReferencesResult(scenario.spaceId), - }, - withoutReferencesOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(scenario.spaceId), - }, - withoutReferencesNotOverwriting: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult(scenario.spaceId), - }, - nonExistentSpace: { - statusCode: 200, - response: createExpectUnauthorizedAtSpaceWithoutReferencesResult( - scenario.spaceId, - NON_EXISTENT_SPACE_ID - ), - }, - }, - } + `rbac user with all globally from the ${spaceId} space`, + definitionAuthorized(scenario.users.allGlobally) + ); + resolveCopyToSpaceConflictsTest( + `dual-privileges user from the ${spaceId} space`, + definitionAuthorized(scenario.users.dualAll) + ); + resolveCopyToSpaceConflictsTest( + `legacy user from the ${spaceId} space`, + definitionNoAccess(scenario.users.legacyAll) + ); + resolveCopyToSpaceConflictsTest( + `rbac user with read globally from the ${spaceId} space`, + definitionUnauthorizedWrite(scenario.users.readGlobally) + ); + resolveCopyToSpaceConflictsTest( + `dual-privileges readonly user from the ${spaceId} space`, + definitionUnauthorizedWrite(scenario.users.dualRead) + ); + resolveCopyToSpaceConflictsTest( + `rbac user with all at space from the ${spaceId} space`, + definitionUnauthorizedRead(scenario.users.allAtSpace) ); }); }); diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts index f3e6580e439bb..ddd029c8d7d68 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts @@ -25,7 +25,7 @@ const createTestCases = (spaceId: string) => { const namespaces = [spaceId]; return [ // Test cases to check adding the target namespace to different saved objects - { ...CASES.DEFAULT_SPACE_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.DEFAULT_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, @@ -37,7 +37,7 @@ const createTestCases = (spaceId: string) => { // These are non-exhaustive, they only check cases for adding two additional namespaces to a saved object // More permutations are covered in the corresponding spaces_only test suite { - ...CASES.DEFAULT_SPACE_ONLY, + ...CASES.DEFAULT_ONLY, namespaces: [SPACE_1_ID, SPACE_2_ID], ...fail404(spaceId !== DEFAULT_SPACE_ID), }, diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts index d83020a9598f1..4b120a71213b7 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts @@ -29,7 +29,7 @@ const createTestCases = (spaceId: string) => { // Test cases to check removing the target namespace from different saved objects let namespaces = [spaceId]; const singleSpace = [ - { id: CASES.DEFAULT_SPACE_ONLY.id, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { id: CASES.DEFAULT_ONLY.id, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { id: CASES.SPACE_1_ONLY.id, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, { id: CASES.SPACE_2_ONLY.id, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces, ...fail404(spaceId === SPACE_2_ID) }, diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts index 75b35fecd5d83..cc5bb9cf8c739 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/copy_to_space.ts @@ -20,6 +20,7 @@ export default function copyToSpacesOnlySuite({ getService }: FtrProviderContext expectNoConflictsForNonExistentSpaceResult, createExpectWithConflictsOverwritingResult, createExpectWithConflictsWithoutOverwritingResult, + createMultiNamespaceTestCases, originSpaces, } = copyToSpaceTestSuiteFactory(es, esArchiver, supertestWithoutAuth); @@ -30,11 +31,11 @@ export default function copyToSpacesOnlySuite({ getService }: FtrProviderContext tests: { noConflictsWithoutReferences: { statusCode: 200, - response: expectNoConflictsWithoutReferencesResult, + response: expectNoConflictsWithoutReferencesResult(spaceId), }, noConflictsWithReferences: { statusCode: 200, - response: expectNoConflictsWithReferencesResult, + response: expectNoConflictsWithReferencesResult(spaceId), }, withConflictsOverwriting: { statusCode: 200, @@ -47,12 +48,13 @@ export default function copyToSpacesOnlySuite({ getService }: FtrProviderContext multipleSpaces: { statusCode: 200, withConflictsResponse: createExpectWithConflictsOverwritingResult(spaceId), - noConflictsResponse: expectNoConflictsWithReferencesResult, + noConflictsResponse: expectNoConflictsWithReferencesResult(spaceId), }, nonExistentSpace: { statusCode: 200, - response: expectNoConflictsForNonExistentSpaceResult, + response: expectNoConflictsForNonExistentSpaceResult(spaceId), }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId), }, }); }); diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts index 1e56a583eca1f..14c98aff262fe 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts @@ -38,6 +38,10 @@ export default function getAllSpacesTestSuite({ getService }: TestInvoker) { statusCode: 200, response: createExpectResults('default', 'space_1', 'space_2'), }, + shareSavedObjectsPurpose: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, }, }); }); diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts index ef2735de3d3db..5c84475d32850 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/resolve_copy_to_space_conflicts.ts @@ -19,6 +19,7 @@ export default function resolveCopyToSpaceConflictsTestSuite({ getService }: Ftr createExpectNonOverriddenResponseWithoutReferences, createExpectOverriddenResponseWithReferences, createExpectOverriddenResponseWithoutReferences, + createMultiNamespaceTestCases, NON_EXISTENT_SPACE_ID, originSpaces, } = resolveCopyToSpaceConflictsSuite(esArchiver, supertestWithAuth, supertestWithoutAuth); @@ -51,6 +52,7 @@ export default function resolveCopyToSpaceConflictsTestSuite({ getService }: Ftr NON_EXISTENT_SPACE_ID ), }, + multiNamespaceTestCases: createMultiNamespaceTestCases(spaceId), }, }); }); diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts index 5cdebf9edfcfd..25ba986a12fd8 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts @@ -27,7 +27,7 @@ const { fail404 } = testCaseFailures; const createSingleTestCases = (spaceId: string) => { const namespaces = ['some-space-id']; return [ - { ...CASES.DEFAULT_SPACE_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.DEFAULT_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, @@ -43,7 +43,7 @@ const createSingleTestCases = (spaceId: string) => { */ const createMultiTestCases = () => { const allSpaces = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; - let id = CASES.DEFAULT_SPACE_ONLY.id; + let id = CASES.DEFAULT_ONLY.id; const one = [{ id, namespaces: allSpaces }]; id = CASES.DEFAULT_AND_SPACE_1.id; const two = [{ id, namespaces: allSpaces }]; diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts index 8bcd294b38f3f..2c4506b723533 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts @@ -27,7 +27,7 @@ const { fail404 } = testCaseFailures; const createSingleTestCases = (spaceId: string) => { const namespaces = [spaceId]; return [ - { ...CASES.DEFAULT_SPACE_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.DEFAULT_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, @@ -43,7 +43,7 @@ const createSingleTestCases = (spaceId: string) => { */ const createMultiTestCases = () => { const nonExistentSpaceId = 'does_not_exist'; // space that doesn't exist - let id = CASES.DEFAULT_SPACE_ONLY.id; + let id = CASES.DEFAULT_ONLY.id; const one = [ { id, namespaces: [nonExistentSpaceId] }, { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID] }, From deb71ecbb7a0a7f0e6eb5159854a782a9aa89a65 Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Wed, 26 Aug 2020 17:13:38 -0400 Subject: [PATCH 37/56] [Security Solution][Exceptions Modal] Switches modal header (#76016) --- .../components/exceptions/add_exception_modal/translations.ts | 2 +- .../components/exceptions/edit_exception_modal/translations.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts index 3916284416707..2e9bced21fe71 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts @@ -13,7 +13,7 @@ export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.addExcep export const ADD_EXCEPTION = i18n.translate( 'xpack.securitySolution.exceptions.addException.addException', { - defaultMessage: 'Add Exception', + defaultMessage: 'Add Rule Exception', } ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts index 09e0a75d21573..1452003d8f8b8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts @@ -20,7 +20,7 @@ export const EDIT_EXCEPTION_SAVE_BUTTON = i18n.translate( export const EDIT_EXCEPTION_TITLE = i18n.translate( 'xpack.securitySolution.exceptions.editException.editExceptionTitle', { - defaultMessage: 'Edit Exception', + defaultMessage: 'Edit Rule Exception', } ); From fd39f094ccc3bf0aba39789961d31bedebe2cce1 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Wed, 26 Aug 2020 17:19:30 -0400 Subject: [PATCH 38/56] Duplicate title warning wording (#75908) Changed wording on duplicate title warning. --- .../save_modal/saved_object_save_modal.tsx | 17 ++++------------- .../translations/translations/ja-JP.json | 2 -- .../translations/translations/zh-CN.json | 2 -- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index 962f993633e6f..3b9efbee22ba6 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -281,8 +281,8 @@ export class SavedObjectSaveModal extends React.Component title={ } color="warning" @@ -292,18 +292,9 @@ export class SavedObjectSaveModal extends React.Component

- {this.props.confirmButtonLabel - ? this.props.confirmButtonLabel - : i18n.translate('savedObjects.saveModal.saveButtonLabel', { - defaultMessage: 'Save', - })} - - ), + title: this.props.title, }} />

diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 70e2b34d06ce6..d6e611e65154b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2880,8 +2880,6 @@ "savedObjects.saveDuplicateRejectedDescription": "重複ファイルの保存確認が拒否されました", "savedObjects.saveModal.cancelButtonLabel": "キャンセル", "savedObjects.saveModal.descriptionLabel": "説明", - "savedObjects.saveModal.duplicateTitleDescription": "{confirmSaveLabel} をクリックすると {objectType} がこの重複したタイトルで保存されます。", - "savedObjects.saveModal.duplicateTitleLabel": "「{title}」というタイトルの {objectType} が既に存在します", "savedObjects.saveModal.saveAsNewLabel": "新しい {objectType} として保存", "savedObjects.saveModal.saveButtonLabel": "保存", "savedObjects.saveModal.saveTitle": "{objectType} を保存", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e682a12859c47..54c69d849e3a9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2881,8 +2881,6 @@ "savedObjects.saveDuplicateRejectedDescription": "已拒绝使用重复标题保存确认", "savedObjects.saveModal.cancelButtonLabel": "取消", "savedObjects.saveModal.descriptionLabel": "描述", - "savedObjects.saveModal.duplicateTitleDescription": "单击“{confirmSaveLabel}”将会使用此重复标题保存 {objectType}。", - "savedObjects.saveModal.duplicateTitleLabel": "具有标题“{title}”的 {objectType} 已存在", "savedObjects.saveModal.saveAsNewLabel": "另存为新的 {objectType}", "savedObjects.saveModal.saveButtonLabel": "保存", "savedObjects.saveModal.saveTitle": "保存 {objectType}", From 35b8d50ccd412ab50e5b22726f4455000c5fa72b Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 26 Aug 2020 16:21:11 -0500 Subject: [PATCH 39/56] [Enterprise Search] Adds app logic file to Workplace Search (#76009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add new Workplace Search initial data properties * Add app logic * Refactor index to match App Search Adds the easier-to-read ComponentConfigured and ComponentUnconfigured FCs with a ternary in the root compoenent * Remove ‘Logic’ from interface names * Extract initial data from WS into interface This allows for breaking apart the app-specific data and also having an interface to extend in the app_logic file * Destructuring FTW --- .../common/__mocks__/initial_app_data.ts | 2 + .../enterprise_search/common/types/index.ts | 7 +- .../common/types/workplace_search.ts | 7 ++ .../workplace_search/app_logic.test.ts | 35 +++++++++ .../workplace_search/app_logic.ts | 32 ++++++++ .../workplace_search/index.test.tsx | 77 ++++++++++++++----- .../applications/workplace_search/index.tsx | 38 +++++---- .../lib/enterprise_search_config_api.test.ts | 4 + .../lib/enterprise_search_config_api.ts | 2 + 9 files changed, 165 insertions(+), 39 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts diff --git a/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts b/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts index 79e1efc425b4e..2d31be65dd30e 100644 --- a/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts +++ b/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts @@ -29,6 +29,8 @@ export const DEFAULT_INITIAL_APP_DATA = { }, }, workplaceSearch: { + canCreateInvitations: true, + isFederatedAuth: false, organization: { name: 'ACME Donuts', defaultOrgName: 'My Organization', diff --git a/x-pack/plugins/enterprise_search/common/types/index.ts b/x-pack/plugins/enterprise_search/common/types/index.ts index 52e468b741a07..008afb234a376 100644 --- a/x-pack/plugins/enterprise_search/common/types/index.ts +++ b/x-pack/plugins/enterprise_search/common/types/index.ts @@ -5,17 +5,14 @@ */ import { IAccount as IAppSearchAccount } from './app_search'; -import { IAccount as IWorkplaceSearchAccount, IOrganization } from './workplace_search'; +import { IWorkplaceSearchInitialData } from './workplace_search'; export interface IInitialAppData { readOnlyMode?: boolean; ilmEnabled?: boolean; configuredLimits?: IConfiguredLimits; appSearch?: IAppSearchAccount; - workplaceSearch?: { - organization: IOrganization; - fpAccount: IWorkplaceSearchAccount; - }; + workplaceSearch?: IWorkplaceSearchInitialData; } export interface IConfiguredLimits { diff --git a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts index fd8fa6daf81ac..bc4e39b0788d9 100644 --- a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts +++ b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts @@ -17,3 +17,10 @@ export interface IOrganization { name: string; defaultOrgName: string; } + +export interface IWorkplaceSearchInitialData { + canCreateInvitations: boolean; + isFederatedAuth: boolean; + organization: IOrganization; + fpAccount: IAccount; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts new file mode 100644 index 0000000000000..bc31b7df5d971 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.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 { resetContext } from 'kea'; + +import { DEFAULT_INITIAL_APP_DATA } from '../../../common/__mocks__'; +import { AppLogic } from './app_logic'; + +describe('AppLogic', () => { + beforeEach(() => { + resetContext({}); + AppLogic.mount(); + }); + + const DEFAULT_VALUES = { + hasInitialized: false, + }; + + it('has expected default values', () => { + expect(AppLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('initializeAppData()', () => { + it('sets values based on passed props', () => { + AppLogic.actions.initializeAppData(DEFAULT_INITIAL_APP_DATA); + + expect(AppLogic.values).toEqual({ + hasInitialized: true, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts new file mode 100644 index 0000000000000..b7116f02663c1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.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 { kea } from 'kea'; + +import { IInitialAppData } from '../../../common/types'; +import { IWorkplaceSearchInitialData } from '../../../common/types/workplace_search'; +import { IKeaLogic } from '../shared/types'; + +export interface IAppValues extends IWorkplaceSearchInitialData { + hasInitialized: boolean; +} +export interface IAppActions { + initializeAppData(props: IInitialAppData): void; +} + +export const AppLogic = kea({ + actions: (): IAppActions => ({ + initializeAppData: ({ workplaceSearch }) => workplaceSearch, + }), + reducers: () => ({ + hasInitialized: [ + false, + { + initializeAppData: () => true, + }, + ], + }), +}) as IKeaLogic; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index a0d9352ee9f82..39280ad6f4be4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -10,39 +10,76 @@ import '../__mocks__/kea.mock'; import React, { useContext } from 'react'; import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { useValues } from 'kea'; +import { useValues, useActions } from 'kea'; -import { Overview } from './views/overview'; +import { SetupGuide } from './views/setup_guide'; import { ErrorState } from './views/error_state'; +import { Overview } from './views/overview'; -import { WorkplaceSearch } from './'; +import { WorkplaceSearch, WorkplaceSearchUnconfigured, WorkplaceSearchConfigured } from './'; -describe('Workplace Search', () => { - it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { - (useContext as jest.Mock).mockImplementationOnce(() => ({ - config: { host: '' }, - })); +describe('WorkplaceSearch', () => { + it('renders WorkplaceSearchUnconfigured when config.host is not set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ config: { host: '' } })); const wrapper = shallow(); - expect(wrapper.find(Redirect)).toHaveLength(1); - expect(wrapper.find(Overview)).toHaveLength(0); + expect(wrapper.find(WorkplaceSearchUnconfigured)).toHaveLength(1); }); - it('renders the Overview when enterpriseSearchUrl is set', () => { - (useContext as jest.Mock).mockImplementationOnce(() => ({ - config: { host: 'https://foo.bar' }, - })); + it('renders WorkplaceSearchConfigured when config.host set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ config: { host: 'some.url' } })); const wrapper = shallow(); + expect(wrapper.find(WorkplaceSearchConfigured)).toHaveLength(1); + }); +}); + +describe('WorkplaceSearchUnconfigured', () => { + it('renders the Setup Guide and redirects to the Setup Guide', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + expect(wrapper.find(Redirect)).toHaveLength(1); + }); +}); + +describe('WorkplaceSearchConfigured', () => { + beforeEach(() => { + // Mock resets + (useValues as jest.Mock).mockImplementation(() => ({})); + (useActions as jest.Mock).mockImplementation(() => ({ initializeAppData: () => {} })); + }); + + it('renders with layout', () => { + const wrapper = shallow(); + expect(wrapper.find(Overview)).toHaveLength(1); - expect(wrapper.find(Redirect)).toHaveLength(0); }); - it('renders ErrorState when the app cannot connect to Enterprise Search', () => { - (useValues as jest.Mock).mockImplementationOnce(() => ({ errorConnecting: true })); - const wrapper = shallow(); + it('initializes app data with passed props', () => { + const initializeAppData = jest.fn(); + (useActions as jest.Mock).mockImplementation(() => ({ initializeAppData })); + + shallow(); + + expect(initializeAppData).toHaveBeenCalledWith({ readOnlyMode: true }); + }); + + it('does not re-initialize app data', () => { + const initializeAppData = jest.fn(); + (useActions as jest.Mock).mockImplementation(() => ({ initializeAppData })); + (useValues as jest.Mock).mockImplementation(() => ({ hasInitialized: true })); + + shallow(); + + expect(initializeAppData).not.toHaveBeenCalled(); + }); + + it('renders ErrorState', () => { + (useValues as jest.Mock).mockImplementation(() => ({ errorConnecting: true })); + + const wrapper = shallow(); - expect(wrapper.find(ErrorState).exists()).toBe(true); - expect(wrapper.find(Overview)).toHaveLength(0); + expect(wrapper.find(ErrorState)).toHaveLength(2); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 8582a003c6fa8..c0a51d5670a14 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import { Route, Redirect, Switch } from 'react-router-dom'; -import { useValues } from 'kea'; +import { useActions, useValues } from 'kea'; import { IInitialAppData } from '../../../common/types'; import { KibanaContext, IKibanaContext } from '../index'; import { HttpLogic, IHttpLogicValues } from '../shared/http'; +import { AppLogic, IAppActions, IAppValues } from './app_logic'; import { Layout } from '../shared/layout'; import { WorkplaceSearchNav } from './components/layout/nav'; @@ -20,21 +21,19 @@ import { SetupGuide } from './views/setup_guide'; import { ErrorState } from './views/error_state'; import { Overview } from './views/overview'; -export const WorkplaceSearch: React.FC = () => { +export const WorkplaceSearch: React.FC = (props) => { const { config } = useContext(KibanaContext) as IKibanaContext; + return !config.host ? : ; +}; + +export const WorkplaceSearchConfigured: React.FC = (props) => { + const { hasInitialized } = useValues(AppLogic) as IAppValues; + const { initializeAppData } = useActions(AppLogic) as IAppActions; const { errorConnecting } = useValues(HttpLogic) as IHttpLogicValues; - if (!config.host) - return ( - - - - - - - - - ); + useEffect(() => { + if (!hasInitialized) initializeAppData(props); + }, [hasInitialized]); return ( @@ -61,3 +60,14 @@ export const WorkplaceSearch: React.FC = () => { ); }; + +export const WorkplaceSearchUnconfigured: React.FC = () => ( + + + + + + + + +); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts index c26ada77f504f..323f79e63bc6f 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -47,6 +47,8 @@ describe('callEnterpriseSearchConfigAPI', () => { onboarding_complete: true, }, workplace_search: { + can_create_invitations: true, + is_federated_auth: false, organization: { name: 'ACME Donuts', default_org_name: 'My Organization', @@ -136,6 +138,8 @@ describe('callEnterpriseSearchConfigAPI', () => { }, }, workplaceSearch: { + canCreateInvitations: false, + isFederatedAuth: false, organization: { name: undefined, defaultOrgName: undefined, diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts index 1dbec76806ba8..c9cbec15169d9 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -90,6 +90,8 @@ export const callEnterpriseSearchConfigAPI = async ({ }, }, workplaceSearch: { + canCreateInvitations: !!data?.settings?.workplace_search?.can_create_invitations, + isFederatedAuth: !!data?.settings?.workplace_search?.is_federated_auth, organization: { name: data?.settings?.workplace_search?.organization?.name, defaultOrgName: data?.settings?.workplace_search?.organization?.default_org_name, From d2d7b0decfef5016a7996a284dd13565ac6b43cf Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Wed, 26 Aug 2020 23:33:15 +0200 Subject: [PATCH 40/56] Legacy ES plugin pre-removal cleanup (#75779) * delete integration tests * remove legacy version healthcheck / waitUntilReady * remove handleESError * remove createCluster * no longer depends on kibana plugin * fix kbn_server * remove deprecated comment and dead code * revert code removal, apparently was used (?) * Revert "revert code removal, apparently was used (?)" This reverts commit 69481850 --- .../integration_tests/index.test.ts | 16 ---- .../integration_tests/lib/servers.ts | 16 ---- .../core_plugins/elasticsearch/index.d.ts | 2 - .../core_plugins/elasticsearch/index.js | 33 +------ .../integration_tests/elasticsearch.test.ts | 89 ------------------- .../elasticsearch/lib/version_health_check.js | 39 -------- .../lib/version_health_check.test.js | 71 --------------- .../server/lib/__tests__/handle_es_error.js | 58 ------------ .../server/lib/handle_es_error.js | 50 ----------- src/test_utils/kbn_server.ts | 5 +- 10 files changed, 3 insertions(+), 376 deletions(-) delete mode 100644 src/legacy/core_plugins/elasticsearch/integration_tests/elasticsearch.test.ts delete mode 100644 src/legacy/core_plugins/elasticsearch/lib/version_health_check.js delete mode 100644 src/legacy/core_plugins/elasticsearch/lib/version_health_check.test.js delete mode 100644 src/legacy/core_plugins/elasticsearch/server/lib/__tests__/handle_es_error.js delete mode 100644 src/legacy/core_plugins/elasticsearch/server/lib/handle_es_error.js diff --git a/src/core/server/ui_settings/integration_tests/index.test.ts b/src/core/server/ui_settings/integration_tests/index.test.ts index e704532ee4cdf..7353f5d3eb760 100644 --- a/src/core/server/ui_settings/integration_tests/index.test.ts +++ b/src/core/server/ui_settings/integration_tests/index.test.ts @@ -24,22 +24,6 @@ import { docMissingSuite } from './doc_missing'; import { docMissingAndIndexReadOnlySuite } from './doc_missing_and_index_read_only'; describe('uiSettings/routes', function () { - /** - * The "doc missing" and "index missing" tests verify how the uiSettings - * API behaves in between healthChecks, so they interact with the healthCheck - * in somewhat weird ways (can't wait until we get to https://github.com/elastic/kibana/issues/14163) - * - * To make this work we have a `waitUntilNextHealthCheck()` function in ./lib/servers.js - * that deletes the kibana index and then calls `plugins.elasticsearch.waitUntilReady()`. - * - * waitUntilReady() waits for the kibana index to exist and then for the - * elasticsearch plugin to go green. Since we have verified that the kibana index - * does not exist we know that the plugin will also turn yellow while waiting for - * it and then green once the health check is complete, ensuring that we run our - * tests right after the health check. All of this is to say that the tests are - * stupidly fragile and timing sensitive. #14163 should fix that, but until then - * this is the most stable way I've been able to get this to work. - */ jest.setTimeout(10000); beforeAll(startServers); diff --git a/src/core/server/ui_settings/integration_tests/lib/servers.ts b/src/core/server/ui_settings/integration_tests/lib/servers.ts index ea462291059a5..b4cfc3c1efe8b 100644 --- a/src/core/server/ui_settings/integration_tests/lib/servers.ts +++ b/src/core/server/ui_settings/integration_tests/lib/servers.ts @@ -39,7 +39,6 @@ interface AllServices { savedObjectsClient: SavedObjectsClientContract; callCluster: LegacyAPICaller; uiSettings: IUiSettingsClient; - deleteKibanaIndex: typeof deleteKibanaIndex; } let services: AllServices; @@ -62,20 +61,6 @@ export async function startServers() { kbnServer = kbn.kbnServer; } -async function deleteKibanaIndex(callCluster: LegacyAPICaller) { - const kibanaIndices = await callCluster('cat.indices', { index: '.kibana*', format: 'json' }); - const indexNames = kibanaIndices.map((x: any) => x.index); - if (!indexNames.length) { - return; - } - await callCluster('indices.putSettings', { - index: indexNames, - body: { index: { blocks: { read_only: false } } }, - }); - await callCluster('indices.delete', { index: indexNames }); - return indexNames; -} - export function getServices() { if (services) { return services; @@ -97,7 +82,6 @@ export function getServices() { callCluster, savedObjectsClient, uiSettings, - deleteKibanaIndex, }; return services; diff --git a/src/legacy/core_plugins/elasticsearch/index.d.ts b/src/legacy/core_plugins/elasticsearch/index.d.ts index 683f58b1a80ce..83e7bb19e57ba 100644 --- a/src/legacy/core_plugins/elasticsearch/index.d.ts +++ b/src/legacy/core_plugins/elasticsearch/index.d.ts @@ -523,6 +523,4 @@ export interface CallCluster { export interface ElasticsearchPlugin { status: { on: (status: string, cb: () => void) => void }; getCluster(name: string): Cluster; - createCluster(name: string, config: ClusterConfig): Cluster; - waitUntilReady(): Promise; } diff --git a/src/legacy/core_plugins/elasticsearch/index.js b/src/legacy/core_plugins/elasticsearch/index.js index eb502e97fb77c..599886788604b 100644 --- a/src/legacy/core_plugins/elasticsearch/index.js +++ b/src/legacy/core_plugins/elasticsearch/index.js @@ -19,14 +19,12 @@ import { first } from 'rxjs/operators'; import { Cluster } from './server/lib/cluster'; import { createProxy } from './server/lib/create_proxy'; -import { handleESError } from './server/lib/handle_es_error'; -import { versionHealthCheck } from './lib/version_health_check'; export default function (kibana) { let defaultVars; return new kibana.Plugin({ - require: ['kibana'], + require: [], uiExports: { injectDefaultVars: () => defaultVars }, @@ -61,25 +59,6 @@ export default function (kibana) { return clusters.get(name); }); - server.expose('createCluster', (name, clientConfig = {}) => { - // NOTE: Not having `admin` and `data` clients provided by the core in `clusters` - // map implicitly allows to create custom `data` and `admin` clients. This is - // allowed intentionally to support custom `admin` cluster client created by the - // x-pack/monitoring bulk uploader. We should forbid that as soon as monitoring - // bulk uploader is refactored, see https://github.com/elastic/kibana/issues/31934. - if (clusters.has(name)) { - throw new Error(`cluster '${name}' already exists`); - } - - const cluster = new Cluster( - server.newPlatform.setup.core.elasticsearch.legacy.createClient(name, clientConfig) - ); - - clusters.set(name, cluster); - - return cluster; - }); - server.events.on('stop', () => { for (const cluster of clusters.values()) { cluster.close(); @@ -88,17 +67,7 @@ export default function (kibana) { clusters.clear(); }); - server.expose('handleESError', handleESError); - createProxy(server); - - const waitUntilHealthy = versionHealthCheck( - this, - server.logWithMetadata, - server.newPlatform.__internals.elasticsearch.esNodesCompatibility$ - ); - - server.expose('waitUntilReady', () => waitUntilHealthy); }, }); } diff --git a/src/legacy/core_plugins/elasticsearch/integration_tests/elasticsearch.test.ts b/src/legacy/core_plugins/elasticsearch/integration_tests/elasticsearch.test.ts deleted file mode 100644 index 0331153cdf615..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/integration_tests/elasticsearch.test.ts +++ /dev/null @@ -1,89 +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 { - createTestServers, - TestElasticsearchUtils, - TestKibanaUtils, - TestUtils, - createRootWithCorePlugins, - getKbnServer, -} from '../../../../test_utils/kbn_server'; - -import { BehaviorSubject } from 'rxjs'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { NodesVersionCompatibility } from 'src/core/server/elasticsearch/version_check/ensure_es_version'; - -describe('Elasticsearch plugin', () => { - let servers: TestUtils; - let esServer: TestElasticsearchUtils; - let root: TestKibanaUtils['root']; - let elasticsearch: TestKibanaUtils['kbnServer']['server']['plugins']['elasticsearch']; - - const esNodesCompatibility$ = new BehaviorSubject({ - isCompatible: true, - incompatibleNodes: [], - warningNodes: [], - kibanaVersion: '8.0.0', - }); - - beforeAll(async function () { - const settings = { - elasticsearch: {}, - adjustTimeout: (t: any) => { - jest.setTimeout(t); - }, - }; - servers = createTestServers(settings); - esServer = await servers.startES(); - - const elasticsearchSettings = { - hosts: esServer.hosts, - username: esServer.username, - password: esServer.password, - }; - root = createRootWithCorePlugins({ elasticsearch: elasticsearchSettings }); - - const setup = await root.setup(); - setup.elasticsearch.esNodesCompatibility$ = esNodesCompatibility$; - await root.start(); - - elasticsearch = getKbnServer(root).server.plugins.elasticsearch; - }); - - afterAll(async () => { - await esServer.stop(); - await root.shutdown(); - }, 30000); - - it("should set it's status to green when all nodes are compatible", (done) => { - jest.setTimeout(30000); - elasticsearch.status.on('green', () => done()); - }); - - it("should set it's status to red when some nodes aren't compatible", (done) => { - esNodesCompatibility$.next({ - isCompatible: false, - incompatibleNodes: [], - warningNodes: [], - kibanaVersion: '8.0.0', - }); - elasticsearch.status.on('red', () => done()); - }); -}); diff --git a/src/legacy/core_plugins/elasticsearch/lib/version_health_check.js b/src/legacy/core_plugins/elasticsearch/lib/version_health_check.js deleted file mode 100644 index b1a106d2aae5d..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/lib/version_health_check.js +++ /dev/null @@ -1,39 +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 const versionHealthCheck = (esPlugin, logWithMetadata, esNodesCompatibility$) => { - esPlugin.status.yellow('Waiting for Elasticsearch'); - - return new Promise((resolve) => { - esNodesCompatibility$.subscribe(({ isCompatible, message, kibanaVersion, warningNodes }) => { - if (!isCompatible) { - esPlugin.status.red(message); - } else { - if (message) { - logWithMetadata(['warning'], message, { - kibanaVersion, - nodes: warningNodes, - }); - } - esPlugin.status.green('Ready'); - resolve(); - } - }); - }); -}; diff --git a/src/legacy/core_plugins/elasticsearch/lib/version_health_check.test.js b/src/legacy/core_plugins/elasticsearch/lib/version_health_check.test.js deleted file mode 100644 index 4c03c0c0105ee..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/lib/version_health_check.test.js +++ /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 { versionHealthCheck } from './version_health_check'; -import { Subject } from 'rxjs'; - -describe('plugins/elasticsearch', () => { - describe('lib/health_version_check', function () { - let plugin; - let logWithMetadata; - - beforeEach(() => { - plugin = { - status: { - red: jest.fn(), - green: jest.fn(), - yellow: jest.fn(), - }, - }; - - logWithMetadata = jest.fn(); - jest.clearAllMocks(); - }); - - it('returned promise resolves when all nodes are compatible ', function () { - const esNodesCompatibility$ = new Subject(); - const versionHealthyPromise = versionHealthCheck( - plugin, - logWithMetadata, - esNodesCompatibility$ - ); - esNodesCompatibility$.next({ isCompatible: true, message: undefined }); - return expect(versionHealthyPromise).resolves.toBe(undefined); - }); - - it('should set elasticsearch plugin status to green when all nodes are compatible', function () { - const esNodesCompatibility$ = new Subject(); - versionHealthCheck(plugin, logWithMetadata, esNodesCompatibility$); - expect(plugin.status.yellow).toHaveBeenCalledWith('Waiting for Elasticsearch'); - expect(plugin.status.green).not.toHaveBeenCalled(); - esNodesCompatibility$.next({ isCompatible: true, message: undefined }); - expect(plugin.status.green).toHaveBeenCalledWith('Ready'); - expect(plugin.status.red).not.toHaveBeenCalled(); - }); - - it('should set elasticsearch plugin status to red when some nodes are incompatible', function () { - const esNodesCompatibility$ = new Subject(); - versionHealthCheck(plugin, logWithMetadata, esNodesCompatibility$); - expect(plugin.status.yellow).toHaveBeenCalledWith('Waiting for Elasticsearch'); - expect(plugin.status.red).not.toHaveBeenCalled(); - esNodesCompatibility$.next({ isCompatible: false, message: 'your nodes are incompatible' }); - expect(plugin.status.red).toHaveBeenCalledWith('your nodes are incompatible'); - expect(plugin.status.green).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/legacy/core_plugins/elasticsearch/server/lib/__tests__/handle_es_error.js b/src/legacy/core_plugins/elasticsearch/server/lib/__tests__/handle_es_error.js deleted file mode 100644 index ccab1a3b830b6..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/server/lib/__tests__/handle_es_error.js +++ /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 expect from '@kbn/expect'; -import { handleESError } from '../handle_es_error'; -import { errors as esErrors } from 'elasticsearch'; - -describe('handleESError', function () { - it('should transform elasticsearch errors into boom errors with the same status code', function () { - const conflict = handleESError(new esErrors.Conflict()); - expect(conflict.isBoom).to.be(true); - expect(conflict.output.statusCode).to.be(409); - - const forbidden = handleESError(new esErrors[403]()); - expect(forbidden.isBoom).to.be(true); - expect(forbidden.output.statusCode).to.be(403); - - const notFound = handleESError(new esErrors.NotFound()); - expect(notFound.isBoom).to.be(true); - expect(notFound.output.statusCode).to.be(404); - - const badRequest = handleESError(new esErrors.BadRequest()); - expect(badRequest.isBoom).to.be(true); - expect(badRequest.output.statusCode).to.be(400); - }); - - it('should return an unknown error without transforming it', function () { - const unknown = new Error('mystery error'); - expect(handleESError(unknown)).to.be(unknown); - }); - - it('should return a boom 503 server timeout error for ES connection errors', function () { - expect(handleESError(new esErrors.ConnectionFault()).output.statusCode).to.be(503); - expect(handleESError(new esErrors.ServiceUnavailable()).output.statusCode).to.be(503); - expect(handleESError(new esErrors.NoConnections()).output.statusCode).to.be(503); - expect(handleESError(new esErrors.RequestTimeout()).output.statusCode).to.be(503); - }); - - it('should throw an error if called with a non-error argument', function () { - expect(handleESError).withArgs('notAnError').to.throwException(); - }); -}); diff --git a/src/legacy/core_plugins/elasticsearch/server/lib/handle_es_error.js b/src/legacy/core_plugins/elasticsearch/server/lib/handle_es_error.js deleted file mode 100644 index d76b2a2aa9364..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/server/lib/handle_es_error.js +++ /dev/null @@ -1,50 +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 Boom from 'boom'; -import _ from 'lodash'; -import { errors as esErrors } from 'elasticsearch'; - -export function handleESError(error) { - if (!(error instanceof Error)) { - throw new Error('Expected an instance of Error'); - } - - if ( - error instanceof esErrors.ConnectionFault || - error instanceof esErrors.ServiceUnavailable || - error instanceof esErrors.NoConnections || - error instanceof esErrors.RequestTimeout - ) { - return Boom.serverUnavailable(error); - } else if ( - error instanceof esErrors.Conflict || - _.includes(error.message, 'index_template_already_exists') - ) { - return Boom.conflict(error); - } else if (error instanceof esErrors[403]) { - return Boom.forbidden(error); - } else if (error instanceof esErrors.NotFound) { - return Boom.notFound(error); - } else if (error instanceof esErrors.BadRequest) { - return Boom.badRequest(error); - } else { - return error; - } -} diff --git a/src/test_utils/kbn_server.ts b/src/test_utils/kbn_server.ts index e337a469f17e6..e44ce0de403d9 100644 --- a/src/test_utils/kbn_server.ts +++ b/src/test_utils/kbn_server.ts @@ -32,10 +32,10 @@ import { defaultsDeep, get } from 'lodash'; import { resolve } from 'path'; import { BehaviorSubject } from 'rxjs'; import supertest from 'supertest'; +import { LegacyAPICaller } from '../core/server'; import { CliArgs, Env } from '../core/server/config'; import { Root } from '../core/server/root'; import KbnServer from '../legacy/server/kbn_server'; -import { CallCluster } from '../legacy/core_plugins/elasticsearch'; export type HttpMethod = 'delete' | 'get' | 'head' | 'post' | 'put'; @@ -156,7 +156,7 @@ export interface TestElasticsearchServer { stop: () => Promise; cleanup: () => Promise; getClient: () => Client; - getCallCluster: () => CallCluster; + getCallCluster: () => LegacyAPICaller; getUrl: () => string; } @@ -292,7 +292,6 @@ export function createTestServers({ await root.start(); const kbnServer = getKbnServer(root); - await kbnServer.server.plugins.elasticsearch.waitUntilReady(); return { root, From 595dfdb023d472c9f0bbecdb4201947b76435f09 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Wed, 26 Aug 2020 14:37:55 -0700 Subject: [PATCH 41/56] Disables Chromedriver version detection (#75984) Signed-off-by: Tyler Smalley --- src/dev/ci_setup/setup.sh | 6 ++++++ src/dev/ci_setup/setup_env.sh | 14 +++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index aabc1e75b9025..3351170c29e01 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -16,6 +16,12 @@ echo " -- TEST_ES_SNAPSHOT_VERSION='$TEST_ES_SNAPSHOT_VERSION'" echo " -- installing node.js dependencies" yarn kbn bootstrap --prefer-offline +### +### ensure Chromedriver install hook is triggered +### when modules are up-to-date +### +node node_modules/chromedriver/install.js + ### ### Download es snapshots ### diff --git a/src/dev/ci_setup/setup_env.sh b/src/dev/ci_setup/setup_env.sh index 72ec73ad810e6..5757d72f99582 100644 --- a/src/dev/ci_setup/setup_env.sh +++ b/src/dev/ci_setup/setup_env.sh @@ -134,13 +134,13 @@ export CYPRESS_DOWNLOAD_MIRROR="https://us-central1-elastic-kibana-184716.cloudf export CHECKS_REPORTER_ACTIVE=false # This is mainly for release-manager builds, which run in an environment that doesn't have Chrome installed -if [[ "$(which google-chrome-stable)" || "$(which google-chrome)" ]]; then - echo "Chrome detected, setting DETECT_CHROMEDRIVER_VERSION=true" - export DETECT_CHROMEDRIVER_VERSION=true - export CHROMEDRIVER_FORCE_DOWNLOAD=true -else - echo "Chrome not detected, installing default chromedriver binary for the package version" -fi +# if [[ "$(which google-chrome-stable)" || "$(which google-chrome)" ]]; then +# echo "Chrome detected, setting DETECT_CHROMEDRIVER_VERSION=true" +# export DETECT_CHROMEDRIVER_VERSION=true +# export CHROMEDRIVER_FORCE_DOWNLOAD=true +# else +# echo "Chrome not detected, installing default chromedriver binary for the package version" +# fi ### only run on pr jobs for elastic/kibana, checks-reporter doesn't work for other repos if [[ "$ghprbPullId" && "$ghprbGhRepository" == 'elastic/kibana' ]] ; then From 979d1dbca801839d0f896599665c564639b3a973 Mon Sep 17 00:00:00 2001 From: "Devin W. Hurley" Date: Wed, 26 Aug 2020 18:18:39 -0400 Subject: [PATCH 42/56] [Security Solution] [Detections] Updates rules routes to validate "from" param on rules (#76000) * updates validation on 'from' param to prevent malformed datemath strings from being accepted * fix imports * copy paste is not my friend * missed type check somehow * forgot to mock common utils * updates bodies for request validation tests --- .../schemas/common/schemas.ts | 17 ++++++++- .../schemas/types/default_from_string.ts | 10 +++-- .../common/detection_engine/utils.ts | 15 ++++++++ .../rules_notification_alert_type.ts | 2 +- .../rules/create_rules_bulk_route.test.ts | 27 ++++++++++++++ .../routes/rules/create_rules_route.test.ts | 25 +++++++++++++ .../rules/patch_rules_bulk_route.test.ts | 27 ++++++++++++++ .../routes/rules/patch_rules_route.test.ts | 33 +++++++++++++++-- .../rules/update_rules_bulk_route.test.ts | 28 ++++++++++++++ .../routes/rules/update_rules_route.test.ts | 34 +++++++++++++++-- .../signals/signal_rule_alert_type.test.ts | 3 +- .../signals/signal_rule_alert_type.ts | 2 +- .../detection_engine/signals/utils.test.ts | 2 +- .../lib/detection_engine/signals/utils.ts | 14 +------ .../basic/tests/import_rules.ts | 37 +++++++++++++++++++ 15 files changed, 246 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index 2a0d1ef8b9dfd..64f2f223a3073 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -7,11 +7,14 @@ /* eslint-disable @typescript-eslint/naming-convention */ import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + import { RiskScore } from '../types/risk_score'; import { UUID } from '../types/uuid'; import { IsoDateString } from '../types/iso_date_string'; import { PositiveIntegerGreaterThanZero } from '../types/positive_integer_greater_than_zero'; import { PositiveInteger } from '../types/positive_integer'; +import { parseScheduleDates } from '../../utils'; export const author = t.array(t.string); export type Author = t.TypeOf; @@ -76,8 +79,18 @@ export const action = t.exact( export const actions = t.array(action); export type Actions = t.TypeOf; -// TODO: Create a regular expression type or custom date math part type here -export const from = t.string; +const stringValidator = (input: unknown): input is string => typeof input === 'string'; +export const from = new t.Type( + 'From', + t.string.is, + (input, context): Either => { + if (stringValidator(input) && parseScheduleDates(input) == null) { + return t.failure(input, context, 'Failed to parse "from" on rule param'); + } + return t.string.validate(input, context); + }, + t.identity +); export type From = t.TypeOf; export const fromOrUndefined = t.union([from, t.undefined]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_from_string.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_from_string.ts index a85ea58b26478..5b1c837db9f74 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_from_string.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_from_string.ts @@ -6,7 +6,7 @@ import * as t from 'io-ts'; import { Either } from 'fp-ts/lib/Either'; - +import { from } from '../common/schemas'; /** * Types the DefaultFromString as: * - If null or undefined, then a default of the string "now-6m" will be used @@ -14,7 +14,11 @@ import { Either } from 'fp-ts/lib/Either'; export const DefaultFromString = new t.Type( 'DefaultFromString', t.string.is, - (input, context): Either => - input == null ? t.success('now-6m') : t.string.validate(input, context), + (input, context): Either => { + if (input == null) { + return t.success('now-6m'); + } + return from.validate(input, context); + }, t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts index 153130fc16d60..a70258c2684b6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import moment from 'moment'; +import dateMath from '@elastic/datemath'; + import { EntriesArray } from '../shared_imports'; import { RuleType } from './types'; @@ -18,3 +21,15 @@ export const hasNestedEntry = (entries: EntriesArray): boolean => { }; export const isThresholdRule = (ruleType: RuleType) => ruleType === 'threshold'; + +export const parseScheduleDates = (time: string): moment.Moment | null => { + const isValidDateString = !isNaN(Date.parse(time)); + const isValidInput = isValidDateString || time.trim().startsWith('now'); + const formattedDate = isValidDateString + ? moment(time) + : isValidInput + ? dateMath.parse(time) + : null; + + return formattedDate ?? null; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts index 2eb34529d044c..0a899562d61c2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts @@ -14,7 +14,7 @@ import { RuleAlertAttributes } from '../signals/types'; import { siemRuleActionGroups } from '../signals/siem_rule_action_groups'; import { scheduleNotificationActions } from './schedule_notification_actions'; import { getNotificationResultsLink } from './utils'; -import { parseScheduleDates } from '../signals/utils'; +import { parseScheduleDates } from '../../../../common/detection_engine/utils'; export const rulesNotificationAlertType = ({ logger, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index 4636618cc5ac0..06fcba36642ca 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -161,6 +161,17 @@ describe('create_rules_bulk', () => { expect(result.ok).toHaveBeenCalled(); }); + test('allows rule type of query and custom from and interval', async () => { + const request = requestMock.create({ + method: 'post', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`, + body: [{ from: 'now-7m', interval: '5m', ...getCreateRulesSchemaMock() }], + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + test('disallows unknown rule type', async () => { const request = requestMock.create({ method: 'post', @@ -173,5 +184,21 @@ describe('create_rules_bulk', () => { 'Invalid value "unexpected_type" supplied to "type"' ); }); + + test('disallows invalid "from" param on rule', async () => { + const request = requestMock.create({ + method: 'post', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`, + body: [ + { + from: 'now-3755555555555555.67s', + interval: '5m', + ...getCreateRulesSchemaMock(), + }, + ], + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Failed to parse "from" on rule param'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 59c64fbf8fce1..26febb0999ac7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -164,5 +164,30 @@ describe('create_rules', () => { 'Invalid value "unexpected_type" supplied to "type"' ); }); + + test('allows rule type of query and custom from and interval', async () => { + const request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: { from: 'now-7m', interval: '5m', ...getCreateRulesSchemaMock() }, + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + + test('disallows invalid "from" param on rule', async () => { + const request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: { + from: 'now-3755555555555555.67s', + interval: '5m', + ...getCreateRulesSchemaMock(), + }, + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Failed to parse "from" on rule param'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index db32f7f4485b1..c162caa1278e6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -183,5 +183,32 @@ describe('patch_rules_bulk', () => { 'Invalid value "unknown_type" supplied to "type"' ); }); + + test('allows rule type of query and custom from and interval', async () => { + const request = requestMock.create({ + method: 'patch', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [{ from: 'now-7m', interval: '5m', ...getCreateRulesSchemaMock() }], + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + + test('disallows invalid "from" param on rule', async () => { + const request = requestMock.create({ + method: 'patch', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [ + { + from: 'now-3755555555555555.67s', + interval: '5m', + ...getCreateRulesSchemaMock(), + }, + ], + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Failed to parse "from" on rule param'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index d3350bcb0d762..a406de593652b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -18,7 +18,7 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { patchRulesRoute } from './patch_rules_route'; -import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/create_rules_schema.mock'; +import { getPatchRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/patch_rules_schema.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -156,7 +156,7 @@ describe('patch_rules', () => { const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_URL, - body: { ...getCreateRulesSchemaMock(), rule_id: undefined }, + body: { ...getPatchRulesSchemaMock(), rule_id: undefined }, }); const response = await server.inject(request, context); expect(response.body).toEqual({ @@ -169,7 +169,7 @@ describe('patch_rules', () => { const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_URL, - body: { ...getCreateRulesSchemaMock(), type: 'query' }, + body: { ...getPatchRulesSchemaMock(), type: 'query' }, }); const result = server.validate(request); @@ -180,7 +180,7 @@ describe('patch_rules', () => { const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_URL, - body: { ...getCreateRulesSchemaMock(), type: 'unknown_type' }, + body: { ...getPatchRulesSchemaMock(), type: 'unknown_type' }, }); const result = server.validate(request); @@ -188,5 +188,30 @@ describe('patch_rules', () => { 'Invalid value "unknown_type" supplied to "type"' ); }); + + test('allows rule type of query and custom from and interval', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_URL, + body: { from: 'now-7m', interval: '5m', ...getPatchRulesSchemaMock() }, + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + + test('disallows invalid "from" param on rule', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_URL, + body: { + from: 'now-3755555555555555.67s', + interval: '5m', + ...getPatchRulesSchemaMock(), + }, + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Failed to parse "from" on rule param'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index 9c5df89a52bed..ec5a2be255a2c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -154,5 +154,33 @@ describe('update_rules_bulk', () => { 'Invalid value "unknown_type" supplied to "type"' ); }); + + test('allows rule type of query and custom from and interval', async () => { + const request = requestMock.create({ + method: 'put', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [{ from: 'now-7m', interval: '5m', ...getCreateRulesSchemaMock(), type: 'query' }], + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + + test('disallows invalid "from" param on rule', async () => { + const request = requestMock.create({ + method: 'put', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [ + { + from: 'now-3755555555555555.67s', + interval: '5m', + ...getCreateRulesSchemaMock(), + type: 'query', + }, + ], + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Failed to parse "from" on rule param'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index 46fe773e1a88d..fd077c18b7983 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -19,7 +19,7 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { updateRulesRoute } from './update_rules_route'; -import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/create_rules_schema.mock'; +import { getUpdateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/update_rules_schema.mock'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); jest.mock('../../rules/update_rules_notifications'); @@ -130,7 +130,7 @@ describe('update_rules', () => { method: 'put', path: DETECTION_ENGINE_RULES_URL, body: { - ...getCreateRulesSchemaMock(), + ...getUpdateRulesSchemaMock(), rule_id: undefined, }, }); @@ -145,7 +145,7 @@ describe('update_rules', () => { const request = requestMock.create({ method: 'put', path: DETECTION_ENGINE_RULES_URL, - body: { ...getCreateRulesSchemaMock(), type: 'query' }, + body: { ...getUpdateRulesSchemaMock(), type: 'query' }, }); const result = await server.validate(request); @@ -156,7 +156,7 @@ describe('update_rules', () => { const request = requestMock.create({ method: 'put', path: DETECTION_ENGINE_RULES_URL, - body: { ...getCreateRulesSchemaMock(), type: 'unknown type' }, + body: { ...getUpdateRulesSchemaMock(), type: 'unknown type' }, }); const result = await server.validate(request); @@ -164,5 +164,31 @@ describe('update_rules', () => { 'Invalid value "unknown type" supplied to "type"' ); }); + + test('allows rule type of query and custom from and interval', async () => { + const request = requestMock.create({ + method: 'put', + path: DETECTION_ENGINE_RULES_URL, + body: { from: 'now-7m', interval: '5m', ...getUpdateRulesSchemaMock(), type: 'query' }, + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + + test('disallows invalid "from" param on rule', async () => { + const request = requestMock.create({ + method: 'put', + path: DETECTION_ENGINE_RULES_URL, + body: { + from: 'now-3755555555555555.67s', + interval: '5m', + ...getUpdateRulesSchemaMock(), + type: 'query', + }, + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith('Failed to parse "from" on rule param'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index b29d15f5e5c72..a7213c30eb3fb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -16,8 +16,8 @@ import { getListsClient, getExceptions, sortExceptionItems, - parseScheduleDates, } from './utils'; +import { parseScheduleDates } from '../../../../common/detection_engine/utils'; import { RuleExecutorOptions } from './types'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { scheduleNotificationActions } from '../notifications/schedule_notification_actions'; @@ -37,6 +37,7 @@ jest.mock('./utils'); jest.mock('../notifications/schedule_notification_actions'); jest.mock('./find_ml_signals'); jest.mock('./bulk_create_ml_signals'); +jest.mock('./../../../../common/detection_engine/utils'); const getPayload = (ruleAlert: RuleAlertType, services: AlertServicesMock) => ({ alertId: ruleAlert.id, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index b5cbf80b084f7..c5124edcaf187 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -14,6 +14,7 @@ import { SERVER_APP_ID, } from '../../../../common/constants'; import { isJobStarted, isMlRule } from '../../../../common/machine_learning/helpers'; +import { parseScheduleDates } from '../../../../common/detection_engine/utils'; import { SetupPlugins } from '../../../plugin'; import { getInputIndex } from './get_input_output_index'; import { @@ -24,7 +25,6 @@ import { getFilter } from './get_filter'; import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types'; import { getGapBetweenRuns, - parseScheduleDates, getListsClient, getExceptions, getGapMaxCatchupRatio, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index 3c41f29625a51..a2e2fec3309c3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -13,11 +13,11 @@ import { buildRuleMessageFactory } from './rule_messages'; import { ExceptionListClient } from '../../../../../lists/server'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { parseScheduleDates } from '../../../../common/detection_engine/utils'; import { generateId, parseInterval, - parseScheduleDates, getDriftTolerance, getGapBetweenRuns, getGapMaxCatchupRatio, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 9519720d0bbec..92cc9be69839f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -14,7 +14,7 @@ import { ExceptionListItemSchema } from '../../../../../lists/common/schemas'; import { ListArrayOrUndefined } from '../../../../common/detection_engine/schemas/types/lists'; import { BulkResponse, BulkResponseErrorAggregation, isValidUnit } from './types'; import { BuildRuleMessage } from './rule_messages'; -import { hasLargeValueList } from '../../../../common/detection_engine/utils'; +import { hasLargeValueList, parseScheduleDates } from '../../../../common/detection_engine/utils'; import { MAX_EXCEPTION_LIST_SIZE } from '../../../../../lists/common/constants'; interface SortExceptionsReturn { @@ -220,18 +220,6 @@ export const parseInterval = (intervalString: string): moment.Duration | null => } }; -export const parseScheduleDates = (time: string): moment.Moment | null => { - const isValidDateString = !isNaN(Date.parse(time)); - const isValidInput = isValidDateString || time.trim().startsWith('now'); - const formattedDate = isValidDateString - ? moment(time) - : isValidInput - ? dateMath.parse(time) - : null; - - return formattedDate ?? null; -}; - export const getDriftTolerance = ({ from, to, diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts index e0b60ae1fbeeb..108ca365bc14f 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts @@ -141,6 +141,43 @@ export default ({ getService }: FtrProviderContext): void => { expect(bodyToCompare).to.eql(getSimpleRuleOutput('rule-1')); }); + it('should fail validation when importing a rule with malformed "from" params on the rules', async () => { + const stringifiedRule = JSON.stringify({ + from: 'now-3755555555555555.67s', + interval: '5m', + ...getSimpleRule('rule-1'), + }); + const fileNdJson = Buffer.from(stringifiedRule + '\n'); + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', fileNdJson, 'rules.ndjson') + .expect(200); + + expect(body.errors[0].error.message).to.eql('Failed to parse "from" on rule param'); + }); + + it('should fail validation when importing two rules and one has a malformed "from" params', async () => { + const stringifiedRule = JSON.stringify({ + from: 'now-3755555555555555.67s', + interval: '5m', + ...getSimpleRule('rule-1'), + }); + const stringifiedRule2 = JSON.stringify({ + ...getSimpleRule('rule-2'), + }); + const fileNdJson = Buffer.from([stringifiedRule, stringifiedRule2].join('\n')); + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', fileNdJson, 'rules.ndjson') + .expect(200); + + // should result in one success and a failure message + expect(body.success_count).to.eql(1); + expect(body.errors[0].error.message).to.eql('Failed to parse "from" on rule param'); + }); + it('should be able to import two rules', async () => { const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) From 8364d8d67acb3d905a08e020eb1f906d82cd1a0c Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 26 Aug 2020 18:27:40 -0400 Subject: [PATCH 43/56] [Lens] Decouple visualizations from specific operations (#75703) * [Lens] Decouple visualizations from specific operations * Remove unused mock --- .../expression.test.tsx | 52 +++++++++++++++++++ .../datatable_visualization/expression.tsx | 5 +- .../pie_visualization/suggestions.test.ts | 36 ++++++++++++- .../public/pie_visualization/suggestions.ts | 2 +- x-pack/plugins/lens/public/types.ts | 5 ++ .../xy_visualization/xy_suggestions.test.ts | 39 ++++++++++++++ .../public/xy_visualization/xy_suggestions.ts | 10 ++-- 7 files changed, 139 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx index ac43593213687..b9bdea5522f32 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -144,6 +144,58 @@ describe('datatable_expression', () => { }); }); + test('it invokes executeTriggerActions with correct context on click on timefield from range', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + l1: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a', meta: { type: 'date_range', aggConfigParams: { field: 'a' } } }, + { id: 'b', name: 'b', meta: { type: 'count' } }, + ], + rows: [{ a: 1588024800000, b: 3 }], + }, + }, + }; + + const args: DatatableProps['args'] = { + title: '', + columns: { columnIds: ['a', 'b'], type: 'lens_datatable_columns' }, + }; + + const wrapper = mountWithIntl( + x as IFieldFormat} + onClickValue={onClickValue} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + /> + ); + + wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(1).simulate('click'); + + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: data.tables.l1, + value: 1588024800000, + }, + ], + negate: false, + timeFieldName: 'a', + }); + }); + test('it shows emptyPlaceholder for undefined bucketed data', () => { const { args, data } = sampleArgs(); const emptyData: LensMultiTable = { diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 02186ecf09b4b..87ac2d1710b19 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -164,9 +164,8 @@ export function DatatableComponent(props: DatatableRenderProps) { const handleFilterClick = useMemo( () => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { const col = firstTable.columns[colIndex]; - const isDateHistogram = col.meta?.type === 'date_histogram'; - const timeFieldName = - negate && isDateHistogram ? undefined : col?.meta?.aggConfigParams?.field; + const isDate = col.meta?.type === 'date_histogram' || col.meta?.type === 'date_range'; + const timeFieldName = negate && isDate ? undefined : col?.meta?.aggConfigParams?.field; const rowIndex = firstTable.rows.findIndex((row) => row[field] === value); const data: LensFilterEvent['data'] = { diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index 20b267caa9074..b8b43c3ed248b 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -90,7 +90,41 @@ describe('suggestions', () => { columns: [ { columnId: 'b', - operation: { label: 'Days', dataType: 'date' as DataType, isBucketed: true }, + operation: { + label: 'Days', + dataType: 'date' as DataType, + isBucketed: true, + scale: 'interval', + }, + }, + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject any histogram operations', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'b', + operation: { + label: 'Durations', + dataType: 'number' as DataType, + isBucketed: true, + scale: 'interval', + }, }, { columnId: 'c', diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts index 5d85ac3bbd56a..067b0bb4906df 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -15,7 +15,7 @@ function shouldReject({ table, keptLayerIds }: SuggestionRequest 1 || (keptLayerIds.length && table.layerId !== keptLayerIds[0]) || table.changeType === 'reorder' || - table.columns.some((col) => col.operation.dataType === 'date') + table.columns.some((col) => col.operation.scale === 'interval') // Histograms are not good for pie ); } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 20f2ce6c56774..729daed7223fe 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -259,6 +259,11 @@ export interface OperationMetadata { // A bucketed operation is grouped by duplicate values, otherwise each row is // treated as unique isBucketed: boolean; + /** + * ordinal: Each name is a unique value, but the names are in sorted order, like "Top values" + * interval: Histogram data, like date or number histograms + * ratio: Most number data is rendered as a ratio that includes 0 + */ scale?: 'ordinal' | 'interval' | 'ratio'; // Extra meta-information like cardinality, color // TODO currently it's not possible to differentiate between a field from a raw diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index 632f6fc8861a4..79e4ed6958193 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -54,6 +54,18 @@ describe('xy_suggestions', () => { }; } + function histogramCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'number', + isBucketed: true, + label: `${columnId} histogram`, + scale: 'interval', + }, + }; + } + // Helper that plucks out the important part of a suggestion for // most test assertions function suggestionSubset(suggestion: VisualizationSuggestion) { @@ -274,6 +286,33 @@ describe('xy_suggestions', () => { `); }); + test('suggests all basic x y chart with histogram on x', () => { + (generateId as jest.Mock).mockReturnValueOnce('aaa'); + const [suggestion, ...rest] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('bytes'), histogramCol('duration')], + layerId: 'first', + changeType: 'unchanged', + }, + keptLayerIds: [], + }); + + expect(rest).toHaveLength(visualizationTypes.length - 1); + expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` + Array [ + Object { + "seriesType": "bar_stacked", + "splitAccessor": undefined, + "x": "duration", + "y": Array [ + "bytes", + ], + }, + ] + `); + }); + test('does not suggest multiple splits', () => { const suggestions = getSuggestions({ table: { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 387d56c03e31a..75dd5a7a579b8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -112,13 +112,13 @@ function getBucketMappings(table: TableSuggestion, currentState?: State) { const currentXColumnIndex = prioritizedBuckets.findIndex( ({ columnId }) => columnId === currentLayer.xAccessor ); - const currentXDataType = - currentXColumnIndex > -1 && prioritizedBuckets[currentXColumnIndex].operation.dataType; + const currentXScaleType = + currentXColumnIndex > -1 && prioritizedBuckets[currentXColumnIndex].operation.scale; if ( - currentXDataType && - // make sure time gets mapped to x dimension even when changing current bucket/dimension mapping - (currentXDataType === 'date' || prioritizedBuckets[0].operation.dataType !== 'date') + currentXScaleType && + // make sure histograms get mapped to x dimension even when changing current bucket/dimension mapping + (currentXScaleType === 'interval' || prioritizedBuckets[0].operation.scale !== 'interval') ) { const [x] = prioritizedBuckets.splice(currentXColumnIndex, 1); prioritizedBuckets.unshift(x); From 043382d686d8c7cd1d5bc5918d813e0654334b77 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 26 Aug 2020 18:46:15 -0400 Subject: [PATCH 44/56] [Security Solution][Exceptions] - Improve UX for missing exception list associated with rule (#76012) ## Summary **Current behavior:** - **Scenario 1:** User is in the exceptions viewer flow, they select to edit an exception item, but the list the item is associated with has since been deleted (let's say by another user) - a user is able to open modal to edit exception item and on save, an error toaster shows but no information is given to the user to indicate the issue. - **Scenario 2:** User exports rules from space 'X' and imports into space 'Y'. The exception lists associated with their newly imported rules do not exist in space 'Y' - a user goes to add an exception item and gets a modal with an error, unable to add any exceptions. - **Workaround:** current workaround exists only via API - user would need to remove the exception list from their rule via API **New behavior:** - **Scenario 1:** User is still able to oped edit modal, but on save they see an error explaining that the associated exception list does not exist and prompts them to remove the exception list --> now they're able to add exceptions to their rule - **Scenario 2:** User navigates to exceptions after importing their rule, tries to add exception, modal pops up with error informing them that they need to remove association to missing exception list, button prompts them to do so --> now can continue adding exceptions to rule --- .../exceptions/add_exception_modal/index.tsx | 90 +++++++--- .../edit_exception_modal/index.test.tsx | 5 + .../exceptions/edit_exception_modal/index.tsx | 91 +++++++--- .../exceptions/error_callout.test.tsx | 160 +++++++++++++++++ .../components/exceptions/error_callout.tsx | 169 ++++++++++++++++++ .../components/exceptions/translations.ts | 49 +++++ .../exceptions/use_add_exception.test.tsx | 44 +++++ .../exceptions/use_add_exception.tsx | 8 +- ...tch_or_create_rule_exception_list.test.tsx | 2 +- ...se_fetch_or_create_rule_exception_list.tsx | 8 +- .../components/exceptions/viewer/index.tsx | 2 + .../use_dissasociate_exception_list.test.tsx | 52 ++++++ .../rules/use_dissasociate_exception_list.tsx | 80 +++++++++ 13 files changed, 706 insertions(+), 54 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 03051ead357c9..21f82c6ab4c98 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -18,7 +18,6 @@ import { EuiCheckbox, EuiSpacer, EuiFormRow, - EuiCallOut, EuiText, } from '@elastic/eui'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; @@ -28,6 +27,7 @@ import { ExceptionListType, } from '../../../../../public/lists_plugin_deps'; import * as i18n from './translations'; +import * as sharedI18n from '../translations'; import { TimelineNonEcsData, Ecs } from '../../../../graphql/types'; import { useAppToasts } from '../../../hooks/use_app_toasts'; import { useKibana } from '../../../lib/kibana'; @@ -35,6 +35,7 @@ import { ExceptionBuilderComponent } from '../builder'; import { Loader } from '../../loader'; import { useAddOrUpdateException } from '../use_add_exception'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; +import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; import { useFetchOrCreateRuleExceptionList } from '../use_fetch_or_create_rule_exception_list'; import { AddExceptionComments } from '../add_exception_comments'; import { @@ -46,6 +47,7 @@ import { entryHasNonEcsType, getMappedNonEcsValue, } from '../helpers'; +import { ErrorInfo, ErrorCallout } from '../error_callout'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; export interface AddExceptionModalBaseProps { @@ -107,13 +109,14 @@ export const AddExceptionModal = memo(function AddExceptionModal({ }: AddExceptionModalProps) { const { http } = useKibana().services; const [comment, setComment] = useState(''); + const { rule: maybeRule } = useRuleAsync(ruleId); const [shouldCloseAlert, setShouldCloseAlert] = useState(false); const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState< Array >([]); - const [fetchOrCreateListError, setFetchOrCreateListError] = useState(false); + const [fetchOrCreateListError, setFetchOrCreateListError] = useState(null); const { addError, addSuccess } = useAppToasts(); const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); const [ @@ -164,17 +167,41 @@ export const AddExceptionModal = memo(function AddExceptionModal({ }, [onRuleChange] ); - const onFetchOrCreateExceptionListError = useCallback( - (error: Error) => { - setFetchOrCreateListError(true); + + const handleDissasociationSuccess = useCallback( + (id: string): void => { + handleRuleChange(true); + addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); + onCancel(); + }, + [handleRuleChange, addSuccess, onCancel] + ); + + const handleDissasociationError = useCallback( + (error: Error): void => { + addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); + onCancel(); + }, + [addError, onCancel] + ); + + const handleFetchOrCreateExceptionListError = useCallback( + (error: Error, statusCode: number | null, message: string | null) => { + setFetchOrCreateListError({ + reason: error.message, + code: statusCode, + details: message, + listListId: null, + }); }, [setFetchOrCreateListError] ); + const [isLoadingExceptionList, ruleExceptionList] = useFetchOrCreateRuleExceptionList({ http, ruleId, exceptionListType, - onError: onFetchOrCreateExceptionListError, + onError: handleFetchOrCreateExceptionListError, onSuccess: handleRuleChange, }); @@ -279,7 +306,9 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ]); const isSubmitButtonDisabled = useMemo( - () => fetchOrCreateListError || exceptionItemsToAdd.every((item) => item.entries.length === 0), + () => + fetchOrCreateListError != null || + exceptionItemsToAdd.every((item) => item.entries.length === 0), [fetchOrCreateListError, exceptionItemsToAdd] ); @@ -295,19 +324,27 @@ export const AddExceptionModal = memo(function AddExceptionModal({ - {fetchOrCreateListError === true && ( - -

{i18n.ADD_EXCEPTION_FETCH_ERROR}

-
+ {fetchOrCreateListError != null && ( + + + )} - {fetchOrCreateListError === false && + {fetchOrCreateListError == null && (isLoadingExceptionList || isIndexPatternLoading || isSignalIndexLoading || isSignalIndexPatternLoading) && ( )} - {fetchOrCreateListError === false && + {fetchOrCreateListError == null && !isSignalIndexLoading && !isSignalIndexPatternLoading && !isLoadingExceptionList && @@ -377,20 +414,21 @@ export const AddExceptionModal = memo(function AddExceptionModal({ )} + {fetchOrCreateListError == null && ( + + {i18n.CANCEL} - - {i18n.CANCEL} - - - {i18n.ADD_EXCEPTION} - - + + {i18n.ADD_EXCEPTION} + + + )} ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx index 6ff218ca06059..c724e6a2c711f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx @@ -77,6 +77,7 @@ describe('When the edit exception modal is opened', () => { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> { ({ eui: euiLightVars, darkMode: false })}> void; onConfirm: () => void; + onRuleChange?: () => void; } const Modal = styled(EuiModal)` @@ -83,14 +88,18 @@ const ModalBodySection = styled.section` export const EditExceptionModal = memo(function EditExceptionModal({ ruleName, + ruleId, ruleIndices, exceptionItem, exceptionListType, onCancel, onConfirm, + onRuleChange, }: EditExceptionModalProps) { const { http } = useKibana().services; const [comment, setComment] = useState(''); + const { rule: maybeRule } = useRuleAsync(ruleId); + const [updateError, setUpdateError] = useState(null); const [hasVersionConflict, setHasVersionConflict] = useState(false); const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); @@ -108,18 +117,44 @@ export const EditExceptionModal = memo(function EditExceptionModal({ 'rules' ); - const onError = useCallback( - (error) => { + const handleExceptionUpdateError = useCallback( + (error: Error, statusCode: number | null, message: string | null) => { if (error.message.includes('Conflict')) { setHasVersionConflict(true); } else { - addError(error, { title: i18n.EDIT_EXCEPTION_ERROR }); - onCancel(); + setUpdateError({ + reason: error.message, + code: statusCode, + details: message, + listListId: exceptionItem.list_id, + }); } }, + [setUpdateError, setHasVersionConflict, exceptionItem.list_id] + ); + + const handleDissasociationSuccess = useCallback( + (id: string): void => { + addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); + + if (onRuleChange) { + onRuleChange(); + } + + onCancel(); + }, + [addSuccess, onCancel, onRuleChange] + ); + + const handleDissasociationError = useCallback( + (error: Error): void => { + addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); + onCancel(); + }, [addError, onCancel] ); - const onSuccess = useCallback(() => { + + const handleExceptionUpdateSuccess = useCallback((): void => { addSuccess(i18n.EDIT_EXCEPTION_SUCCESS); onConfirm(); }, [addSuccess, onConfirm]); @@ -127,8 +162,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({ const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( { http, - onSuccess, - onError, + onSuccess: handleExceptionUpdateSuccess, + onError: handleExceptionUpdateError, } ); @@ -222,11 +257,9 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {ruleName} - {(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && ( )} - {!isSignalIndexLoading && !addExceptionIsLoading && !isIndexPatternLoading && ( <> @@ -280,7 +313,18 @@ export const EditExceptionModal = memo(function EditExceptionModal({ )} - + {updateError != null && ( + + + + )} {hasVersionConflict && ( @@ -288,20 +332,21 @@ export const EditExceptionModal = memo(function EditExceptionModal({ )} + {updateError == null && ( + + {i18n.CANCEL} - - {i18n.CANCEL} - - - {i18n.EDIT_EXCEPTION_SAVE_BUTTON} - - + + {i18n.EDIT_EXCEPTION_SAVE_BUTTON} + + + )} ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx new file mode 100644 index 0000000000000..9c86c502a7648 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.test.tsx @@ -0,0 +1,160 @@ +/* + * 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 { ThemeProvider } from 'styled-components'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { getListMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; +import { useDissasociateExceptionList } from '../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list'; +import { ErrorCallout } from './error_callout'; +import { savedRuleMock } from '../../../detections/containers/detection_engine/rules/mock'; + +jest.mock('../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list'); + +const mockKibanaHttpService = coreMock.createStart().http; + +describe('ErrorCallout', () => { + const mockDissasociate = jest.fn(); + + beforeEach(() => { + (useDissasociateExceptionList as jest.Mock).mockReturnValue([false, mockDissasociate]); + }); + + it('it renders error details', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text() + ).toEqual('Error: error reason (500)'); + expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual( + 'Error fetching exception list' + ); + }); + + it('it invokes "onCancel" when cancel button clicked', () => { + const mockOnCancel = jest.fn(); + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="errorCalloutCancelButton"]').at(0).simulate('click'); + + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it('it does not render status code if not available', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text() + ).toEqual('Error: not found'); + expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual( + 'Error fetching exception list' + ); + expect(wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').exists()).toBeFalsy(); + }); + + it('it renders specific missing exceptions list error', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text() + ).toEqual('Error: not found (404)'); + expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual( + 'The associated exception list (some_uuid) no longer exists. Please remove the missing exception list to add additional exceptions to the detection rule.' + ); + expect(wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').exists()).toBeTruthy(); + }); + + it('it dissasociates list from rule when remove exception list clicked ', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').at(0).simulate('click'); + + expect(mockDissasociate).toHaveBeenCalledWith([]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx new file mode 100644 index 0000000000000..a2419ef16df3a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/error_callout.tsx @@ -0,0 +1,169 @@ +/* + * 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, { useMemo, useEffect, useState, useCallback } from 'react'; +import { + EuiButtonEmpty, + EuiAccordion, + EuiCodeBlock, + EuiButton, + EuiCallOut, + EuiText, + EuiSpacer, +} from '@elastic/eui'; + +import { HttpSetup } from '../../../../../../../src/core/public'; +import { List } from '../../../../common/detection_engine/schemas/types/lists'; +import { Rule } from '../../../detections/containers/detection_engine/rules/types'; +import * as i18n from './translations'; +import { useDissasociateExceptionList } from '../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list'; + +export interface ErrorInfo { + reason: string | null; + code: number | null; + details: string | null; + listListId: string | null; +} + +export interface ErrorCalloutProps { + http: HttpSetup; + rule: Rule | null; + errorInfo: ErrorInfo; + onCancel: () => void; + onSuccess: (listId: string) => void; + onError: (arg: Error) => void; +} + +const ErrorCalloutComponent = ({ + http, + rule, + errorInfo, + onCancel, + onError, + onSuccess, +}: ErrorCalloutProps): JSX.Element => { + const [listToDelete, setListToDelete] = useState(null); + const [errorTitle, setErrorTitle] = useState(''); + const [errorMessage, setErrorMessage] = useState(i18n.ADD_EXCEPTION_FETCH_ERROR); + + const handleOnSuccess = useCallback((): void => { + onSuccess(listToDelete != null ? listToDelete.id : ''); + }, [onSuccess, listToDelete]); + + const [isDissasociatingList, handleDissasociateExceptionList] = useDissasociateExceptionList({ + http, + ruleRuleId: rule != null ? rule.rule_id : '', + onSuccess: handleOnSuccess, + onError, + }); + + const canDisplay404Actions = useMemo( + (): boolean => + errorInfo.code === 404 && + rule != null && + listToDelete != null && + handleDissasociateExceptionList != null, + [errorInfo.code, listToDelete, handleDissasociateExceptionList, rule] + ); + + useEffect((): void => { + // Yes, it's redundant, unfortunately typescript wasn't picking up + // that `listToDelete` is checked in canDisplay404Actions + if (canDisplay404Actions && listToDelete != null) { + setErrorMessage(i18n.ADD_EXCEPTION_FETCH_404_ERROR(listToDelete.id)); + } + + setErrorTitle(`${errorInfo.reason}${errorInfo.code != null ? ` (${errorInfo.code})` : ''}`); + }, [errorInfo.reason, errorInfo.code, listToDelete, canDisplay404Actions]); + + const handleDissasociateList = useCallback((): void => { + // Yes, it's redundant, unfortunately typescript wasn't picking up + // that `handleDissasociateExceptionList` and `list` are checked in + // canDisplay404Actions + if ( + canDisplay404Actions && + rule != null && + listToDelete != null && + handleDissasociateExceptionList != null + ) { + const exceptionLists = (rule.exceptions_list ?? []).filter( + ({ id }) => id !== listToDelete.id + ); + + handleDissasociateExceptionList(exceptionLists); + } + }, [handleDissasociateExceptionList, listToDelete, canDisplay404Actions, rule]); + + useEffect((): void => { + if (errorInfo.code === 404 && rule != null && rule.exceptions_list != null) { + const [listFound] = rule.exceptions_list.filter( + ({ id, list_id: listId }) => + (errorInfo.details != null && errorInfo.details.includes(id)) || + errorInfo.listListId === listId + ); + setListToDelete(listFound); + } + }, [rule, errorInfo.details, errorInfo.code, errorInfo.listListId]); + + return ( + + +

{errorMessage}

+
+ + {listToDelete != null && ( + +

{i18n.MODAL_ERROR_ACCORDION_TEXT}

+ + } + > + + {JSON.stringify(listToDelete)} + +
+ )} + + + {i18n.CANCEL} + + {canDisplay404Actions && ( + + {i18n.CLEAR_EXCEPTIONS_LABEL} + + )} +
+ ); +}; + +ErrorCalloutComponent.displayName = 'ErrorCalloutComponent'; + +export const ErrorCallout = React.memo(ErrorCalloutComponent); + +ErrorCallout.displayName = 'ErrorCallout'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 13e9d0df549f8..484a3d593026e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -190,3 +190,52 @@ export const TOTAL_ITEMS_FETCH_ERROR = i18n.translate( defaultMessage: 'Error getting exception item totals', } ); + +export const CLEAR_EXCEPTIONS_LABEL = i18n.translate( + 'xpack.securitySolution.exceptions.clearExceptionsLabel', + { + defaultMessage: 'Remove Exception List', + } +); + +export const ADD_EXCEPTION_FETCH_404_ERROR = (listId: string) => + i18n.translate('xpack.securitySolution.exceptions.fetch404Error', { + values: { listId }, + defaultMessage: + 'The associated exception list ({listId}) no longer exists. Please remove the missing exception list to add additional exceptions to the detection rule.', + }); + +export const ADD_EXCEPTION_FETCH_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.fetchError', + { + defaultMessage: 'Error fetching exception list', + } +); + +export const ERROR = i18n.translate('xpack.securitySolution.exceptions.errorLabel', { + defaultMessage: 'Error', +}); + +export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.cancelLabel', { + defaultMessage: 'Cancel', +}); + +export const MODAL_ERROR_ACCORDION_TEXT = i18n.translate( + 'xpack.securitySolution.exceptions.modalErrorAccordionText', + { + defaultMessage: 'Show rule reference information:', + } +); + +export const DISSASOCIATE_LIST_SUCCESS = (id: string) => + i18n.translate('xpack.securitySolution.exceptions.dissasociateListSuccessText', { + values: { id }, + defaultMessage: 'Exception list ({id}) has successfully been removed', + }); + +export const DISSASOCIATE_EXCEPTION_LIST_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.dissasociateExceptionListError', + { + defaultMessage: 'Failed to remove exception list', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx index 6611ee2385d10..46923e07d225a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx @@ -148,6 +148,50 @@ describe('useAddOrUpdateException', () => { }); }); + it('invokes "onError" if call to add exception item fails', async () => { + const mockError = new Error('error adding item'); + + addExceptionListItem = jest + .spyOn(listsApi, 'addExceptionListItem') + .mockRejectedValue(mockError); + + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(onError).toHaveBeenCalledWith(mockError, null, null); + }); + }); + + it('invokes "onError" if call to update exception item fails', async () => { + const mockError = new Error('error updating item'); + + updateExceptionListItem = jest + .spyOn(listsApi, 'updateExceptionListItem') + .mockRejectedValue(mockError); + + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(onError).toHaveBeenCalledWith(mockError, null, null); + }); + }); + describe('when alertIdToClose is not passed in', () => { it('should not update the alert status', async () => { await act(async () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx index 9d45a411b5130..be289b0e85e66 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx @@ -42,7 +42,7 @@ export type ReturnUseAddOrUpdateException = [ export interface UseAddOrUpdateExceptionProps { http: HttpStart; - onError: (arg: Error) => void; + onError: (arg: Error, code: number | null, message: string | null) => void; onSuccess: () => void; } @@ -157,7 +157,11 @@ export const useAddOrUpdateException = ({ } catch (error) { if (isSubscribed) { setIsLoading(false); - onError(error); + if (error.body != null) { + onError(error, error.body.status_code, error.body.message); + } else { + onError(error, null, null); + } } } }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx index 39d88bd8e4724..f20a58b9ffa36 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx @@ -379,7 +379,7 @@ describe('useFetchOrCreateRuleExceptionList', () => { await waitForNextUpdate(); await waitForNextUpdate(); expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith(error); + expect(onError).toHaveBeenCalledWith(error, null, null); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx index 0d367e03a799f..944631d4e9fb5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx @@ -30,7 +30,7 @@ export interface UseFetchOrCreateRuleExceptionListProps { http: HttpStart; ruleId: Rule['id']; exceptionListType: ExceptionListSchema['type']; - onError: (arg: Error) => void; + onError: (arg: Error, code: number | null, message: string | null) => void; onSuccess?: (ruleWasChanged: boolean) => void; } @@ -179,7 +179,11 @@ export const useFetchOrCreateRuleExceptionList = ({ if (isSubscribed) { setIsLoading(false); setExceptionList(null); - onError(error); + if (error.body != null) { + onError(error, error.body.status_code, error.body.message); + } else { + onError(error, null, null); + } } } } diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index 7482068454a97..c97895cdfe236 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -322,11 +322,13 @@ const ExceptionsViewerComponent = ({ exceptionListTypeToEdit != null && ( )} diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx new file mode 100644 index 0000000000000..6721d89f2799b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.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 { act, renderHook } from '@testing-library/react-hooks'; + +import { coreMock } from '../../../../../../../../src/core/public/mocks'; + +import * as api from './api'; +import { ruleMock } from './mock'; +import { + ReturnUseDissasociateExceptionList, + UseDissasociateExceptionListProps, + useDissasociateExceptionList, +} from './use_dissasociate_exception_list'; + +const mockKibanaHttpService = coreMock.createStart().http; + +describe('useDissasociateExceptionList', () => { + const onError = jest.fn(); + const onSuccess = jest.fn(); + + beforeEach(() => { + jest.spyOn(api, 'patchRule').mockResolvedValue(ruleMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initializes hook', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseDissasociateExceptionListProps, + ReturnUseDissasociateExceptionList + >(() => + useDissasociateExceptionList({ + http: mockKibanaHttpService, + ruleRuleId: 'rule_id', + onError, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual([false, null]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx new file mode 100644 index 0000000000000..dffba3e6e0436 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.tsx @@ -0,0 +1,80 @@ +/* + * 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, useRef } from 'react'; + +import { HttpStart } from '../../../../../../../../src/core/public'; +import { List } from '../../../../../common/detection_engine/schemas/types/lists'; +import { patchRule } from './api'; + +type Func = (lists: List[]) => void; +export type ReturnUseDissasociateExceptionList = [boolean, Func | null]; + +export interface UseDissasociateExceptionListProps { + http: HttpStart; + ruleRuleId: string; + onError: (arg: Error) => void; + onSuccess: () => void; +} + +/** + * Hook for removing an exception list reference from a rule + * + * @param http Kibana http service + * @param ruleRuleId a rule_id (NOT id) + * @param onError error callback + * @param onSuccess success callback + * + */ +export const useDissasociateExceptionList = ({ + http, + ruleRuleId, + onError, + onSuccess, +}: UseDissasociateExceptionListProps): ReturnUseDissasociateExceptionList => { + const [isLoading, setLoading] = useState(false); + const dissasociateList = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const dissasociateListFromRule = (id: string) => async ( + exceptionLists: List[] + ): Promise => { + try { + if (isSubscribed) { + setLoading(true); + + await patchRule({ + ruleProperties: { + rule_id: id, + exceptions_list: exceptionLists, + }, + signal: abortCtrl.signal, + }); + + onSuccess(); + setLoading(false); + } + } catch (err) { + if (isSubscribed) { + setLoading(false); + onError(err); + } + } + }; + + dissasociateList.current = dissasociateListFromRule(ruleRuleId); + + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [http, ruleRuleId, onError, onSuccess]); + + return [isLoading, dissasociateList.current]; +}; From 4289f9d8b110fb03bc9b16eabdef8d37b073d409 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 26 Aug 2020 14:23:27 -0700 Subject: [PATCH 45/56] skip all tests that rely on es authentication type --- .../test/login_selector_api_integration/apis/login_selector.ts | 3 ++- .../apis/authorization_code_flow/oidc_auth.ts | 3 ++- .../test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts | 3 ++- x-pack/test/pki_api_integration/apis/security/pki_auth.ts | 3 ++- x-pack/test/saml_api_integration/apis/security/saml_login.ts | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/x-pack/test/login_selector_api_integration/apis/login_selector.ts b/x-pack/test/login_selector_api_integration/apis/login_selector.ts index 439e553b17a86..63084d3bfc9e9 100644 --- a/x-pack/test/login_selector_api_integration/apis/login_selector.ts +++ b/x-pack/test/login_selector_api_integration/apis/login_selector.ts @@ -62,7 +62,8 @@ export default function ({ getService }: FtrProviderContext) { expect(apiResponse.body.authentication_provider).to.be(providerName); } - describe('Login Selector', () => { + // FAILING: https://github.com/elastic/kibana/issues/75707 + describe.skip('Login Selector', () => { it('should redirect user to a login selector', async () => { const response = await supertest .get('/abc/xyz/handshake?one=two three') diff --git a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts index 18dfdcffef363..1b37d60436ddc 100644 --- a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts +++ b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts @@ -15,7 +15,8 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const config = getService('config'); - describe('OpenID Connect authentication', () => { + // FAILING: https://github.com/elastic/kibana/issues/75707 + describe.skip('OpenID Connect authentication', () => { it('should reject API requests if client is not authenticated', async () => { await supertest.get('/internal/security/me').set('kbn-xsrf', 'xxx').expect(401); }); diff --git a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts index bea2f996141d5..43d9d680e102a 100644 --- a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts +++ b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts @@ -15,7 +15,8 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const config = getService('config'); - describe('OpenID Connect Implicit Flow authentication', () => { + // FAILING: https://github.com/elastic/kibana/issues/75707 + describe.skip('OpenID Connect Implicit Flow authentication', () => { describe('finishing handshake', () => { let stateAndNonce: ReturnType; let handshakeCookie: Cookie; diff --git a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts index 664fdb9fba67a..a2090a8c2cc48 100644 --- a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts +++ b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts @@ -41,7 +41,8 @@ export default function ({ getService }: FtrProviderContext) { expect(cookie.maxAge).to.be(0); } - describe('PKI authentication', () => { + // FAILING: https://github.com/elastic/kibana/issues/75707 + describe.skip('PKI authentication', () => { before(async () => { await getService('esSupertest') .post('/_security/role_mapping/first_client_pki') diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.ts b/x-pack/test/saml_api_integration/apis/security/saml_login.ts index d78f4da63ab5b..13b541f75e5bd 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.ts +++ b/x-pack/test/saml_api_integration/apis/security/saml_login.ts @@ -61,7 +61,8 @@ export default function ({ getService }: FtrProviderContext) { expect(apiResponse.body.username).to.be(username); } - describe('SAML authentication', () => { + // FAILING: https://github.com/elastic/kibana/issues/75707 + describe.skip('SAML authentication', () => { it('should reject API requests if client is not authenticated', async () => { await supertest.get('/internal/security/me').set('kbn-xsrf', 'xxx').expect(401); }); From c08bf7f3ca6374a7eb4adb7b5ee760d75ceddf0b Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Wed, 26 Aug 2020 17:21:16 -0700 Subject: [PATCH 46/56] using test_user with minimum privileges for canvas functional ui tests (#75917) * incorporating test_user wth specific roles for the canvas functional ui tests * additional checks - removed comments * changes to incorporate code comments * lint check * incorporate code reviews Co-authored-by: Elastic Machine --- .../functional/apps/canvas/custom_elements.ts | 6 +----- x-pack/test/functional/apps/canvas/expression.ts | 6 +----- x-pack/test/functional/apps/canvas/index.js | 15 ++++++++++++++- x-pack/test/functional/apps/canvas/smoke_test.js | 7 +------ x-pack/test/functional/config.js | 11 +++++++++++ 5 files changed, 28 insertions(+), 17 deletions(-) diff --git a/x-pack/test/functional/apps/canvas/custom_elements.ts b/x-pack/test/functional/apps/canvas/custom_elements.ts index 33db56751285e..1a05560aaf931 100644 --- a/x-pack/test/functional/apps/canvas/custom_elements.ts +++ b/x-pack/test/functional/apps/canvas/custom_elements.ts @@ -12,24 +12,20 @@ export default function canvasCustomElementTest({ getService, getPageObjects, }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); const browser = getService('browser'); const retry = getService('retry'); const PageObjects = getPageObjects(['canvas', 'common']); const find = getService('find'); + const esArchiver = getService('esArchiver'); describe('custom elements', function () { this.tags('skipFirefox'); before(async () => { - // init data - await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.load('canvas/default'); - // open canvas home await PageObjects.common.navigateToApp('canvas'); - // load test workpad await PageObjects.common.navigateToApp('canvas', { hash: '/workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31/page/1', diff --git a/x-pack/test/functional/apps/canvas/expression.ts b/x-pack/test/functional/apps/canvas/expression.ts index c184dca8366be..548321243d4fb 100644 --- a/x-pack/test/functional/apps/canvas/expression.ts +++ b/x-pack/test/functional/apps/canvas/expression.ts @@ -9,22 +9,18 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function canvasExpressionTest({ getService, getPageObjects }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); - // const browser = getService('browser'); const retry = getService('retry'); const PageObjects = getPageObjects(['canvas', 'common']); const find = getService('find'); + const esArchiver = getService('esArchiver'); describe('expression editor', function () { // there is an issue with FF not properly clicking on workpad elements this.tags('skipFirefox'); before(async () => { - // init data - await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.load('canvas/default'); - // load test workpad await PageObjects.common.navigateToApp('canvas', { hash: '/workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31/page/1', diff --git a/x-pack/test/functional/apps/canvas/index.js b/x-pack/test/functional/apps/canvas/index.js index d6ded9b20b1ad..7ee48beaabb2a 100644 --- a/x-pack/test/functional/apps/canvas/index.js +++ b/x-pack/test/functional/apps/canvas/index.js @@ -4,8 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -export default function canvasApp({ loadTestFile }) { +export default function canvasApp({ loadTestFile, getService }) { + const security = getService('security'); + const esArchiver = getService('esArchiver'); + describe('Canvas app', function canvasAppTestSuite() { + before(async () => { + // init data + await security.testUser.setRoles(['test_logstash_reader', 'global_canvas_all']); + await esArchiver.loadIfNeeded('logstash_functional'); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + }); + this.tags('ciGroup2'); // CI requires tags ヽ(゜Q。)ノ? loadTestFile(require.resolve('./smoke_test')); loadTestFile(require.resolve('./expression')); diff --git a/x-pack/test/functional/apps/canvas/smoke_test.js b/x-pack/test/functional/apps/canvas/smoke_test.js index 056713a5dacfa..596d34e7c1df5 100644 --- a/x-pack/test/functional/apps/canvas/smoke_test.js +++ b/x-pack/test/functional/apps/canvas/smoke_test.js @@ -8,11 +8,11 @@ import expect from '@kbn/expect'; import { parse } from 'url'; export default function canvasSmokeTest({ getService, getPageObjects }) { - const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); const browser = getService('browser'); const retry = getService('retry'); const PageObjects = getPageObjects(['common']); + const esArchiver = getService('esArchiver'); describe('smoke test', function () { this.tags('includeFirefox'); @@ -20,12 +20,7 @@ export default function canvasSmokeTest({ getService, getPageObjects }) { const testWorkpadId = 'workpad-1705f884-6224-47de-ba49-ca224fe6ec31'; before(async () => { - // init data - await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.load('canvas/default'); - - // load canvas - // see also navigateToUrl(app, hash) await PageObjects.common.navigateToApp('canvas'); }); diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 003d842cc3d6f..cdc6292ba808a 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -221,6 +221,17 @@ export default async function ({ readConfigFile }) { kibana: [], }, + global_canvas_all: { + kibana: [ + { + feature: { + canvas: ['all'], + }, + spaces: ['*'], + }, + ], + }, + global_discover_read: { kibana: [ { From 42942327e52fc9cb1671f7c938158991d62c2659 Mon Sep 17 00:00:00 2001 From: Brent Kimmel Date: Wed, 26 Aug 2020 21:06:38 -0400 Subject: [PATCH 47/56] =?UTF-8?q?[Security=20Solution][Resolver]=20Word-br?= =?UTF-8?q?eak=20long=20titles=20in=20related=20event=E2=80=A6=20(#75926)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Security Solution][Resolver] Word-break long titles in related event description lists * word-break long titles at non-word boundaries Co-authored-by: Elastic Machine --- .../resolver/store/data/reducer.test.ts | 19 ++ .../public/resolver/store/data/selectors.ts | 98 ++++++++++ .../public/resolver/store/selectors.ts | 9 + .../public/resolver/types.ts | 16 ++ .../view/panels/event_counts_for_process.tsx | 3 +- .../view/panels/panel_content_error.tsx | 3 +- .../view/panels/panel_content_utilities.tsx | 8 - .../resolver/view/panels/process_details.tsx | 3 +- .../view/panels/process_event_list.tsx | 9 +- .../view/panels/process_list_with_counts.tsx | 3 +- .../view/panels/related_event_detail.tsx | 174 ++++++++---------- .../view/use_resolver_query_params.ts | 2 +- 12 files changed, 225 insertions(+), 122 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts index edda2ef984a9e..e087db9f74685 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts @@ -11,6 +11,7 @@ import * as selectors from './selectors'; import { DataState } from '../../types'; import { DataAction } from './action'; import { ResolverChildNode, ResolverTree } from '../../../../common/endpoint/types'; +import * as eventModel from '../../../../common/endpoint/models/event'; /** * Test the data reducer and selector. @@ -175,6 +176,24 @@ describe('Resolver Data Middleware', () => { eventStatsForFirstChildNode.byCategory[categoryToOverCount] - 1 ); }); + it('should return the correct related event detail metadata for a given related event', () => { + const relatedEventsByCategory = selectors.relatedEventsByCategory(store.getState()); + const someRelatedEventForTheFirstChild = relatedEventsByCategory(firstChildNodeInTree.id)( + categoryToOverCount + )[0]; + const relatedEventID = eventModel.eventId(someRelatedEventForTheFirstChild)!; + const relatedDisplayInfo = selectors.relatedEventDisplayInfoByEntityAndSelfID( + store.getState() + )(firstChildNodeInTree.id, relatedEventID); + const [, countOfSameType, , sectionData] = relatedDisplayInfo; + const hostEntries = sectionData.filter((section) => { + return section.sectionTitle === 'host'; + })[0].entries; + expect(hostEntries).toContainEqual({ title: 'os.platform', description: 'Windows' }); + expect(countOfSameType).toBe( + eventStatsForFirstChildNode.byCategory[categoryToOverCount] - 1 + ); + }); it('should indicate the limit has been exceeded because the number of related events received for the category is less than what the stats count said it would be', () => { const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); const shouldShowLimit = selectedRelatedInfo(firstChildNodeInTree.id) diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 569a24bb8537e..965547f1e309a 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -14,6 +14,7 @@ import { IndexedProcessNode, AABB, VisibleEntites, + SectionData, } from '../../types'; import { isGraphableProcess, @@ -29,11 +30,14 @@ import { ResolverNodeStats, ResolverRelatedEvents, SafeResolverEvent, + EndpointEvent, + LegacyEndpointEvent, } from '../../../../common/endpoint/types'; import * as resolverTreeModel from '../../models/resolver_tree'; import * as isometricTaxiLayoutModel from '../../models/indexed_process_tree/isometric_taxi_layout'; import * as eventModel from '../../../../common/endpoint/models/event'; import * as vector2 from '../../models/vector2'; +import { formatDate } from '../../view/panels/panel_content_utilities'; /** * If there is currently a request. @@ -173,6 +177,100 @@ export function relatedEventsByEntityId(data: DataState): Map
` entries + */ +const objectToDescriptionListEntries = function* ( + obj: object, + prefix = '' +): Generator<{ title: string; description: string }> { + const nextPrefix = prefix.length ? `${prefix}.` : ''; + for (const [metaKey, metaValue] of Object.entries(obj)) { + if (typeof metaValue === 'number' || typeof metaValue === 'string') { + yield { title: nextPrefix + metaKey, description: `${metaValue}` }; + } else if (metaValue instanceof Array) { + yield { + title: nextPrefix + metaKey, + description: metaValue + .filter((arrayEntry) => { + return typeof arrayEntry === 'number' || typeof arrayEntry === 'string'; + }) + .join(','), + }; + } else if (typeof metaValue === 'object') { + yield* objectToDescriptionListEntries(metaValue, nextPrefix + metaKey); + } + } +}; + +/** + * Returns a function that returns the information needed to display related event details based on + * the related event's entityID and its own ID. + */ +export const relatedEventDisplayInfoByEntityAndSelfID: ( + state: DataState +) => ( + entityId: string, + relatedEventId: string | number +) => [ + EndpointEvent | LegacyEndpointEvent | undefined, + number, + string | undefined, + SectionData, + string +] = createSelector(relatedEventsByEntityId, function relatedEventDetails( + /* eslint-disable no-shadow */ + relatedEventsByEntityId + /* eslint-enable no-shadow */ +) { + return defaultMemoize((entityId: string, relatedEventId: string | number) => { + const relatedEventsForThisProcess = relatedEventsByEntityId.get(entityId); + if (!relatedEventsForThisProcess) { + return [undefined, 0, undefined, [], '']; + } + const specificEvent = relatedEventsForThisProcess.events.find( + (evt) => eventModel.eventId(evt) === relatedEventId + ); + // For breadcrumbs: + const specificCategory = specificEvent && eventModel.primaryEventCategory(specificEvent); + const countOfCategory = relatedEventsForThisProcess.events.reduce((sumtotal, evt) => { + return eventModel.primaryEventCategory(evt) === specificCategory ? sumtotal + 1 : sumtotal; + }, 0); + + // Assuming these details (agent, ecs, process) aren't as helpful, can revisit + const { agent, ecs, process, ...relevantData } = specificEvent as ResolverEvent & { + // Type this with various unknown keys so that ts will let us delete those keys + ecs: unknown; + process: unknown; + }; + + let displayDate = ''; + const sectionData: SectionData = Object.entries(relevantData) + .map(([sectionTitle, val]) => { + if (sectionTitle === '@timestamp') { + displayDate = formatDate(val); + return { sectionTitle: '', entries: [] }; + } + if (typeof val !== 'object') { + return { sectionTitle, entries: [{ title: sectionTitle, description: `${val}` }] }; + } + return { sectionTitle, entries: [...objectToDescriptionListEntries(val)] }; + }) + .filter((v) => v.sectionTitle !== '' && v.entries.length); + + return [specificEvent, countOfCategory, specificCategory, sectionData, displayDate]; + }); +}); + /** * Returns a function that returns a function (when supplied with an entity id for a node) * that returns related events for a node that match an event.category (when supplied with the category) diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 70a461909a99b..f50aeed3f4d48 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -122,6 +122,15 @@ export const relatedEventsByEntityId = composeSelectors( dataSelectors.relatedEventsByEntityId ); +/** + * Returns a function that returns the information needed to display related event details based on + * the related event's entityID and its own ID. + */ +export const relatedEventDisplayInfoByEntityAndSelfId = composeSelectors( + dataStateSelector, + dataSelectors.relatedEventDisplayInfoByEntityAndSelfID +); + /** * Returns a function that returns a function (when supplied with an entity id for a node) * that returns related events for a node that match an event.category (when supplied with the category) diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 33f7a1d97db13..9ebe3fa14e842 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -160,6 +160,22 @@ export interface IndexedProcessNode extends BBox { position: Vector2; } +/** + * A type describing the shape of section titles and entries for description lists + */ +export type SectionData = Array<{ + sectionTitle: string; + entries: Array<{ title: string; description: string }>; +}>; + +/** + * The two query parameters we read/write on to control which view the table presents: + */ +export interface CrumbInfo { + crumbId: string; + crumbEvent: string; +} + /** * A type containing all things to actually be rendered to the DOM. */ diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/event_counts_for_process.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/event_counts_for_process.tsx index 129aff776808a..c528ba547e6ae 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/event_counts_for_process.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/event_counts_for_process.tsx @@ -8,10 +8,11 @@ import React, { memo, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiBasicTableColumn, EuiButtonEmpty, EuiSpacer, EuiInMemoryTable } from '@elastic/eui'; import { FormattedMessage } from 'react-intl'; -import { CrumbInfo, StyledBreadcrumbs } from './panel_content_utilities'; +import { StyledBreadcrumbs } from './panel_content_utilities'; import * as event from '../../../../common/endpoint/models/event'; import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types'; +import { CrumbInfo } from '../../types'; /** * This view gives counts for all the related events of a process grouped by related event type. diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx index c9a536fd5932d..b93ef6146f1cf 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx @@ -7,7 +7,8 @@ import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui'; import React, { memo, useMemo } from 'react'; -import { CrumbInfo, StyledBreadcrumbs } from './panel_content_utilities'; +import { StyledBreadcrumbs } from './panel_content_utilities'; +import { CrumbInfo } from '../../types'; /** * Display an error in the panel when something goes wrong and give the user a way to "retreat" back to a default state. diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx index 55b5be21fb4a4..5c7a4a476efba 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx @@ -23,14 +23,6 @@ const BetaHeader = styled(`header`)` margin-bottom: 1em; `; -/** - * The two query parameters we read/write on to control which view the table presents: - */ -export interface CrumbInfo { - crumbId: string; - crumbEvent: string; -} - const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: string }>` &.euiBreadcrumbs { background-color: ${(props) => props.background}; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx index adfcc4cc44d1f..15711909c4c9b 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx @@ -19,7 +19,7 @@ import { FormattedMessage } from 'react-intl'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import * as selectors from '../../store/selectors'; import * as event from '../../../../common/endpoint/models/event'; -import { CrumbInfo, formatDate, StyledBreadcrumbs } from './panel_content_utilities'; +import { formatDate, StyledBreadcrumbs } from './panel_content_utilities'; import { processPath, processPid, @@ -31,6 +31,7 @@ import { import { CubeForProcess } from './cube_for_process'; import { ResolverEvent } from '../../../../common/endpoint/types'; import { useResolverTheme } from '../assets'; +import { CrumbInfo } from '../../types'; const StyledDescriptionList = styled(EuiDescriptionList)` &.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/process_event_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/process_event_list.tsx index 101711475c938..a710d3ad846b3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/process_event_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/process_event_list.tsx @@ -10,18 +10,13 @@ import { EuiTitle, EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from import { useSelector } from 'react-redux'; import { FormattedMessage } from 'react-intl'; import styled from 'styled-components'; -import { - CrumbInfo, - formatDate, - StyledBreadcrumbs, - BoldCode, - StyledTime, -} from './panel_content_utilities'; +import { formatDate, StyledBreadcrumbs, BoldCode, StyledTime } from './panel_content_utilities'; import * as event from '../../../../common/endpoint/models/event'; import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types'; import * as selectors from '../../store/selectors'; import { useResolverDispatch } from '../use_resolver_dispatch'; import { RelatedEventLimitWarning } from '../limit_warnings'; +import { CrumbInfo } from '../../types'; /** * This view presents a list of related events of a given type for a given process. diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/process_list_with_counts.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/process_list_with_counts.tsx index 1be4b4b055243..e42140feb928b 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/process_list_with_counts.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/process_list_with_counts.tsx @@ -16,12 +16,13 @@ import { useSelector } from 'react-redux'; import styled from 'styled-components'; import * as event from '../../../../common/endpoint/models/event'; import * as selectors from '../../store/selectors'; -import { CrumbInfo, formatter, StyledBreadcrumbs } from './panel_content_utilities'; +import { formatter, StyledBreadcrumbs } from './panel_content_utilities'; import { useResolverDispatch } from '../use_resolver_dispatch'; import { SideEffectContext } from '../side_effect_context'; import { CubeForProcess } from './cube_for_process'; import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { LimitWarning } from '../limit_warnings'; +import { CrumbInfo } from '../../types'; const StyledLimitWarning = styled(LimitWarning)` flex-flow: row wrap; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx index 3579b1b2f69b8..da4cd3c9dacad 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/related_event_detail.tsx @@ -10,58 +10,19 @@ import { EuiSpacer, EuiText, EuiDescriptionList, EuiTextColor, EuiTitle } from ' import styled from 'styled-components'; import { useSelector } from 'react-redux'; import { FormattedMessage } from 'react-intl'; -import { - CrumbInfo, - formatDate, - StyledBreadcrumbs, - BoldCode, - StyledTime, -} from './panel_content_utilities'; +import { StyledBreadcrumbs, BoldCode, StyledTime } from './panel_content_utilities'; import * as event from '../../../../common/endpoint/models/event'; import { ResolverEvent } from '../../../../common/endpoint/types'; import * as selectors from '../../store/selectors'; import { useResolverDispatch } from '../use_resolver_dispatch'; import { PanelContentError } from './panel_content_error'; - -/** - * A helper function to turn objects into EuiDescriptionList entries. - * This reflects the strategy of more or less "dumping" metadata for related processes - * in description lists with little/no 'prettification'. This has the obvious drawback of - * data perhaps appearing inscrutable/daunting, but the benefit of presenting these fields - * to the user "as they occur" in ECS, which may help them with e.g. EQL queries. - * - * Given an object like: {a:{b: 1}, c: 'd'} it will yield title/description entries like so: - * {title: "a.b", description: "1"}, {title: "c", description: "d"} - * - * @param {object} obj The object to turn into `
` entries - */ -const objectToDescriptionListEntries = function* ( - obj: object, - prefix = '' -): Generator<{ title: string; description: string }> { - const nextPrefix = prefix.length ? `${prefix}.` : ''; - for (const [metaKey, metaValue] of Object.entries(obj)) { - if (typeof metaValue === 'number' || typeof metaValue === 'string') { - yield { title: nextPrefix + metaKey, description: `${metaValue}` }; - } else if (metaValue instanceof Array) { - yield { - title: nextPrefix + metaKey, - description: metaValue - .filter((arrayEntry) => { - return typeof arrayEntry === 'number' || typeof arrayEntry === 'string'; - }) - .join(','), - }; - } else if (typeof metaValue === 'object') { - yield* objectToDescriptionListEntries(metaValue, nextPrefix + metaKey); - } - } -}; +import { CrumbInfo } from '../../types'; // Adding some styles to prevent horizontal scrollbars, per request from UX review const StyledDescriptionList = memo(styled(EuiDescriptionList)` &.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title { max-width: 8em; + overflow-wrap: break-word; } &.euiDescriptionList.euiDescriptionList--column dd.euiDescriptionList__description { max-width: calc(100% - 8.5em); @@ -69,6 +30,12 @@ const StyledDescriptionList = memo(styled(EuiDescriptionList)` } `); +// Also prevents horizontal scrollbars on long descriptive names +const StyledDescriptiveName = memo(styled(EuiText)` + padding-right: 1em; + overflow-wrap: break-word; +`); + // Styling subtitles, per UX review: const StyledFlexTitle = memo(styled('h3')` display: flex; @@ -90,6 +57,49 @@ const TitleHr = memo(() => { }); TitleHr.displayName = 'TitleHR'; +const GeneratedText = React.memo(function ({ children }) { + return <>{processedValue()}; + + function processedValue() { + return React.Children.map(children, (child) => { + if (typeof child === 'string') { + const valueSplitByWordBoundaries = child.split(/\b/); + + if (valueSplitByWordBoundaries.length < 2) { + return valueSplitByWordBoundaries[0]; + } + + return [ + valueSplitByWordBoundaries[0], + ...valueSplitByWordBoundaries + .splice(1) + .reduce(function (generatedTextMemo: Array, value, index) { + return [...generatedTextMemo, value, ]; + }, []), + ]; + } else { + return child; + } + }); + } +}); +GeneratedText.displayName = 'GeneratedText'; + +/** + * Take description list entries and prepare them for display by + * seeding with `` tags. + * + * @param entries {title: string, description: string}[] + */ +function entriesForDisplay(entries: Array<{ title: string; description: string }>) { + return entries.map((entry) => { + return { + description: {entry.description}, + title: {entry.title}, + }; + }); +} + /** * This view presents a detailed view of all the available data for a related event, split and titled by the "section" * it appears in the underlying ResolverEvent @@ -138,60 +148,17 @@ export const RelatedEventDetail = memo(function RelatedEventDetail({ } }, [relatedsReady, dispatch, processEntityId]); - const relatedEventsForThisProcess = useSelector(selectors.relatedEventsByEntityId).get( - processEntityId! + const [ + relatedEventToShowDetailsFor, + countBySameCategory, + relatedEventCategory = naString, + sections, + formattedDate, + ] = useSelector(selectors.relatedEventDisplayInfoByEntityAndSelfId)( + processEntityId, + relatedEventId ); - const [relatedEventToShowDetailsFor, countBySameCategory, relatedEventCategory] = useMemo(() => { - if (!relatedEventsForThisProcess) { - return [undefined, 0]; - } - const specificEvent = relatedEventsForThisProcess.events.find( - (evt) => event.eventId(evt) === relatedEventId - ); - // For breadcrumbs: - const specificCategory = specificEvent && event.primaryEventCategory(specificEvent); - const countOfCategory = relatedEventsForThisProcess.events.reduce((sumtotal, evt) => { - return event.primaryEventCategory(evt) === specificCategory ? sumtotal + 1 : sumtotal; - }, 0); - return [specificEvent, countOfCategory, specificCategory || naString]; - }, [relatedEventsForThisProcess, naString, relatedEventId]); - - const [sections, formattedDate] = useMemo(() => { - if (!relatedEventToShowDetailsFor) { - // This could happen if user relaods from URL param and requests an eventId that no longer exists - return [[], naString]; - } - // Assuming these details (agent, ecs, process) aren't as helpful, can revisit - const { - agent, - ecs, - process, - ...relevantData - } = relatedEventToShowDetailsFor as ResolverEvent & { - // Type this with various unknown keys so that ts will let us delete those keys - ecs: unknown; - process: unknown; - }; - let displayDate = ''; - const sectionData: Array<{ - sectionTitle: string; - entries: Array<{ title: string; description: string }>; - }> = Object.entries(relevantData) - .map(([sectionTitle, val]) => { - if (sectionTitle === '@timestamp') { - displayDate = formatDate(val); - return { sectionTitle: '', entries: [] }; - } - if (typeof val !== 'object') { - return { sectionTitle, entries: [{ title: sectionTitle, description: `${val}` }] }; - } - return { sectionTitle, entries: [...objectToDescriptionListEntries(val)] }; - }) - .filter((v) => v.sectionTitle !== '' && v.entries.length); - return [sectionData, displayDate]; - }, [relatedEventToShowDetailsFor, naString]); - const waitCrumbs = useMemo(() => { return [ { @@ -338,15 +305,18 @@ export const RelatedEventDetail = memo(function RelatedEventDetail({ - - - + + + + + {sections.map(({ sectionTitle, entries }, index) => { + const displayEntries = entriesForDisplay(entries); return ( {index === 0 ? null : } @@ -364,7 +334,7 @@ export const RelatedEventDetail = memo(function RelatedEventDetail({ align="left" titleProps={{ className: 'desc-title' }} compressed - listItems={entries} + listItems={displayEntries} /> {index === sections.length - 1 ? null : } diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts index aa0851916a7b4..b6c229181e9f7 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -7,7 +7,7 @@ import { useCallback, useMemo } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { useQueryStringKeys } from './use_query_string_keys'; -import { CrumbInfo } from './panels/panel_content_utilities'; +import { CrumbInfo } from '../types'; export function useResolverQueryParams() { /** From a358c5768ea4f378270140d4e480fece98e8e103 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 27 Aug 2020 07:02:28 +0200 Subject: [PATCH 48/56] [Uptime] One click simple monitor down alert (#73835) * WIP * added anomaly alert * update types * update types * update * types * types * update ML part * update ML part * update ML part * unnecessary change * icon for disable * update test * update api * update labels * resolve conflicts * fix types * fix editing alert * fix types * added actions column * added code to add alert * update anomaly message * added anomaly alert test * update * update type * fix ml legacy scoped client * update * WIP * fix conflict * added aria label * Added deleteion loading * fix type * update * update tests * update * update type * fix types * WIP * added enabled alerts section * add data * update * update tests * fix test * update i18n * update i18n * update i18n * fix * update message * update * update * update * revert * update types * added component * update test * incorporate PR feedback * fix focus * update drawer * handle edge case * improve btn text * improve btn text * use switch instead of icons * update snapshot * use compressed form * fix type * update snapshot * update snapshot * update test * update test * PR feedback * fix test and type * remove delete action * remove unnecessary function Co-authored-by: Elastic Machine --- .../triggers_actions_ui/public/index.ts | 1 + .../uptime/common/constants/rest_api.ts | 3 + .../common/constants/settings_defaults.ts | 1 + .../runtime_types/alerts/status_check.ts | 2 + .../common/runtime_types/dynamic_settings.ts | 1 + .../common/runtime_types/monitor/details.ts | 3 +- .../__tests__/link_for_eui.test.tsx | 4 +- .../components/monitor/ml/manage_ml_job.tsx | 4 +- .../monitor/ml/use_anomaly_alert.ts | 8 +- .../__mocks__/{mock.ts => poly_layer_mock.ts} | 0 .../embeddables/__tests__/map_config.test.ts | 2 +- .../__snapshots__/monitor_list.test.tsx.snap | 111 ++++++++++- .../__snapshots__/enable_alert.test.tsx.snap | 89 +++++++++ .../columns/__tests__/enable_alert.test.tsx | 90 +++++++++ .../columns/define_connectors.tsx | 55 ++++++ .../monitor_list/columns/enable_alert.tsx | 130 +++++++++++++ .../monitor_list/columns/translations.ts | 15 ++ .../overview/monitor_list/monitor_list.tsx | 24 ++- .../monitor_list_drawer.test.tsx.snap | 2 + .../most_recent_error.test.tsx.snap | 2 +- .../__tests__/monitor_list_drawer.test.tsx | 24 ++- .../monitor_list_drawer/enabled_alerts.tsx | 57 ++++++ .../list_drawer_container.tsx | 58 +++--- .../monitor_list_drawer.tsx | 13 +- .../overview/monitor_list/translations.ts | 4 + .../certificate_form.test.tsx.snap | 1 + .../__snapshots__/indices_form.test.tsx.snap | 1 + .../__tests__/certificate_form.test.tsx | 3 + .../settings/__tests__/indices_form.test.tsx | 1 + .../settings/add_connector_flyout.tsx | 70 +++++++ .../settings/alert_defaults_form.tsx | 182 ++++++++++++++++++ .../components/settings/translations.ts | 9 + .../use_url_params.test.tsx.snap | 4 +- .../uptime/public/hooks/use_init_app.ts | 18 ++ .../uptime/public/lib/__mocks__/index.ts | 7 - .../public/lib/__mocks__/uptime_store.mock.ts | 120 ++++++++++++ ...tory.mock.ts => ut_router_history.mock.ts} | 0 .../__tests__/monitor_status.test.ts | 10 +- .../public/lib/alert_types/alert_messages.tsx | 28 +++ .../public/lib/helper/helper_with_redux.tsx | 5 +- .../public/lib/helper/helper_with_router.tsx | 37 +++- .../get_supported_url_params.test.ts.snap | 5 + .../get_supported_url_params.test.ts | 2 + .../url_params/get_supported_url_params.ts | 3 + x-pack/plugins/uptime/public/lib/index.ts | 2 +- .../__snapshots__/page_header.test.tsx.snap | 46 ++--- .../plugins/uptime/public/pages/monitor.tsx | 23 ++- .../plugins/uptime/public/pages/overview.tsx | 12 ++ .../uptime/public/pages/page_header.tsx | 10 +- .../plugins/uptime/public/pages/settings.tsx | 17 +- .../uptime/public/state/actions/monitor.ts | 12 +- .../uptime/public/state/actions/types.ts | 8 + .../uptime/public/state/alerts/alerts.ts | 165 ++++++++++++++++ .../plugins/uptime/public/state/api/alerts.ts | 77 +++++++- .../public/state/certificates/certificates.ts | 8 +- .../uptime/public/state/effects/alerts.ts | 4 +- .../uptime/public/state/effects/index.ts | 2 +- .../uptime/public/state/effects/ml_anomaly.ts | 5 +- .../uptime/public/state/effects/monitor.ts | 8 +- .../uptime/public/state/reducers/alerts.ts | 29 --- .../uptime/public/state/reducers/index.ts | 2 +- .../public/state/reducers/index_status.ts | 8 +- .../public/state/reducers/ml_anomaly.ts | 24 +-- .../uptime/public/state/reducers/monitor.ts | 9 +- .../uptime/public/state/reducers/types.ts | 2 +- .../uptime/public/state/reducers/utils.ts | 22 ++- .../state/selectors/__tests__/index.test.ts | 6 +- .../uptime/public/state/selectors/index.ts | 4 +- x-pack/plugins/uptime/server/kibana.index.ts | 8 +- .../lib/adapters/framework/adapter_types.ts | 10 +- .../telemetry/kibana_telemetry_adapter.ts | 6 +- .../lib/alerts/__tests__/status_check.test.ts | 8 + .../uptime/server/lib/alerts/status_check.ts | 74 ++++--- .../lib/requests/__tests__/get_certs.test.ts | 1 + .../__tests__/get_latest_monitor.test.ts | 2 +- .../server/lib/requests/get_latest_monitor.ts | 10 +- .../lib/requests/get_monitor_details.ts | 75 +++++++- .../server/lib/requests/get_monitor_status.ts | 2 +- .../uptime/server/lib/saved_objects.ts | 3 + .../__tests__/dynamic_settings.test.ts | 5 + .../server/rest_api/dynamic_settings.ts | 1 + .../rest_api/monitors/monitors_details.ts | 6 +- .../rest_api/monitors/monitors_durations.ts | 1 + .../apis/uptime/rest/dynamic_settings.ts | 1 + x-pack/test/functional/apps/uptime/index.ts | 42 ++-- .../test/functional/apps/uptime/settings.ts | 1 + .../functional/page_objects/uptime_page.ts | 1 + .../test/functional/services/uptime/common.ts | 6 +- .../functional/services/uptime/navigation.ts | 1 + .../functional/services/uptime/overview.ts | 4 + .../functional/services/uptime/settings.ts | 1 + .../apps/uptime/index.ts | 1 + .../apps/uptime/simple_down_alert.ts | 109 +++++++++++ 93 files changed, 1828 insertions(+), 265 deletions(-) rename x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/__mocks__/{mock.ts => poly_layer_mock.ts} (100%) create mode 100644 x-pack/plugins/uptime/public/components/overview/monitor_list/columns/__tests__/__snapshots__/enable_alert.test.tsx.snap create mode 100644 x-pack/plugins/uptime/public/components/overview/monitor_list/columns/__tests__/enable_alert.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/monitor_list/columns/define_connectors.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/monitor_list/columns/enable_alert.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/monitor_list/columns/translations.ts create mode 100644 x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/enabled_alerts.tsx create mode 100644 x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx create mode 100644 x-pack/plugins/uptime/public/components/settings/alert_defaults_form.tsx create mode 100644 x-pack/plugins/uptime/public/hooks/use_init_app.ts delete mode 100644 x-pack/plugins/uptime/public/lib/__mocks__/index.ts create mode 100644 x-pack/plugins/uptime/public/lib/__mocks__/uptime_store.mock.ts rename x-pack/plugins/uptime/public/lib/__mocks__/{react_router_history.mock.ts => ut_router_history.mock.ts} (100%) create mode 100644 x-pack/plugins/uptime/public/lib/alert_types/alert_messages.tsx create mode 100644 x-pack/plugins/uptime/public/state/alerts/alerts.ts delete mode 100644 x-pack/plugins/uptime/public/state/reducers/alerts.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/uptime/simple_down_alert.ts diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 7808e2a7f608d..f73fac2259067 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -21,6 +21,7 @@ export { AlertTypeParamsExpressionProps, ValidationResult, ActionVariable, + ActionConnector, } from './types'; export { ConnectorAddFlyout, diff --git a/x-pack/plugins/uptime/common/constants/rest_api.ts b/x-pack/plugins/uptime/common/constants/rest_api.ts index f3f06f776260d..be1f498c2e75d 100644 --- a/x-pack/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/plugins/uptime/common/constants/rest_api.ts @@ -24,6 +24,9 @@ export enum API_URLS { ML_DELETE_JOB = `/api/ml/jobs/delete_jobs`, ML_CAPABILITIES = '/api/ml/ml_capabilities', ML_ANOMALIES_RESULT = `/api/ml/results/anomalies_table_data`, + + ALERT_ACTIONS = '/api/actions', + CREATE_ALERT = '/api/alerts/alert', ALERT = '/api/alerts/alert/', ALERTS_FIND = '/api/alerts/_find', } diff --git a/x-pack/plugins/uptime/common/constants/settings_defaults.ts b/x-pack/plugins/uptime/common/constants/settings_defaults.ts index b9e99a54b3b11..6eb2a1913b871 100644 --- a/x-pack/plugins/uptime/common/constants/settings_defaults.ts +++ b/x-pack/plugins/uptime/common/constants/settings_defaults.ts @@ -10,4 +10,5 @@ export const DYNAMIC_SETTINGS_DEFAULTS: DynamicSettings = { heartbeatIndices: 'heartbeat-8*', certAgeThreshold: 730, certExpirationThreshold: 30, + defaultConnectors: [], }; diff --git a/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts b/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts index 5a355dc576c0a..971a9f51bfae1 100644 --- a/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts +++ b/x-pack/plugins/uptime/common/runtime_types/alerts/status_check.ts @@ -25,6 +25,7 @@ export const AtomicStatusCheckParamsType = t.intersection([ search: t.string, filters: StatusCheckFiltersType, shouldCheckStatus: t.boolean, + isAutoGenerated: t.boolean, }), ]); @@ -34,6 +35,7 @@ export const StatusCheckParamsType = t.intersection([ t.partial({ filters: t.string, shouldCheckStatus: t.boolean, + isAutoGenerated: t.boolean, }), t.type({ locations: t.array(t.string), diff --git a/x-pack/plugins/uptime/common/runtime_types/dynamic_settings.ts b/x-pack/plugins/uptime/common/runtime_types/dynamic_settings.ts index a0ec92f7d869b..3621827b294a6 100644 --- a/x-pack/plugins/uptime/common/runtime_types/dynamic_settings.ts +++ b/x-pack/plugins/uptime/common/runtime_types/dynamic_settings.ts @@ -10,6 +10,7 @@ export const DynamicSettingsType = t.type({ heartbeatIndices: t.string, certAgeThreshold: t.number, certExpirationThreshold: t.number, + defaultConnectors: t.array(t.string), }); export const DynamicSettingsSaveType = t.intersection([ diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor/details.ts b/x-pack/plugins/uptime/common/runtime_types/monitor/details.ts index bf81c91bae633..c622d4f19bade 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor/details.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor/details.ts @@ -18,7 +18,6 @@ export type MonitorError = t.TypeOf; export const MonitorDetailsType = t.intersection([ t.type({ monitorId: t.string }), - t.partial({ error: MonitorErrorType }), - t.partial({ timestamp: t.string }), + t.partial({ error: MonitorErrorType, timestamp: t.string, alerts: t.unknown }), ]); export type MonitorDetails = t.TypeOf; diff --git a/x-pack/plugins/uptime/public/components/common/react_router_helpers/__tests__/link_for_eui.test.tsx b/x-pack/plugins/uptime/public/components/common/react_router_helpers/__tests__/link_for_eui.test.tsx index 4a681f6fa60bf..845b597a8ad18 100644 --- a/x-pack/plugins/uptime/public/components/common/react_router_helpers/__tests__/link_for_eui.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/react_router_helpers/__tests__/link_for_eui.test.tsx @@ -8,10 +8,10 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; import { EuiLink, EuiButton } from '@elastic/eui'; -import '../../../../lib/__mocks__/react_router_history.mock'; +import '../../../../lib/__mocks__/ut_router_history.mock'; import { ReactRouterEuiLink, ReactRouterEuiButton } from '../link_for_eui'; -import { mockHistory } from '../../../../lib/__mocks__'; +import { mockHistory } from '../../../../lib/__mocks__/ut_router_history.mock'; describe('EUI & React Router Component Helpers', () => { beforeEach(() => { diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx index f4382b37b3d30..7971c4eb58350 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx @@ -22,7 +22,7 @@ import { useMonitorId } from '../../../hooks'; import { setAlertFlyoutType, setAlertFlyoutVisible } from '../../../state/actions'; import { useAnomalyAlert } from './use_anomaly_alert'; import { ConfirmAlertDeletion } from './confirm_alert_delete'; -import { deleteAlertAction } from '../../../state/actions/alerts'; +import { deleteAnomalyAlertAction } from '../../../state/alerts/alerts'; interface Props { hasMLJob: boolean; @@ -52,7 +52,7 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro const [isConfirmAlertDeleteOpen, setIsConfirmAlertDeleteOpen] = useState(false); const deleteAnomalyAlert = () => - dispatch(deleteAlertAction.get({ alertId: anomalyAlert?.id as string })); + dispatch(deleteAnomalyAlertAction.get({ alertId: anomalyAlert?.id as string })); const showLoading = isMLJobCreating || isMLJobLoading; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts b/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts index d204cdf10012a..949bbadfc9d26 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts +++ b/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts @@ -6,10 +6,10 @@ import { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { getExistingAlertAction } from '../../../state/actions/alerts'; -import { alertSelector, selectAlertFlyoutVisibility } from '../../../state/selectors'; +import { selectAlertFlyoutVisibility } from '../../../state/selectors'; import { UptimeRefreshContext } from '../../../contexts'; import { useMonitorId } from '../../../hooks'; +import { anomalyAlertSelector, getAnomalyAlertAction } from '../../../state/alerts/alerts'; export const useAnomalyAlert = () => { const { lastRefresh } = useContext(UptimeRefreshContext); @@ -18,12 +18,12 @@ export const useAnomalyAlert = () => { const monitorId = useMonitorId(); - const { data: anomalyAlert } = useSelector(alertSelector); + const { data: anomalyAlert } = useSelector(anomalyAlertSelector); const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility); useEffect(() => { - dispatch(getExistingAlertAction.get({ monitorId })); + dispatch(getAnomalyAlertAction.get({ monitorId })); }, [monitorId, lastRefresh, dispatch, alertFlyoutVisible]); return anomalyAlert; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/__mocks__/mock.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/__mocks__/poly_layer_mock.ts similarity index 100% rename from x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/__mocks__/mock.ts rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/__mocks__/poly_layer_mock.ts diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/map_config.test.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/map_config.test.ts index 18b43434da24b..582c60f048bed 100644 --- a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/map_config.test.ts +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/map_config.test.ts @@ -5,7 +5,7 @@ */ import { getLayerList } from '../map_config'; -import { mockLayerList } from './__mocks__/mock'; +import { mockLayerList } from './__mocks__/poly_layer_mock'; import { LocationPoint } from '../embedded_map'; import { UptimeAppColors } from '../../../../../../apps/uptime_app'; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap index 4898ec00b38e2..e177f1cf01147 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap @@ -1055,9 +1055,26 @@ exports[`MonitorList component renders the monitor list 1`] = `
+
+ +
+
+ + Status alert + +
+
+
+ Status alert +
+
+
+
+
+ +
+
+
+
+
+
+ Status alert +
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ +`; + +exports[`EnableAlertComponent shallow renders without errors for valid props 1`] = ` + + + + + +`; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/__tests__/enable_alert.test.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/__tests__/enable_alert.test.tsx new file mode 100644 index 0000000000000..4f41ea4c0b895 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/__tests__/enable_alert.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 { EnableMonitorAlert } from '../enable_alert'; +import * as redux from 'react-redux'; +import { + mountWithRouterRedux, + renderWithRouterRedux, + shallowWithRouterRedux, +} from '../../../../../lib'; +import { EuiPopover, EuiText } from '@elastic/eui'; +import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../../../common/constants'; + +describe('EnableAlertComponent', () => { + let defaultConnectors: string[] = []; + let alerts: any = []; + + beforeEach(() => { + jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn()); + + jest.spyOn(redux, 'useSelector').mockImplementation((fn, d) => { + if (fn.name === 'selectDynamicSettings') { + return { + settings: Object.assign(DYNAMIC_SETTINGS_DEFAULTS, { + defaultConnectors, + }), + }; + } + if (fn.name === 'alertsSelector') { + return { + data: { + data: alerts, + }, + loading: false, + }; + } + return {}; + }); + }); + + it('shallow renders without errors for valid props', () => { + const wrapper = shallowWithRouterRedux( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders without errors for valid props', () => { + const wrapper = renderWithRouterRedux( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + it('displays define connectors when there is none', () => { + defaultConnectors = []; + const wrapper = mountWithRouterRedux( + + ); + expect(wrapper.find(EuiPopover)).toHaveLength(1); + wrapper.find('button').simulate('click'); + expect(wrapper.find(EuiText).text()).toBe( + 'To start enabling alerts, please define a default alert action connector in Settings' + ); + }); + + it('does not displays define connectors when there is connector', () => { + defaultConnectors = ['infra-slack-connector-id']; + const wrapper = mountWithRouterRedux( + + ); + + expect(wrapper.find(EuiPopover)).toHaveLength(0); + }); + + it('displays disable when alert is there', () => { + alerts = [{ id: 'test-alert', params: { search: 'testMonitor' } }]; + defaultConnectors = ['infra-slack-connector-id']; + + const wrapper = mountWithRouterRedux( + + ); + + expect(wrapper.find('button').prop('aria-label')).toBe('Disable status alert'); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/define_connectors.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/define_connectors.tsx new file mode 100644 index 0000000000000..673588688db84 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/define_connectors.tsx @@ -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 React, { useState } from 'react'; +import { EuiPopover, EuiSwitch, EuiText } from '@elastic/eui'; +import { useRouteMatch } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ReactRouterEuiLink } from '../../../common/react_router_helpers'; +import { MONITOR_ROUTE, SETTINGS_ROUTE } from '../../../../../common/constants'; +import { ENABLE_STATUS_ALERT } from './translations'; +import { SETTINGS_LINK_TEXT } from '../../../../pages/page_header'; + +export const DefineAlertConnectors = () => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onButtonClick = () => setIsPopoverOpen((val) => !val); + const closePopover = () => setIsPopoverOpen(false); + + const isMonitorPage = useRouteMatch(MONITOR_ROUTE); + + return ( + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + > + + {' '} + + {SETTINGS_LINK_TEXT} + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/enable_alert.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/enable_alert.tsx new file mode 100644 index 0000000000000..8a5a72891c3e7 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/enable_alert.tsx @@ -0,0 +1,130 @@ +/* + * 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 { EuiLoadingSpinner, EuiToolTip, EuiSwitch } from '@elastic/eui'; +import { useRouteMatch } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { selectDynamicSettings } from '../../../../state/selectors'; +import { + alertsSelector, + connectorsSelector, + createAlertAction, + deleteAlertAction, + isAlertDeletedSelector, + newAlertSelector, +} from '../../../../state/alerts/alerts'; +import { MONITOR_ROUTE } from '../../../../../common/constants'; +import { DefineAlertConnectors } from './define_connectors'; +import { DISABLE_STATUS_ALERT, ENABLE_STATUS_ALERT } from './translations'; + +interface Props { + monitorId: string; + monitorName?: string; +} + +export const EnableMonitorAlert = ({ monitorId, monitorName }: Props) => { + const [isLoading, setIsLoading] = useState(false); + + const { settings } = useSelector(selectDynamicSettings); + + const isMonitorPage = useRouteMatch(MONITOR_ROUTE); + + const dispatch = useDispatch(); + + const { data: actionConnectors } = useSelector(connectorsSelector); + + const { data: alerts, loading: alertsLoading } = useSelector(alertsSelector); + + const { data: deletedAlertId } = useSelector(isAlertDeletedSelector); + + const { data: newAlert } = useSelector(newAlertSelector); + + const isNewAlert = newAlert?.params.search.includes(monitorId); + + let hasAlert = (alerts?.data ?? []).find((alert) => alert.params.search.includes(monitorId)); + + if (isNewAlert) { + // if it's newly created alert, we assign that quickly without waiting for find alert result + hasAlert = newAlert!; + } + if (deletedAlertId === hasAlert?.id) { + // if it just got deleted, we assign that quickly without waiting for find alert result + hasAlert = undefined; + } + + const defaultActions = (actionConnectors ?? []).filter((act) => + settings?.defaultConnectors?.includes(act.id) + ); + + const enableAlert = () => { + dispatch( + createAlertAction.get({ + defaultActions, + monitorId, + monitorName, + }) + ); + setIsLoading(true); + }; + + const disableAlert = () => { + if (hasAlert) { + dispatch( + deleteAlertAction.get({ + alertId: hasAlert.id, + }) + ); + setIsLoading(true); + } + }; + + useEffect(() => { + setIsLoading(false); + }, [hasAlert, deletedAlertId]); + + const hasDefaultConnectors = (settings?.defaultConnectors ?? []).length > 0; + + const showSpinner = isLoading || (alertsLoading && !alerts); + + const onAlertClick = () => { + if (hasAlert) { + disableAlert(); + } else { + enableAlert(); + } + }; + const btnLabel = hasAlert ? DISABLE_STATUS_ALERT : ENABLE_STATUS_ALERT; + + return hasDefaultConnectors || hasAlert ? ( +
+ { + + <> + {' '} + {showSpinner && } + + + } +
+ ) : ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/translations.ts b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/translations.ts new file mode 100644 index 0000000000000..421072ab603c2 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/translations.ts @@ -0,0 +1,15 @@ +/* + * 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 ENABLE_STATUS_ALERT = i18n.translate('xpack.uptime.monitorList.enableDownAlert', { + defaultMessage: 'Enable status alert', +}); + +export const DISABLE_STATUS_ALERT = i18n.translate('xpack.uptime.monitorList.disableDownAlert', { + defaultMessage: 'Disable status alert', +}); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx index ce4c518d82255..718e9e9948081 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx @@ -31,6 +31,8 @@ import { MonitorList } from '../../../state/reducers/monitor_list'; import { CertStatusColumn } from './cert_status_column'; import { MonitorListHeader } from './monitor_list_header'; import { URL_LABEL } from '../../common/translations'; +import { EnableMonitorAlert } from './columns/enable_alert'; +import { STATUS_ALERT_COLUMN } from './translations'; interface Props extends MonitorListProps { pageSize: number; @@ -49,7 +51,13 @@ export const noItemsMessage = (loading: boolean, filters?: string) => { return !!filters ? labels.NO_MONITOR_ITEM_SELECTED : labels.NO_DATA_MESSAGE; }; -export const MonitorListComponent: React.FC = ({ +export const MonitorListComponent: ({ + filters, + monitorList: { list, error, loading }, + linkParameters, + pageSize, + setPageSize, +}: Props) => any = ({ filters, monitorList: { list, error, loading }, linkParameters, @@ -69,7 +77,7 @@ export const MonitorListComponent: React.FC = ({ ...map, [id]: ( monitorId === id)} + summary={items.find(({ monitor_id: monitorId }) => monitorId === id)!} /> ), }; @@ -135,6 +143,18 @@ export const MonitorListComponent: React.FC = ({ ), }, + { + align: 'center' as const, + field: '', + name: STATUS_ALERT_COLUMN, + width: '150px', + render: (item: MonitorSummary) => ( + + ), + }, { align: 'right' as const, field: 'monitor_id', diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/monitor_list_drawer.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/monitor_list_drawer.test.tsx.snap index 42c885dfaf515..e4450e67ae5b3 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/monitor_list_drawer.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/monitor_list_drawer.test.tsx.snap @@ -86,6 +86,7 @@ exports[`MonitorListDrawer component renders a MonitorListDrawer when there are } > Get https://expired.badssl.com: x509: certificate has expired or is not yet valid diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/monitor_list_drawer.test.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/monitor_list_drawer.test.tsx index 502ccd53ef80c..302137199276b 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/monitor_list_drawer.test.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/monitor_list_drawer.test.tsx @@ -52,7 +52,11 @@ describe('MonitorListDrawer component', () => { it('renders nothing when no summary data is present', () => { const component = shallowWithRouter( - + ); expect(component).toEqual({}); }); @@ -60,14 +64,22 @@ describe('MonitorListDrawer component', () => { it('renders nothing when no check data is present', () => { delete summary.state.summaryPings; const component = shallowWithRouter( - + ); expect(component).toEqual({}); }); it('renders a MonitorListDrawer when there is only one check', () => { const component = shallowWithRouter( - + ); expect(component).toMatchSnapshot(); }); @@ -88,7 +100,11 @@ describe('MonitorListDrawer component', () => { } const component = shallowWithRouter( - + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/enabled_alerts.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/enabled_alerts.tsx new file mode 100644 index 0000000000000..d869c6d78ec11 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/enabled_alerts.tsx @@ -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 React, { useContext } from 'react'; +import { EuiCallOut, EuiListGroup, EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiListGroupItemProps } from '@elastic/eui/src/components/list_group/list_group_item'; +import { i18n } from '@kbn/i18n'; +import { UptimeSettingsContext } from '../../../../contexts'; +import { Alert } from '../../../../../../triggers_actions_ui/public'; + +interface Props { + monitorAlerts: Alert[]; + loading: boolean; +} + +export const EnabledAlerts = ({ monitorAlerts, loading }: Props) => { + const { basePath } = useContext(UptimeSettingsContext); + + const listItems: EuiListGroupItemProps[] = []; + + (monitorAlerts ?? []).forEach((alert, ind) => { + listItems.push({ + label: alert.name, + href: basePath + '/app/management/insightsAndAlerting/triggersActions/alert/' + alert.id, + size: 's', + 'data-test-subj': 'uptimeMonitorListDrawerAlert' + ind, + }); + }); + + return ( + <> + + + +

+ {i18n.translate('xpack.uptime.monitorList.enabledAlerts.title', { + defaultMessage: 'Enabled alerts:', + description: 'Alerts enabled for this monitor', + })} +

+
+
+ {listItems.length === 0 && !loading && ( + + )} + {loading ? : } + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/list_drawer_container.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/list_drawer_container.tsx index bec32ace27f2b..fd68a487a21e4 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/list_drawer_container.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/list_drawer_container.tsx @@ -4,44 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect } from 'react'; -import { connect } from 'react-redux'; +import React, { useContext, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { AppState } from '../../../../state'; -import { monitorDetailsSelector } from '../../../../state/selectors'; -import { MonitorDetailsActionPayload } from '../../../../state/actions/types'; +import { monitorDetailsLoadingSelector, monitorDetailsSelector } from '../../../../state/selectors'; import { getMonitorDetailsAction } from '../../../../state/actions/monitor'; import { MonitorListDrawerComponent } from './monitor_list_drawer'; import { useGetUrlParams } from '../../../../hooks'; -import { MonitorDetails, MonitorSummary } from '../../../../../common/runtime_types'; +import { MonitorSummary } from '../../../../../common/runtime_types'; +import { alertsSelector } from '../../../../state/alerts/alerts'; +import { UptimeRefreshContext } from '../../../../contexts'; interface ContainerProps { summary: MonitorSummary; - monitorDetails: MonitorDetails; - loadMonitorDetails: typeof getMonitorDetailsAction; } -const Container: React.FC = ({ summary, loadMonitorDetails, monitorDetails }) => { +export const MonitorListDrawer: React.FC = ({ summary }) => { + const { lastRefresh } = useContext(UptimeRefreshContext); + const monitorId = summary?.monitor_id; const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = useGetUrlParams(); - useEffect(() => { - loadMonitorDetails({ - dateStart, - dateEnd, - monitorId, - }); - }, [dateStart, dateEnd, monitorId, loadMonitorDetails]); - return ; -}; + const monitorDetails = useSelector((state: AppState) => monitorDetailsSelector(state, summary)); + + const isLoading = useSelector(monitorDetailsLoadingSelector); -const mapStateToProps = (state: AppState, { summary }: any) => ({ - monitorDetails: monitorDetailsSelector(state, summary), -}); + const dispatch = useDispatch(); -const mapDispatchToProps = (dispatch: any) => ({ - loadMonitorDetails: (actionPayload: MonitorDetailsActionPayload) => - dispatch(getMonitorDetailsAction(actionPayload)), -}); + const { data: alerts, loading: alertsLoading } = useSelector(alertsSelector); -export const MonitorListDrawer = connect(mapStateToProps, mapDispatchToProps)(Container); + const hasAlert = (alerts?.data ?? []).find((alert) => alert.params.search.includes(monitorId)); + + useEffect(() => { + dispatch( + getMonitorDetailsAction.get({ + dateStart, + dateEnd, + monitorId, + }) + ); + }, [dateStart, dateEnd, monitorId, dispatch, hasAlert, lastRefresh]); + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/monitor_list_drawer.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/monitor_list_drawer.tsx index 305455c8ba573..4b359099bc58c 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/monitor_list_drawer.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/monitor_list_drawer.tsx @@ -6,11 +6,13 @@ import React from 'react'; import styled from 'styled-components'; -import { EuiLink, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; +import { EuiLink, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; import { MostRecentError } from './most_recent_error'; import { MonitorStatusList } from './monitor_status_list'; import { MonitorDetails, MonitorSummary } from '../../../../../common/runtime_types'; import { ActionsPopover } from './actions_popover/actions_popover_container'; +import { EnabledAlerts } from './enabled_alerts'; +import { Alert } from '../../../../../../triggers_actions_ui/public'; const ContainerDiv = styled.div` padding: 10px; @@ -27,13 +29,18 @@ interface MonitorListDrawerProps { * Monitor details to be fetched from rest api using monitorId */ monitorDetails: MonitorDetails; + loading: boolean; } /** * The elements shown when the user expands the monitor list rows. */ -export function MonitorListDrawerComponent({ summary, monitorDetails }: MonitorListDrawerProps) { +export function MonitorListDrawerComponent({ + summary, + monitorDetails, + loading, +}: MonitorListDrawerProps) { const monitorUrl = summary?.state?.url?.full || ''; return summary && summary.state.summaryPings ? ( @@ -51,8 +58,8 @@ export function MonitorListDrawerComponent({ summary, monitorDetails }: MonitorL - + {monitorDetails && monitorDetails.error && ( { heartbeatIndices: 'heartbeat-8*', certExpirationThreshold: 7, certAgeThreshold: 36, + defaultConnectors: [], }} fieldErrors={null} isDisabled={false} @@ -37,6 +38,7 @@ describe('CertificateForm', () => { heartbeatIndices: 'heartbeat-8*', certExpirationThreshold: 7, certAgeThreshold: 36, + defaultConnectors: [], }} fieldErrors={null} isDisabled={false} @@ -90,6 +92,7 @@ describe('CertificateForm', () => { heartbeatIndices: 'heartbeat-8*', certExpirationThreshold: 7, certAgeThreshold: 36, + defaultConnectors: [], }} fieldErrors={null} isDisabled={false} diff --git a/x-pack/plugins/uptime/public/components/settings/__tests__/indices_form.test.tsx b/x-pack/plugins/uptime/public/components/settings/__tests__/indices_form.test.tsx index 68a0d96d491b6..01b66263d3e93 100644 --- a/x-pack/plugins/uptime/public/components/settings/__tests__/indices_form.test.tsx +++ b/x-pack/plugins/uptime/public/components/settings/__tests__/indices_form.test.tsx @@ -19,6 +19,7 @@ describe('CertificateForm', () => { heartbeatIndices: 'heartbeat-8*', certAgeThreshold: 36, certExpirationThreshold: 7, + defaultConnectors: [], }} fieldErrors={null} isDisabled={false} diff --git a/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx b/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx new file mode 100644 index 0000000000000..60c0807ae89a8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useDispatch } from 'react-redux'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { + ActionsConnectorsContextProvider, + ConnectorAddFlyout, +} from '../../../../triggers_actions_ui/public'; + +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { getConnectorsAction } from '../../state/alerts/alerts'; + +interface Props { + focusInput: () => void; +} +export const AddConnectorFlyout = ({ focusInput }: Props) => { + const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); + + const { + services: { + triggers_actions_ui: { actionTypeRegistry }, + application, + docLinks, + http, + notifications, + }, + } = useKibana(); + + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(getConnectorsAction.get()); + focusInput(); + }, [addFlyoutVisible, dispatch, focusInput]); + + return ( + <> + setAddFlyoutVisibility(true)} + > + + + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/settings/alert_defaults_form.tsx b/x-pack/plugins/uptime/public/components/settings/alert_defaults_form.tsx new file mode 100644 index 0000000000000..b3b38a84e4f22 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/settings/alert_defaults_form.tsx @@ -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 React, { useEffect, useState, useRef, useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiDescribedFormGroup, + EuiFormRow, + EuiTitle, + EuiSpacer, + EuiComboBox, + EuiIcon, + EuiComboBoxOptionOption, +} from '@elastic/eui'; +import { useSelector } from 'react-redux'; +import styled from 'styled-components'; +import { SettingsFormProps } from '../../pages/settings'; +import { connectorsSelector } from '../../state/alerts/alerts'; +import { AddConnectorFlyout } from './add_connector_flyout'; +import { useGetUrlParams, useUrlParams } from '../../hooks'; +import { alertFormI18n } from './translations'; +import { useInitApp } from '../../hooks/use_init_app'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; + +type ConnectorOption = EuiComboBoxOptionOption; + +const ConnectorSpan = styled.span` + .euiIcon { + margin-right: 5px; + } + > img { + width: 16px; + height: 20px; + } +`; + +export const AlertDefaultsForm: React.FC = ({ + onChange, + loading, + formFields, + fieldErrors, + isDisabled, +}) => { + const { + services: { + triggers_actions_ui: { actionTypeRegistry }, + }, + } = useKibana(); + const { focusConnectorField } = useGetUrlParams(); + + const updateUrlParams = useUrlParams()[1]; + + const inputRef = useRef(null); + + useInitApp(); + + useEffect(() => { + if (focusConnectorField && inputRef.current && !loading) { + inputRef.current.focus(); + } + }, [focusConnectorField, inputRef, loading]); + + const { data = [] } = useSelector(connectorsSelector); + + const [error, setError] = useState(undefined); + + const onBlur = () => { + if (inputRef.current) { + const { value } = inputRef.current; + setError(value.length === 0 ? undefined : `"${value}" is not a valid option`); + } + if (inputRef.current && !loading && focusConnectorField) { + updateUrlParams({ focusConnectorField: undefined }); + } + }; + + const onSearchChange = (value: string, hasMatchingOptions?: boolean) => { + setError( + value.length === 0 || hasMatchingOptions ? undefined : `"${value}" is not a valid option` + ); + }; + + const options = (data ?? []).map((connectorAction) => ({ + value: connectorAction.id, + label: connectorAction.name, + 'data-test-subj': connectorAction.name, + })); + + const renderOption = (option: ConnectorOption) => { + const { label, value } = option; + + const { actionTypeId: type } = data?.find((dt) => dt.id === value) ?? {}; + return ( + + + {label} + + ); + }; + + const onOptionChange = (selectedOptions: ConnectorOption[]) => { + onChange({ + defaultConnectors: selectedOptions.map((item) => { + const conOpt = data?.find((dt) => dt.id === item.value)!; + return conOpt.id; + }), + }); + }; + + return ( + <> + +

+ +

+
+ + + + + } + description={ + + } + > + + } + > + + formFields?.defaultConnectors?.includes(opt.value) + )} + inputRef={(input) => { + inputRef.current = input; + }} + onSearchChange={onSearchChange} + onBlur={onBlur} + isLoading={loading} + isDisabled={isDisabled} + onChange={onOptionChange} + data-test-subj={`default-connectors-input-${loading ? 'loading' : 'loaded'}`} + renderOption={renderOption} + /> + + + { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [])} + /> + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/settings/translations.ts b/x-pack/plugins/uptime/public/components/settings/translations.ts index 2de25a44165c6..f9f3b0b6af9a9 100644 --- a/x-pack/plugins/uptime/public/components/settings/translations.ts +++ b/x-pack/plugins/uptime/public/components/settings/translations.ts @@ -22,3 +22,12 @@ export const certificateFormTranslations = { } ), }; + +export const alertFormI18n = { + inputPlaceHolder: i18n.translate( + 'xpack.uptime.sourceConfiguration.alertDefaultForm.selectConnector', + { + defaultMessage: 'Please select one or more connectors', + } + ), +}; diff --git a/x-pack/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap b/x-pack/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap index 5d2565b7210da..5bbb606b6142f 100644 --- a/x-pack/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap +++ b/x-pack/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap @@ -209,7 +209,7 @@ exports[`useUrlParams deletes keys that do not have truthy values 1`] = ` } >
- {"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-12","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":"","pagination":"foo"} + {"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-12","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":"","pagination":"foo","focusConnectorField":false}
+ {isOn && ( + + {(formData) => { + onFormData(formData); + return null; + }} + + )} + + ); + }; + + const setup = registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + }); + + const { + form: { setInputValue }, + find, + } = setup() as TestBed; + + expect(onFormData.mock.calls.length).toBe(0); // Not present in the DOM yet + + // Make some changes to the form fields + await act(async () => { + setInputValue('nameField', 'updated value'); + }); + + // Update state to trigger the mounting of the FormDataProvider + await act(async () => { + find('btn').simulate('click').update(); + }); + + expect(onFormData.mock.calls.length).toBe(1); + + const [formDataUpdated] = onFormData.mock.calls[onFormData.mock.calls.length - 1] as Parameters< + OnUpdateHandler + >; + + expect(formDataUpdated).toEqual({ + name: 'updated value', + }); + }); + test('props.pathsToWatch (string): should not re-render the children when the field that changed is not the one provided', async () => { const onFormData = jest.fn(); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts index 4c8e91b13b1b7..3630b902f0564 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts @@ -31,6 +31,7 @@ export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) = const form = useFormContext(); const { subscribe } = form; const previousRawData = useRef(form.__getFormData$().value); + const isMounted = useRef(false); const [formData, setFormData] = useState(previousRawData.current); const onFormData = useCallback( @@ -59,5 +60,17 @@ export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) = return subscription.unsubscribe; }, [subscribe, onFormData]); + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + if (!isMounted.current && Object.keys(formData).length === 0) { + // No field has mounted yet, don't render anything + return null; + } + return children(formData); }); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index 01d9f8a59129a..9d22e4eb2ee5e 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -43,38 +43,41 @@ export const useField = ( deserializer, } = config; - const { getFormData, __removeField, __updateFormDataAt, __validateFields } = form; + const { + getFormData, + getFields, + __addField, + __removeField, + __updateFormDataAt, + __validateFields, + } = form; - /** - * This callback is both used as the initial "value" state getter, **and** for when we reset the form - * (and thus reset the field value). When we reset the form, we can provide a new default value (which will be - * passed through this "initialValueGetter" handler). - */ - const initialValueGetter = useCallback( - (updatedDefaultValue = initialValue) => { - if (typeof updatedDefaultValue === 'function') { - return deserializer ? deserializer(updatedDefaultValue()) : updatedDefaultValue(); + const deserializeValue = useCallback( + (rawValue = initialValue) => { + if (typeof rawValue === 'function') { + return deserializer ? deserializer(rawValue()) : rawValue(); } - return deserializer ? deserializer(updatedDefaultValue) : updatedDefaultValue; + return deserializer ? deserializer(rawValue) : rawValue; }, [initialValue, deserializer] ); - const [value, setStateValue] = useState(initialValueGetter); + const [value, setStateValue] = useState(deserializeValue); const [errors, setErrors] = useState([]); const [isPristine, setPristine] = useState(true); const [isValidating, setValidating] = useState(false); const [isChangingValue, setIsChangingValue] = useState(false); const [isValidated, setIsValidated] = useState(false); + const validateCounter = useRef(0); const changeCounter = useRef(0); const inflightValidation = useRef | null>(null); const debounceTimeout = useRef(null); - const isUnmounted = useRef(false); + const isMounted = useRef(false); // -- HELPERS // ---------------------------------- - const serializeOutput: FieldHook['__serializeOutput'] = useCallback( + const serializeValue: FieldHook['__serializeValue'] = useCallback( (rawValue = value) => { return serializer ? serializer(rawValue) : rawValue; }, @@ -121,8 +124,11 @@ export const useField = ( if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); + debounceTimeout.current = null; } + setPristine(false); + if (errorDisplayDelay > 0) { setIsChangingValue(true); } @@ -135,10 +141,14 @@ export const useField = ( // Update the form data observable __updateFormDataAt(path, value); - // Validate field(s) and update form.isValid state - await __validateFields(fieldsToValidateOnChange ?? [path]); + // Validate field(s) (that will update form.isValid state) + // We only validate if the value is different than the initial or default value + // to avoid validating after a form.reset() call. + if (value !== initialValue && value !== defaultValue) { + await __validateFields(fieldsToValidateOnChange ?? [path]); + } - if (isUnmounted.current) { + if (isMounted.current === false) { return; } @@ -160,10 +170,12 @@ export const useField = ( } } }, [ - valueChangeListener, - errorDisplayDelay, path, value, + defaultValue, + initialValue, + valueChangeListener, + errorDisplayDelay, fieldsToValidateOnChange, __updateFormDataAt, __validateFields, @@ -229,7 +241,7 @@ export const useField = ( inflightValidation.current = validator({ value: (valueToValidate as unknown) as string, errors: validationErrors, - form, + form: { getFormData, getFields }, formData, path, }) as Promise; @@ -273,7 +285,7 @@ export const useField = ( const validationResult = validator({ value: (valueToValidate as unknown) as string, errors: validationErrors, - form, + form: { getFormData, getFields }, formData, path, }); @@ -308,7 +320,7 @@ export const useField = ( // We first try to run the validations synchronously return runSync(); }, - [clearErrors, cancelInflightValidation, validations, form, path] + [clearErrors, cancelInflightValidation, validations, getFormData, getFields, path] ); // -- API @@ -331,12 +343,12 @@ export const useField = ( setValidating(true); // By the time our validate function has reached completion, it’s possible - // that validate() will have been called again. If this is the case, we need + // that we have called validate() again. If this is the case, we need // to ignore the results of this invocation and only use the results of // the most recent invocation to update the error state for a field const validateIteration = ++validateCounter.current; - const onValidationErrors = (_validationErrors: ValidationError[]): FieldValidateResponse => { + const onValidationResult = (_validationErrors: ValidationError[]): FieldValidateResponse => { if (validateIteration === validateCounter.current) { // This is the most recent invocation setValidating(false); @@ -360,9 +372,9 @@ export const useField = ( }); if (Reflect.has(validationErrors, 'then')) { - return (validationErrors as Promise).then(onValidationErrors); + return (validationErrors as Promise).then(onValidationResult); } - return onValidationErrors(validationErrors as ValidationError[]); + return onValidationResult(validationErrors as ValidationError[]); }, [getFormData, value, runValidations] ); @@ -374,15 +386,11 @@ export const useField = ( */ const setValue: FieldHook['setValue'] = useCallback( (newValue) => { - if (isPristine) { - setPristine(false); - } - const formattedValue = formatInputValue(newValue); setStateValue(formattedValue); return formattedValue; }, - [formatInputValue, isPristine] + [formatInputValue] ); const _setErrors: FieldHook['setErrors'] = useCallback((_errors) => { @@ -447,32 +455,17 @@ export const useField = ( setErrors([]); if (resetValue) { - const newValue = initialValueGetter(updatedDefaultValue ?? defaultValue); + const newValue = deserializeValue(updatedDefaultValue ?? defaultValue); setValue(newValue); return newValue; } }, - [setValue, initialValueGetter, defaultValue] + [setValue, deserializeValue, defaultValue] ); - // -- EFFECTS - // ---------------------------------- - useEffect(() => { - if (isPristine) { - // Avoid validate on mount - return; - } - - onValueChange(); + const isValid = errors.length === 0; - return () => { - if (debounceTimeout.current) { - clearTimeout(debounceTimeout.current); - } - }; - }, [isPristine, onValueChange]); - - const field: FieldHook = useMemo(() => { + const field = useMemo>(() => { return { path, type, @@ -481,9 +474,8 @@ export const useField = ( helpText, value, errors, - form, isPristine, - isValid: errors.length === 0, + isValid, isValidating, isValidated, isChangingValue, @@ -494,7 +486,7 @@ export const useField = ( clearErrors, validate, reset, - __serializeOutput: serializeOutput, + __serializeValue: serializeValue, }; }, [ path, @@ -503,9 +495,9 @@ export const useField = ( labelAppend, helpText, value, - form, isPristine, errors, + isValid, isValidating, isValidated, isChangingValue, @@ -516,18 +508,43 @@ export const useField = ( clearErrors, validate, reset, - serializeOutput, + serializeValue, ]); - form.__addField(field as FieldHook); + // ---------------------------------- + // -- EFFECTS + // ---------------------------------- + useEffect(() => { + __addField(field as FieldHook); + }, [field, __addField]); useEffect(() => { return () => { - // Remove field from the form when it is unmounted or if its path changes. - isUnmounted.current = true; __removeField(path); }; }, [path, __removeField]); + useEffect(() => { + if (!isMounted.current) { + return; + } + + onValueChange(); + + return () => { + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current); + } + }; + }, [onValueChange]); + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + }; + }, []); + return field; }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index c3f6ecc7f4831..35bac5b9a58c6 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -40,19 +40,26 @@ export function useForm( const { onSubmit, schema, serializer, deserializer, options, id = 'default', defaultValue } = formConfig ?? {}; - const formDefaultValue = useMemo<{ [key: string]: any }>(() => { - if (defaultValue === undefined || Object.keys(defaultValue).length === 0) { - return {}; - } + const initDefaultValue = useCallback( + (_defaultValue?: Partial): { [key: string]: any } => { + if (_defaultValue === undefined || Object.keys(_defaultValue).length === 0) { + return {}; + } - const defaultValueFiltered = Object.entries(defaultValue as object) - .filter(({ 1: value }) => value !== undefined) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + const filtered = Object.entries(_defaultValue as object) + .filter(({ 1: value }) => value !== undefined) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); - return deserializer ? (deserializer(defaultValueFiltered) as any) : defaultValueFiltered; - }, [defaultValue, deserializer]); + return deserializer ? (deserializer(filtered) as any) : filtered; + }, + [deserializer] + ); - const defaultValueDeserialized = useRef(formDefaultValue); + const defaultValueMemoized = useMemo<{ [key: string]: any }>(() => { + return initDefaultValue(defaultValue); + }, [defaultValue, initDefaultValue]); + + const defaultValueDeserialized = useRef(defaultValueMemoized); const { errorDisplayDelay, stripEmptyFields: doStripEmptyFields } = options ?? {}; const formOptions = useMemo( @@ -68,7 +75,7 @@ export function useForm( const [isValid, setIsValid] = useState(undefined); const fieldsRefs = useRef({}); const formUpdateSubscribers = useRef([]); - const isUnmounted = useRef(false); + const isMounted = useRef(false); // formData$ is an observable we can subscribe to in order to receive live // update of the raw form data. As an observable it does not trigger any React @@ -77,14 +84,6 @@ export function useForm( // and updating its state to trigger the necessary view render. const formData$ = useRef | null>(null); - useEffect(() => { - return () => { - formUpdateSubscribers.current.forEach((subscription) => subscription.unsubscribe()); - formUpdateSubscribers.current = []; - isUnmounted.current = true; - }; - }, []); - // -- HELPERS // ---------------------------------- const getFormData$ = useCallback((): Subject => { @@ -135,7 +134,7 @@ export function useForm( (getDataOptions: Parameters['getFormData']>[0] = { unflatten: true }) => { if (getDataOptions.unflatten) { const nonEmptyFields = stripEmptyFields(fieldsRefs.current); - const fieldsValue = mapFormFields(nonEmptyFields, (field) => field.__serializeOutput()); + const fieldsValue = mapFormFields(nonEmptyFields, (field) => field.__serializeValue()); return serializer ? (serializer(unflattenObject(fieldsValue)) as T) : (unflattenObject(fieldsValue) as T); @@ -168,45 +167,53 @@ export function useForm( const isFieldValid = (field: FieldHook) => field.isValid && !field.isValidating; - const updateFormValidity = useCallback(() => { - if (isUnmounted.current) { - return; - } - - const fieldsArray = fieldsToArray(); - const areAllFieldsValidated = fieldsArray.every((field) => field.isValidated); - - if (!areAllFieldsValidated) { - // If *not* all the fiels have been validated, the validity of the form is unknown, thus still "undefined" - return undefined; - } - - const isFormValid = fieldsArray.every(isFieldValid); - - setIsValid(isFormValid); - return isFormValid; - }, [fieldsToArray]); - const validateFields: FormHook['__validateFields'] = useCallback( async (fieldNames) => { const fieldsToValidate = fieldNames .map((name) => fieldsRefs.current[name]) .filter((field) => field !== undefined); - if (fieldsToValidate.length === 0) { - // Nothing to validate + const formData = getFormData({ unflatten: false }); + const validationResult = await Promise.all( + fieldsToValidate.map((field) => field.validate({ formData })) + ); + + if (isMounted.current === false) { return { areFieldsValid: true, isFormValid: true }; } - const formData = getFormData({ unflatten: false }); - await Promise.all(fieldsToValidate.map((field) => field.validate({ formData }))); + const areFieldsValid = validationResult.every(Boolean); - const isFormValid = updateFormValidity(); - const areFieldsValid = fieldsToValidate.every(isFieldValid); + const validationResultByPath = fieldsToValidate.reduce((acc, field, i) => { + acc[field.path] = validationResult[i].isValid; + return acc; + }, {} as { [key: string]: boolean }); + + // At this stage we have an updated field validation state inside the "validationResultByPath" object. + // The fields we have in our "fieldsRefs.current" have not been updated yet with the new validation state + // (isValid, isValidated...) as this will happen _after_, when the "useEffect" triggers and calls "addField()". + // This means that we have **stale state value** in our fieldsRefs. + // To know the current form validity, we will then merge the "validationResult" _with_ the fieldsRefs object state, + // the "validationResult" taking presedence over the fieldsRefs values. + const formFieldsValidity = fieldsToArray().map((field) => { + const _isValid = validationResultByPath[field.path] ?? field.isValid; + const _isValidated = + validationResultByPath[field.path] !== undefined ? true : field.isValidated; + return [_isValid, _isValidated]; + }); + + const areAllFieldsValidated = formFieldsValidity.every(({ 1: isValidated }) => isValidated); + + // If *not* all the fiels have been validated, the validity of the form is unknown, thus still "undefined" + const isFormValid = areAllFieldsValidated + ? formFieldsValidity.every(([_isValid]) => _isValid) + : undefined; + + setIsValid(isFormValid); return { areFieldsValid, isFormValid }; }, - [getFormData, updateFormValidity] + [getFormData, fieldsToArray] ); const validateAllFields = useCallback(async (): Promise => { @@ -216,19 +223,12 @@ export function useForm( let isFormValid: boolean | undefined; if (fieldsToValidate.length === 0) { - // We should never enter this condition as the form validity is updated each time - // a field is validated. But sometimes, during tests or race conditions it does not happen and we need - // to wait the next tick (hooks lifecycle being tricky) to make sure the "isValid" state is updated. - // In order to avoid this unintentional behaviour, we add this if condition here. - - // TODO: Fix this when adding tests to the form lib. isFormValid = fieldsArray.every(isFieldValid); - setIsValid(isFormValid); - return isFormValid; + } else { + ({ isFormValid } = await validateFields(fieldsToValidate.map((field) => field.path))); } - ({ isFormValid } = await validateFields(fieldsToValidate.map((field) => field.path))); - + setIsValid(isFormValid); return isFormValid!; }, [fieldsToArray, validateFields]); @@ -236,11 +236,13 @@ export function useForm( (field) => { fieldsRefs.current[field.path] = field; - if (!{}.hasOwnProperty.call(getFormData$().value, field.path)) { - updateFormDataAt(field.path, field.value); + updateFormDataAt(field.path, field.value); + + if (!field.isValidated) { + setIsValid(undefined); } }, - [getFormData$, updateFormDataAt] + [updateFormDataAt] ); const removeField: FormHook['__removeField'] = useCallback( @@ -259,9 +261,16 @@ export function useForm( * After removing a field, the form validity might have changed * (an invalid field might have been removed and now the form is valid) */ - updateFormValidity(); + setIsValid((prev) => { + if (prev === false) { + const isFormValid = fieldsToArray().every(isFieldValid); + return isFormValid; + } + // If the form validity is "true" or "undefined", it does not change after removing a field + return prev; + }); }, - [getFormData$, updateFormValidity] + [getFormData$, fieldsToArray] ); const setFieldValue: FormHook['setFieldValue'] = useCallback((fieldName, value) => { @@ -310,7 +319,7 @@ export function useForm( await onSubmit(formData, isFormValid!); } - if (isUnmounted.current === false) { + if (isMounted.current) { setSubmitting(false); } @@ -322,9 +331,7 @@ export function useForm( const subscribe: FormHook['subscribe'] = useCallback( (handler) => { const subscription = getFormData$().subscribe((raw) => { - if (!isUnmounted.current) { - handler({ isValid, data: { raw, format: getFormData }, validate: validateAllFields }); - } + handler({ isValid, data: { raw, format: getFormData }, validate: validateAllFields }); }); formUpdateSubscribers.current.push(subscription); @@ -351,9 +358,7 @@ export function useForm( const currentFormData = { ...getFormData$().value } as FormData; if (updatedDefaultValue) { - defaultValueDeserialized.current = deserializer - ? (deserializer(updatedDefaultValue) as any) - : updatedDefaultValue; + defaultValueDeserialized.current = initDefaultValue(updatedDefaultValue); } Object.entries(fieldsRefs.current).forEach(([path, field]) => { @@ -374,7 +379,7 @@ export function useForm( setSubmitting(false); setIsValid(undefined); }, - [getFormData$, deserializer, getFieldDefaultValue] + [getFormData$, initDefaultValue, getFieldDefaultValue] ); const form = useMemo>(() => { @@ -425,6 +430,25 @@ export function useForm( validateFields, ]); + useEffect(() => { + if (!isMounted.current) { + return; + } + + // Whenever the "defaultValue" prop changes, reinitialize our ref + defaultValueDeserialized.current = defaultValueMemoized; + }, [defaultValueMemoized]); + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + formUpdateSubscribers.current.forEach((subscription) => subscription.unsubscribe()); + formUpdateSubscribers.current = []; + }; + }, []); + return { form, }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index 4b203c3927ffd..dc495f6eb56b4 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -38,7 +38,7 @@ export interface FormHook { getFieldDefaultValue: (fieldName: string) => unknown; /* Returns a list of all errors in the form */ getErrors: () => string[]; - reset: (options?: { resetValues?: boolean; defaultValue?: FormData }) => void; + reset: (options?: { resetValues?: boolean; defaultValue?: Partial }) => void; readonly __options: Required; __getFormData$: () => Subject; __addField: (field: FieldHook) => void; @@ -102,7 +102,6 @@ export interface FieldHook { readonly isValidating: boolean; readonly isValidated: boolean; readonly isChangingValue: boolean; - readonly form: FormHook; getErrorsMessages: (args?: { validationType?: 'field' | string; errorCode?: string; @@ -117,7 +116,7 @@ export interface FieldHook { validationType?: string; }) => FieldValidateResponse | Promise; reset: (options?: { resetValue?: boolean; defaultValue?: T }) => unknown | undefined; - __serializeOutput: (rawValue?: unknown) => unknown; + __serializeValue: (rawValue?: unknown) => unknown; } export interface FieldConfig { @@ -154,7 +153,10 @@ export interface ValidationError { export interface ValidationFuncArg { path: string; value: V; - form: FormHook; + form: { + getFormData: FormHook['getFormData']; + getFields: FormHook['getFields']; + }; formData: T; errors: readonly ValidationError[]; } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts index f92f46d71e7c7..870b8b7ec5509 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts @@ -85,6 +85,9 @@ export const getFormActions = (testBed: TestBed) => { value: type, }, ]); + }); + + await act(async () => { find('createFieldForm.addButton').simulate('click'); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/name_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/name_parameter.tsx index 0320f2ff51da3..9b27b930b47c4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/name_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/name_parameter.tsx @@ -4,33 +4,42 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import { TextField, UseField, FieldConfig } from '../../../shared_imports'; import { validateUniqueName } from '../../../lib'; import { PARAMETERS_DEFINITION } from '../../../constants'; import { useMappingsState } from '../../../mappings_state_context'; +const { validations, ...rest } = PARAMETERS_DEFINITION.name.fieldConfig as FieldConfig; + export const NameParameter = () => { const { fields: { rootLevelFields, byId }, documentFields: { fieldToAddFieldTo, fieldToEdit }, } = useMappingsState(); - const { validations, ...rest } = PARAMETERS_DEFINITION.name.fieldConfig as FieldConfig; const initialName = fieldToEdit ? byId[fieldToEdit].source.name : undefined; const parentId = fieldToEdit ? byId[fieldToEdit].parentId : fieldToAddFieldTo; - const uniqueNameValidator = validateUniqueName({ rootLevelFields, byId }, initialName, parentId); + const uniqueNameValidator = useCallback( + (arg: any) => { + return validateUniqueName({ rootLevelFields, byId }, initialName, parentId)(arg); + }, + [rootLevelFields, byId, initialName, parentId] + ); - const nameConfig: FieldConfig = { - ...rest, - validations: [ - ...validations!, - { - validator: uniqueNameValidator, - }, - ], - }; + const nameConfig: FieldConfig = useMemo( + () => ({ + ...rest, + validations: [ + ...validations!, + { + validator: uniqueNameValidator, + }, + ], + }), + [uniqueNameValidator] + ); return ( { const suggestedFields = getSuggestedFields(allFields, field); + const fieldConfig = useMemo( + () => ({ + ...getFieldConfig('path'), + deserializer: getDeserializer(allFields), + }), + [allFields] + ); + return ( - + {(pathField) => { const error = pathField.getErrorsMessages(); const isInvalid = error ? Boolean(error.length) : false; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx index 95575124b6abd..6b5a848ce85d3 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx @@ -163,7 +163,7 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit, updateF - {form.isSubmitted && !form.isValid && ( + {form.isSubmitted && form.isValid === false && ( <> {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldUpdateButtonLabel', { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_header_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_header_form.tsx index f2ad37cb45818..3b55c5ac076c2 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_header_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_header_form.tsx @@ -59,6 +59,9 @@ export const EditFieldHeaderForm = React.memo( {({ type, subType }) => { + if (!type) { + return null; + } const typeDefinition = TYPE_DEFINITION[type[0].value as MainType]; const hasSubType = typeDefinition.subTypes !== undefined; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processor_settings_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processor_settings_fields.tsx index 6a70592bc2f70..9adb3957ea9f4 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processor_settings_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processor_settings_fields.tsx @@ -35,7 +35,7 @@ export const ProcessorSettingsFields: FunctionComponent = ({ processor }) if (formDescriptor?.FieldsComponent) { return ( <> - + diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/append.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/append.tsx index 09d0981adf1c2..23425297f3420 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/append.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/append.tsx @@ -21,6 +21,7 @@ const { emptyField } = fieldValidators; const fieldsConfig: FieldsConfig = { value: { + defaultValue: [], type: FIELD_TYPES.COMBO_BOX, deserializer: to.arrayOfStrings, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.appendForm.valueFieldLabel', { diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 9ead8171bfef6..c7810af13eb74 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -23,7 +23,7 @@ import { createKibanaContextProviderMock, createStartServicesMock, } from '../lib/kibana/kibana_react.mock'; -import { FieldHook, useForm } from '../../shared_imports'; +import { FieldHook } from '../../shared_imports'; import { SUB_PLUGINS_REDUCER } from './utils'; import { createSecuritySolutionStorageMock, localStorageMock } from './mock_local_storage'; @@ -78,8 +78,6 @@ const TestProvidersComponent: React.FC = ({ export const TestProviders = React.memo(TestProvidersComponent); export const useFormFieldMock = (options?: Partial): FieldHook => { - const { form } = useForm(); - return { path: 'path', type: 'type', @@ -88,7 +86,6 @@ export const useFormFieldMock = (options?: Partial): FieldHook => { isValidating: false, isValidated: false, isChangingValue: false, - form, errors: [], isValid: true, getErrorsMessages: jest.fn(), @@ -98,7 +95,7 @@ export const useFormFieldMock = (options?: Partial): FieldHook => { clearErrors: jest.fn(), validate: jest.fn(), reset: jest.fn(), - __serializeOutput: jest.fn(), + __serializeValue: jest.fn(), ...options, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/index.tsx index a0384ef52a841..cdeca54bfc39b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/index.tsx @@ -13,13 +13,13 @@ import { EuiFormLabel, EuiIcon, EuiSpacer, + EuiRange, } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { noop } from 'lodash/fp'; import * as i18n from './translations'; import { FieldHook } from '../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; -import { CommonUseField } from '../../../../cases/components/create'; import { AboutStepRiskScore } from '../../../pages/detection_engine/rules/types'; import { FieldComponent } from '../../../../common/components/autocomplete/field'; import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; @@ -59,11 +59,12 @@ export const RiskScoreField = ({ placeholder, }: RiskScoreFieldProps) => { const fieldTypeFilter = useMemo(() => ['number'], []); + const { value: fieldValue, setValue } = field; const handleFieldChange = useCallback( ([newField]: IFieldType[]): void => { - const values = field.value as AboutStepRiskScore; - field.setValue({ + const values = fieldValue as AboutStepRiskScore; + setValue({ value: values.value, isMappingChecked: values.isMappingChecked, mapping: [ @@ -76,25 +77,37 @@ export const RiskScoreField = ({ ], }); }, - [field] + [setValue, fieldValue] + ); + + const handleRangeFieldChange = useCallback( + (e: React.ChangeEvent | React.MouseEvent): void => { + const range = (e.target as HTMLInputElement).value; + setValue({ + value: range.trim() === '' ? '' : +range, + isMappingChecked: (fieldValue as AboutStepRiskScore).isMappingChecked, + mapping: (fieldValue as AboutStepRiskScore).mapping, + }); + }, + [fieldValue, setValue] ); const selectedField = useMemo(() => { - const existingField = (field.value as AboutStepRiskScore).mapping?.[0]?.field ?? ''; + const existingField = (fieldValue as AboutStepRiskScore).mapping?.[0]?.field ?? ''; const [newSelectedField] = indices.fields.filter( ({ name }) => existingField != null && existingField === name ); return newSelectedField; - }, [field.value, indices]); + }, [fieldValue, indices]); const handleRiskScoreMappingChecked = useCallback(() => { - const values = field.value as AboutStepRiskScore; - field.setValue({ + const values = fieldValue as AboutStepRiskScore; + setValue({ value: values.value, mapping: [...values.mapping], isMappingChecked: !values.isMappingChecked, }); - }, [field]); + }, [fieldValue, setValue]); const riskScoreLabel = useMemo(() => { return ( @@ -119,7 +132,7 @@ export const RiskScoreField = ({ @@ -132,7 +145,7 @@ export const RiskScoreField = ({ ); - }, [field.value, handleRiskScoreMappingChecked, isDisabled]); + }, [fieldValue, handleRiskScoreMappingChecked, isDisabled]); return ( @@ -144,24 +157,20 @@ export const RiskScoreField = ({ error={'errorMessage'} isInvalid={false} fullWidth - data-test-subj={dataTestSubj} - describedByIds={idAria ? [idAria] : undefined} + data-test-subj="detectionEngineStepAboutRuleRiskScore" + describedByIds={['detectionEngineStepAboutRuleRiskScore']} > - @@ -170,7 +179,7 @@ export const RiskScoreField = ({ label={riskScoreMappingLabel} labelAppend={field.labelAppend} helpText={ - (field.value as AboutStepRiskScore).isMappingChecked ? ( + (fieldValue as AboutStepRiskScore).isMappingChecked ? ( {i18n.RISK_SCORE_MAPPING_DETAILS} ) : ( '' @@ -184,7 +193,7 @@ export const RiskScoreField = ({ > - {(field.value as AboutStepRiskScore).isMappingChecked && ( + {(fieldValue as AboutStepRiskScore).isMappingChecked && ( diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx index a9bde76126b6e..20c3073789b2a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { RuleActionsField } from './index'; +import { useForm, Form } from '../../../../shared_imports'; import { useKibana } from '../../../../common/lib/kibana'; import { useFormFieldMock } from '../../../../common/mock'; jest.mock('../../../../common/lib/kibana'); @@ -32,8 +33,13 @@ describe('RuleActionsField', () => { }); const Component = () => { const field = useFormFieldMock(); + const { form } = useForm(); - return ; + return ( +
+ + + ); }; const wrapper = shallow(); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx index c6ff25f311d9c..b9097949bd20a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx @@ -12,7 +12,7 @@ import ReactMarkdown from 'react-markdown'; import styled from 'styled-components'; import { NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS } from '../../../../../common/constants'; -import { SelectField } from '../../../../shared_imports'; +import { SelectField, useFormContext } from '../../../../shared_imports'; import { ActionForm, ActionType, @@ -37,6 +37,8 @@ const FieldErrorsContainer = styled.div` export const RuleActionsField: ThrottleSelectField = ({ field, messageVariables }) => { const [fieldErrors, setFieldErrors] = useState(null); const [supportedActionTypes, setSupportedActionTypes] = useState(); + const form = useFormContext(); + const { isSubmitted, isSubmitting, isValid } = form; const { http, triggers_actions_ui: { actionTypeRegistry }, @@ -88,26 +90,14 @@ export const RuleActionsField: ThrottleSelectField = ({ field, messageVariables }, []); useEffect(() => { - if (field.form.isSubmitting || !field.errors.length) { + if (isSubmitting || !field.errors.length) { return setFieldErrors(null); } - if ( - field.form.isSubmitted && - !field.form.isSubmitting && - field.form.isValid === false && - field.errors.length - ) { + if (isSubmitted && !isSubmitting && isValid === false && field.errors.length) { const errorsString = field.errors.map(({ message }) => message).join('\n'); return setFieldErrors(errorsString); } - }, [ - field.form.isSubmitted, - field.form.isSubmitting, - field.isChangingValue, - field.form.isValid, - field.errors, - setFieldErrors, - ]); + }, [isSubmitted, isSubmitting, field.isChangingValue, isValid, field.errors, setFieldErrors]); if (!supportedActionTypes) return <>; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/severity_mapping/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/severity_mapping/index.tsx index 733e701cff204..70e66af25f69e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/severity_mapping/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/severity_mapping/index.tsx @@ -13,6 +13,7 @@ import { EuiFormLabel, EuiIcon, EuiSpacer, + EuiSuperSelect, } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; @@ -20,7 +21,6 @@ import styled from 'styled-components'; import * as i18n from './translations'; import { FieldHook } from '../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; import { SeverityOptionItem } from '../step_about_rule/data'; -import { CommonUseField } from '../../../../cases/components/create'; import { AboutStepSeverity } from '../../../pages/detection_engine/rules/types'; import { IFieldType, @@ -68,58 +68,61 @@ export const SeverityField = ({ options, }: SeverityFieldProps) => { const fieldValueInputWidth = 160; + const { setValue } = field; + const { value, isMappingChecked, mapping } = field.value as AboutStepSeverity; const handleFieldValueChange = useCallback( (newMappingItems: SeverityMapping, index: number): void => { - const values = field.value as AboutStepSeverity; - field.setValue({ - value: values.value, - isMappingChecked: values.isMappingChecked, - mapping: [ - ...values.mapping.slice(0, index), - ...newMappingItems, - ...values.mapping.slice(index + 1), - ], + setValue({ + value, + isMappingChecked, + mapping: [...mapping.slice(0, index), ...newMappingItems, ...mapping.slice(index + 1)], }); }, - [field] + [value, isMappingChecked, mapping, setValue] ); const handleFieldChange = useCallback( (index: number, severity: Severity, [newField]: IFieldType[]): void => { - const values = field.value as AboutStepSeverity; const newMappingItems: SeverityMapping = [ { - ...values.mapping[index], + ...mapping[index], field: newField?.name ?? '', - value: newField != null ? values.mapping[index].value : '', + value: newField != null ? mapping[index].value : '', operator: 'equals', severity, }, ]; handleFieldValueChange(newMappingItems, index); }, - [field, handleFieldValueChange] + [mapping, handleFieldValueChange] + ); + + const handleSecurityLevelChange = useCallback( + (newValue: string) => { + setValue({ + value: newValue, + isMappingChecked, + mapping, + }); + }, + [isMappingChecked, mapping, setValue] ); const handleFieldMatchValueChange = useCallback( (index: number, severity: Severity, newMatchValue: string): void => { - const values = field.value as AboutStepSeverity; const newMappingItems: SeverityMapping = [ { - ...values.mapping[index], - field: values.mapping[index].field, - value: - values.mapping[index].field != null && values.mapping[index].field !== '' - ? newMatchValue - : '', + ...mapping[index], + field: mapping[index].field, + value: mapping[index].field != null && mapping[index].field !== '' ? newMatchValue : '', operator: 'equals', severity, }, ]; handleFieldValueChange(newMappingItems, index); }, - [field, handleFieldValueChange] + [mapping, handleFieldValueChange] ); const getIFieldTypeFromFieldName = ( @@ -131,13 +134,12 @@ export const SeverityField = ({ }; const handleSeverityMappingChecked = useCallback(() => { - const values = field.value as AboutStepSeverity; - field.setValue({ - value: values.value, - mapping: [...values.mapping], - isMappingChecked: !values.isMappingChecked, + setValue({ + value, + mapping: [...mapping], + isMappingChecked: !isMappingChecked, }); - }, [field]); + }, [isMappingChecked, mapping, value, setValue]); const severityLabel = useMemo(() => { return ( @@ -162,7 +164,7 @@ export const SeverityField = ({ @@ -175,7 +177,7 @@ export const SeverityField = ({
); - }, [field.value, handleSeverityMappingChecked, isDisabled]); + }, [handleSeverityMappingChecked, isDisabled, isMappingChecked]); return ( @@ -187,21 +189,16 @@ export const SeverityField = ({ error={'errorMessage'} isInvalid={false} fullWidth - data-test-subj={dataTestSubj} - describedByIds={idAria ? [idAria] : undefined} + data-test-subj="detectionEngineStepAboutRuleSeverity" + describedByIds={['detectionEngineStepAboutRuleSeverity']} > - @@ -211,11 +208,7 @@ export const SeverityField = ({ label={severityMappingLabel} labelAppend={field.labelAppend} helpText={ - (field.value as AboutStepSeverity).isMappingChecked ? ( - {i18n.SEVERITY_MAPPING_DETAILS} - ) : ( - '' - ) + isMappingChecked ? {i18n.SEVERITY_MAPPING_DETAILS} : '' } error={'errorMessage'} isInvalid={false} @@ -225,7 +218,7 @@ export const SeverityField = ({ > - {(field.value as AboutStepSeverity).isMappingChecked && ( + {isMappingChecked && ( @@ -242,71 +235,69 @@ export const SeverityField = ({ - {(field.value as AboutStepSeverity).mapping.map( - (severityMappingItem: SeverityMappingItem, index) => ( - - - - - + {mapping.map((severityMappingItem: SeverityMappingItem, index) => ( + + + + + - - - - - - - - { - options.find((o) => o.value === severityMappingItem.severity) - ?.inputDisplay - } - - - - ) - )} + + + + + + + + { + options.find((o) => o.value === severityMappingItem.severity) + ?.inputDisplay + } + + + + ))} )} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx index cb3fd5e5bec32..0c834b9fff33a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mount, shallow } from 'enzyme'; import { ThemeProvider } from 'styled-components'; import euiDarkVars from '@elastic/eui/dist/eui_theme_light.json'; @@ -223,32 +224,33 @@ describe('StepAboutRuleComponent', () => { .first() .simulate('change', { target: { value: '80' } }); - wrapper.find('[data-test-subj="about-continue"]').first().simulate('click').update(); - await waitFor(() => { - const expected: Omit = { - author: [], - isAssociatedToEndpointList: false, - isBuildingBlock: false, - license: '', - ruleNameOverride: '', - timestampOverride: '', - description: 'Test description text', - falsePositives: [''], - name: 'Test name text', - note: '', - references: [''], - riskScore: { value: 80, mapping: [], isMappingChecked: false }, - severity: { value: 'low', mapping: fillEmptySeverityMappings([]), isMappingChecked: false }, - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { id: 'none', name: 'none', reference: 'none' }, - technique: [], - }, - ], - }; - expect(stepDataMock.mock.calls[1][1]).toEqual(expected); + await act(async () => { + wrapper.find('[data-test-subj="about-continue"]').first().simulate('click').update(); }); + + const expected: Omit = { + author: [], + isAssociatedToEndpointList: false, + isBuildingBlock: false, + license: '', + ruleNameOverride: '', + timestampOverride: '', + description: 'Test description text', + falsePositives: [''], + name: 'Test name text', + note: '', + references: [''], + riskScore: { value: 80, mapping: [], isMappingChecked: false }, + severity: { value: 'low', mapping: fillEmptySeverityMappings([]), isMappingChecked: false }, + tags: [], + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { id: 'none', name: 'none', reference: 'none' }, + technique: [], + }, + ], + }; + expect(stepDataMock.mock.calls[1][1]).toEqual(expected); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx index a3db8fe659d84..2264a11341eb8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx @@ -102,29 +102,12 @@ export const schema: FormSchema = { labelAppend: OptionalFieldLabel, }, severity: { - value: { - type: FIELD_TYPES.SUPER_SELECT, - validations: [ - { - validator: emptyField( - i18n.translate( - 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.severityFieldRequiredError', - { - defaultMessage: 'A severity is required.', - } - ) - ), - }, - ], - }, + value: {}, mapping: {}, isMappingChecked: {}, }, riskScore: { - value: { - type: FIELD_TYPES.RANGE, - serializer: (input: string) => Number(input), - }, + value: {}, mapping: {}, isMappingChecked: {}, }, diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index b2c7319b94576..097166a9c866a 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -20,6 +20,7 @@ export { UseField, UseMultiFields, useForm, + useFormContext, ValidationFunc, VALIDATION_TYPES, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d6e611e65154b..c99980fe6205c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -15631,7 +15631,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.guideLabel": "調査ガイド", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.nameFieldRequiredError": "名前が必要です。", "xpack.securitySolution.detectionEngine.createRule.stepAboutrule.noteHelpText": "ルール調査ガイドを追加...", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.severityFieldRequiredError": "深刻度が必要です。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRuleForm.addFalsePositiveDescription": "誤検出の例を追加します", "xpack.securitySolution.detectionEngine.createRule.stepAboutRuleForm.addReferenceDescription": "参照URLを追加します", "xpack.securitySolution.detectionEngine.createRule.stepAboutRuleForm.advancedSettingsButton": "高度な設定", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 54c69d849e3a9..9ffa81a921ba8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -15637,7 +15637,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.guideLabel": "调查指南", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.nameFieldRequiredError": "名称必填。", "xpack.securitySolution.detectionEngine.createRule.stepAboutrule.noteHelpText": "添加规则调查指南......", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.severityFieldRequiredError": "严重性必填。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRuleForm.addFalsePositiveDescription": "添加误报示例", "xpack.securitySolution.detectionEngine.createRule.stepAboutRuleForm.addReferenceDescription": "添加引用 URL", "xpack.securitySolution.detectionEngine.createRule.stepAboutRuleForm.advancedSettingsButton": "高级设置", From b802af800268bfa8a008d129b99dfd496b87b276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Thu, 27 Aug 2020 14:15:59 +0200 Subject: [PATCH 53/56] [ILM] Fix json in request flyout (#75971) Co-authored-by: Elastic Machine --- .../__jest__/components/edit_policy.test.js | 28 ++++++++++++++++ .../components/policy_json_flyout.tsx | 32 +++++++++++-------- .../sections/edit_policy/edit_policy.tsx | 3 +- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js index 81c30579cd4dd..e4227bac520fe 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js @@ -187,6 +187,34 @@ describe('edit policy', () => { save(rendered); expectedErrorMessages(rendered, [policyNameStartsWithUnderscoreErrorMessage]); }); + test('should show correct json in policy flyout', () => { + const rendered = mountWithIntl(component); + findTestSubject(rendered, 'requestButton').simulate('click'); + const json = rendered.find(`code`).text(); + const expected = `PUT _ilm/policy/\n${JSON.stringify( + { + policy: { + phases: { + hot: { + min_age: '0ms', + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + }, + }, + }, + null, + 2 + )}`; + expect(json).toBe(expected); + }); }); describe('hot phase', () => { test('should show errors when trying to save with no max size and no max age', () => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx index 66cb4ad9fba32..2f246f21aaf2e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx @@ -18,29 +18,35 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import { Policy } from '../../../services/policies/types'; +import { Policy, PolicyFromES } from '../../../services/policies/types'; +import { serializePolicy } from '../../../services/policies/policy_serialization'; interface Props { close: () => void; policy: Policy; + existingPolicy?: PolicyFromES; policyName: string; } -export const PolicyJsonFlyout: React.FunctionComponent = ({ close, policy, policyName }) => { - const getEsJson = ({ phases }: Policy) => { - return JSON.stringify( - { - policy: { - phases, - }, +export const PolicyJsonFlyout: React.FunctionComponent = ({ + close, + policy, + policyName, + existingPolicy, +}) => { + const { phases } = serializePolicy(policy, existingPolicy?.policy); + const json = JSON.stringify( + { + policy: { + phases, }, - null, - 2 - ); - }; + }, + null, + 2 + ); const endpoint = `PUT _ilm/policy/${policyName || ''}`; - const request = `${endpoint}\n${getEsJson(policy)}`; + const request = `${endpoint}\n${json}`; return ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 6cffde577b35e..c99d01b546679 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -352,7 +352,7 @@ export const EditPolicy: React.FunctionComponent = ({ - + {isShowingPolicyJsonFlyout ? ( = ({ {isShowingPolicyJsonFlyout ? ( setIsShowingPolicyJsonFlyout(false)} /> From f065191a750639179a63b77df0cc95261c920812 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Thu, 27 Aug 2020 08:44:41 -0400 Subject: [PATCH 54/56] [Enterprise Search] Added an App Search route for listing Credentials (#75487) In addition to a route for listing Credentials, this also adds a utility function which helps create API routes which simply proxy the App Search API. The reasoning for this is as follows; 1. Creating new routes takes less effort and cognitive load if we can simply just create proxy routes that use the APIs as is. 2. It keeps the App Search API as the source of truth. All logic is implemented in the underlying API. 3. It makes unit testing routes much simpler. We do not need to verify any connectivity to the underlying App Search API, because that is already tested as part of the utility. --- .../server/{routes => }/__mocks__/index.ts | 0 .../{routes => }/__mocks__/router.mock.ts | 0 .../__mocks__/routerDependencies.mock.ts | 2 +- .../collectors/app_search/telemetry.test.ts | 2 +- .../server/collectors/lib/telemetry.test.ts | 2 +- .../workplace_search/telemetry.test.ts | 2 +- .../enterprise_search_request_handler.test.ts | 133 ++++++++++++++++++ .../lib/enterprise_search_request_handler.ts | 69 +++++++++ .../enterprise_search/server/plugin.ts | 2 + .../routes/app_search/credentials.test.ts | 93 ++++++++++++ .../server/routes/app_search/credentials.ts | 50 +++++++ .../server/routes/app_search/engines.test.ts | 2 +- .../enterprise_search/config_data.test.ts | 2 +- .../enterprise_search/telemetry.test.ts | 2 +- .../routes/workplace_search/overview.test.ts | 2 +- 15 files changed, 355 insertions(+), 8 deletions(-) rename x-pack/plugins/enterprise_search/server/{routes => }/__mocks__/index.ts (100%) rename x-pack/plugins/enterprise_search/server/{routes => }/__mocks__/router.mock.ts (100%) rename x-pack/plugins/enterprise_search/server/{routes => }/__mocks__/routerDependencies.mock.ts (95%) create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/credentials.ts diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts b/x-pack/plugins/enterprise_search/server/__mocks__/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/server/routes/__mocks__/index.ts rename to x-pack/plugins/enterprise_search/server/__mocks__/index.ts diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts similarity index 100% rename from x-pack/plugins/enterprise_search/server/routes/__mocks__/router.mock.ts rename to x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts diff --git a/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts similarity index 95% rename from x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts rename to x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts index 9b6fa30271d61..7a244be96cfc4 100644 --- a/x-pack/plugins/enterprise_search/server/routes/__mocks__/routerDependencies.mock.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts @@ -5,7 +5,7 @@ */ import { loggingSystemMock } from 'src/core/server/mocks'; -import { ConfigType } from '../../'; +import { ConfigType } from '../'; export const mockLogger = loggingSystemMock.createLogger().get(); diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts index 53c6dee61cd1d..189f8278f1b07 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mockLogger } from '../../routes/__mocks__'; +import { mockLogger } from '../../__mocks__'; import { registerTelemetryUsageCollector } from './telemetry'; diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts index 3ab3b03dd7725..aae162c23ccb4 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mockLogger } from '../../routes/__mocks__'; +import { mockLogger } from '../../__mocks__'; jest.mock('../../../../../../src/core/server', () => ({ SavedObjectsErrorHelpers: { diff --git a/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts index 496b2f254f9a6..8960d6fa9b67b 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mockLogger } from '../../routes/__mocks__'; +import { mockLogger } from '../../__mocks__'; import { registerTelemetryUsageCollector } from './telemetry'; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts new file mode 100644 index 0000000000000..f0c003936996e --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -0,0 +1,133 @@ +/* + * 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 { mockConfig, mockLogger } from '../__mocks__'; + +import { createEnterpriseSearchRequestHandler } from './enterprise_search_request_handler'; + +jest.mock('node-fetch'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const fetchMock = require('node-fetch') as jest.Mock; +const { Response } = jest.requireActual('node-fetch'); + +const responseMock = { + ok: jest.fn(), + customError: jest.fn(), +}; +const KibanaAuthHeader = 'Basic 123'; + +describe('createEnterpriseSearchRequestHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.mockReset(); + }); + + it('makes an API call and returns the response', async () => { + const responseBody = { + results: [{ name: 'engine1' }], + meta: { page: { total_results: 1 } }, + }; + + EnterpriseSearchAPI.mockReturn(responseBody); + + const requestHandler = createEnterpriseSearchRequestHandler({ + config: mockConfig, + log: mockLogger, + path: '/as/credentials/collection', + }); + + await makeAPICall(requestHandler, { + query: { + type: 'indexed', + pageIndex: 1, + }, + }); + + EnterpriseSearchAPI.shouldHaveBeenCalledWith( + 'http://localhost:3002/as/credentials/collection?type=indexed&pageIndex=1' + ); + + expect(responseMock.ok).toHaveBeenCalledWith({ + body: responseBody, + }); + }); + + describe('when an API request fails', () => { + it('should return 502 with a message', async () => { + EnterpriseSearchAPI.mockReturnError(); + + const requestHandler = createEnterpriseSearchRequestHandler({ + config: mockConfig, + log: mockLogger, + path: '/as/credentials/collection', + }); + + await makeAPICall(requestHandler); + + EnterpriseSearchAPI.shouldHaveBeenCalledWith( + 'http://localhost:3002/as/credentials/collection' + ); + + expect(responseMock.customError).toHaveBeenCalledWith({ + body: 'Error connecting or fetching data from Enterprise Search', + statusCode: 502, + }); + }); + }); + + describe('when `hasValidData` fails', () => { + it('should return 502 with a message', async () => { + const responseBody = { + foo: 'bar', + }; + + EnterpriseSearchAPI.mockReturn(responseBody); + + const requestHandler = createEnterpriseSearchRequestHandler({ + config: mockConfig, + log: mockLogger, + path: '/as/credentials/collection', + hasValidData: (body?: any) => + Array.isArray(body?.results) && typeof body?.meta?.page?.total_results === 'number', + }); + + await makeAPICall(requestHandler); + + EnterpriseSearchAPI.shouldHaveBeenCalledWith( + 'http://localhost:3002/as/credentials/collection' + ); + + expect(responseMock.customError).toHaveBeenCalledWith({ + body: 'Error connecting or fetching data from Enterprise Search', + statusCode: 502, + }); + }); + }); +}); + +const makeAPICall = (handler: Function, params = {}) => { + const request = { headers: { authorization: KibanaAuthHeader }, ...params }; + return handler(null, request, responseMock); +}; + +const EnterpriseSearchAPI = { + shouldHaveBeenCalledWith(expectedUrl: string, expectedParams = {}) { + expect(fetchMock).toHaveBeenCalledWith(expectedUrl, { + headers: { Authorization: KibanaAuthHeader }, + ...expectedParams, + }); + }, + mockReturn(response: object) { + fetchMock.mockImplementation(() => { + return Promise.resolve(new Response(JSON.stringify(response))); + }); + }, + mockReturnError() { + fetchMock.mockImplementation(() => { + return Promise.reject('Failed'); + }); + }, +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts new file mode 100644 index 0000000000000..11152aa651743 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -0,0 +1,69 @@ +/* + * 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 fetch from 'node-fetch'; +import querystring from 'querystring'; +import { + RequestHandlerContext, + KibanaRequest, + KibanaResponseFactory, + Logger, +} from 'src/core/server'; +import { ConfigType } from '../index'; + +interface IEnterpriseSearchRequestParams { + config: ConfigType; + log: Logger; + path: string; + hasValidData?: (body?: ResponseBody) => boolean; +} + +/** + * This helper function creates a single standard DRY way of handling + * Enterprise Search API requests. + * + * This handler assumes that it will essentially just proxy the + * Enterprise Search API request, so the request body and request + * parameters are simply passed through. + */ +export function createEnterpriseSearchRequestHandler({ + config, + log, + path, + hasValidData = () => true, +}: IEnterpriseSearchRequestParams) { + return async ( + _context: RequestHandlerContext, + request: KibanaRequest, unknown>, + response: KibanaResponseFactory + ) => { + try { + const enterpriseSearchUrl = config.host as string; + const params = request.query ? `?${querystring.stringify(request.query)}` : ''; + const url = `${encodeURI(enterpriseSearchUrl)}${path}${params}`; + + const apiResponse = await fetch(url, { + headers: { Authorization: request.headers.authorization as string }, + }); + + const body = await apiResponse.json(); + + if (hasValidData(body)) { + return response.ok({ body }); + } else { + throw new Error(`Invalid data received: ${JSON.stringify(body)}`); + } + } catch (e) { + log.error(`Cannot connect to Enterprise Search: ${e.toString()}`); + if (e instanceof Error) log.debug(e.stack as string); + + return response.customError({ + statusCode: 502, + body: 'Error connecting or fetching data from Enterprise Search', + }); + } + }; +} diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index a0d3a57eabb7a..ef8c72f0cbca5 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -32,6 +32,7 @@ import { registerTelemetryRoute } from './routes/enterprise_search/telemetry'; import { appSearchTelemetryType } from './saved_objects/app_search/telemetry'; import { registerTelemetryUsageCollector as registerASTelemetryUsageCollector } from './collectors/app_search/telemetry'; import { registerEnginesRoute } from './routes/app_search/engines'; +import { registerCredentialsRoutes } from './routes/app_search/credentials'; import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/telemetry'; import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry'; @@ -108,6 +109,7 @@ export class EnterpriseSearchPlugin implements Plugin { registerConfigDataRoute(dependencies); registerEnginesRoute(dependencies); + registerCredentialsRoutes(dependencies); registerWSOverviewRoute(dependencies); /** diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts new file mode 100644 index 0000000000000..682c17aea6d52 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts @@ -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 { MockRouter, mockConfig, mockLogger } from '../../__mocks__'; + +import { registerCredentialsRoutes } from './credentials'; + +jest.mock('../../lib/enterprise_search_request_handler', () => ({ + createEnterpriseSearchRequestHandler: jest.fn(), +})); +import { createEnterpriseSearchRequestHandler } from '../../lib/enterprise_search_request_handler'; + +describe('credentials routes', () => { + describe('GET /api/app_search/credentials', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + + registerCredentialsRoutes({ + router: mockRouter.router, + log: mockLogger, + config: mockConfig, + }); + }); + + it('creates a handler with createEnterpriseSearchRequestHandler', () => { + expect(createEnterpriseSearchRequestHandler).toHaveBeenCalledWith({ + config: mockConfig, + log: mockLogger, + path: '/as/credentials/collection', + hasValidData: expect.any(Function), + }); + }); + + describe('hasValidData', () => { + it('should correctly validate that a response has data', () => { + const response = { + meta: { + page: { + current: 1, + total_pages: 1, + total_results: 1, + size: 25, + }, + }, + results: [ + { + id: 'loco_moco_account_id:5f3575de2b76ff13405f3155|name:asdfasdf', + key: 'search-fe49u2z8d5gvf9s4ekda2ad4', + name: 'asdfasdf', + type: 'search', + access_all_engines: true, + }, + ], + }; + + const { + hasValidData, + } = (createEnterpriseSearchRequestHandler as jest.Mock).mock.calls[0][0]; + + expect(hasValidData(response)).toBe(true); + }); + + it('should correctly validate that a response does not have data', () => { + const response = { + foo: 'bar', + }; + + const hasValidData = (createEnterpriseSearchRequestHandler as jest.Mock).mock.calls[0][0] + .hasValidData; + + expect(hasValidData(response)).toBe(false); + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { query: { 'page[current]': 1 } }; + mockRouter.shouldValidate(request); + }); + + it('missing page[current]', () => { + const request = { query: {} }; + mockRouter.shouldThrow(request); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.ts new file mode 100644 index 0000000000000..d9539692069f0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.ts @@ -0,0 +1,50 @@ +/* + * 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 { IRouteDependencies } from '../../plugin'; +import { createEnterpriseSearchRequestHandler } from '../../lib/enterprise_search_request_handler'; + +interface ICredential { + id: string; + key: string; + name: string; + type: string; + access_all_engines: boolean; +} +interface ICredentialsResponse { + results: ICredential[]; + meta?: { + page?: { + current: number; + total_results: number; + total_pages: number; + size: number; + }; + }; +} + +export function registerCredentialsRoutes({ router, config, log }: IRouteDependencies) { + router.get( + { + path: '/api/app_search/credentials', + validate: { + query: schema.object({ + 'page[current]': schema.number(), + }), + }, + }, + createEnterpriseSearchRequestHandler({ + config, + log, + path: '/as/credentials/collection', + hasValidData: (body?: ICredentialsResponse) => { + return Array.isArray(body?.results) && typeof body?.meta?.page?.total_results === 'number'; + }, + }) + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index 1ea023ecacdbe..03edab89d1b99 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; +import { MockRouter, mockConfig, mockLogger } from '../../__mocks__'; import { registerEnginesRoute } from './engines'; diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.test.ts index 7484e27594df4..253c9a418d60b 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.test.ts @@ -5,7 +5,7 @@ */ import { DEFAULT_INITIAL_APP_DATA } from '../../../common/__mocks__'; -import { MockRouter, mockDependencies } from '../__mocks__'; +import { MockRouter, mockDependencies } from '../../__mocks__'; jest.mock('../../lib/enterprise_search_config_api', () => ({ callEnterpriseSearchConfigAPI: jest.fn(), diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts index ebd84d3e0e79a..daf0a1e895a61 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts @@ -5,7 +5,7 @@ */ import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks'; -import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; +import { MockRouter, mockConfig, mockLogger } from '../../__mocks__'; jest.mock('../../collectors/lib/telemetry', () => ({ incrementUICounter: jest.fn(), diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts index f6534b27b5da0..69e8354e8b2f7 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; +import { MockRouter, mockConfig, mockLogger } from '../../__mocks__'; import { registerWSOverviewRoute } from './overview'; From d457d530017e086064ed174fc74f9a8d738bd6bb Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Thu, 27 Aug 2020 09:02:32 -0400 Subject: [PATCH 55/56] [Uptime] Translate bare strings (#75918) * Translate a bare string. * Remove unneeded translation. --- .../plugins/uptime/public/state/effects/dynamic_settings.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/uptime/public/state/effects/dynamic_settings.ts b/x-pack/plugins/uptime/public/state/effects/dynamic_settings.ts index 4b41862649b55..57be818c928dc 100644 --- a/x-pack/plugins/uptime/public/state/effects/dynamic_settings.ts +++ b/x-pack/plugins/uptime/public/state/effects/dynamic_settings.ts @@ -47,7 +47,11 @@ export function* setDynamicSettingsEffect() { } yield call(setDynamicSettingsAPI, { settings: action.payload }); yield put(setDynamicSettingsSuccess(action.payload)); - kibanaService.core.notifications.toasts.addSuccess('Settings saved!'); + kibanaService.core.notifications.toasts.addSuccess( + i18n.translate('xpack.uptime.settings.saveSuccess', { + defaultMessage: 'Settings saved!', + }) + ); } catch (err) { kibanaService.core.notifications.toasts.addError(err, { title: couldNotSaveSettingsText, From 070d4232a0203b6bf66c202dd73bfb62b34716fc Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Tue, 25 Aug 2020 17:41:07 +0200 Subject: [PATCH 56/56] Improve wording on add agent flyout. --- .../agent_enrollment_flyout/managed_instructions.tsx | 6 +++--- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx index b02893057c9c3..61e17aa670492 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx @@ -72,13 +72,13 @@ export const ManagedInstructions: React.FunctionComponent = ({ agentPolic <> ), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c99980fe6205c..7fefbf1eb21fb 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9150,7 +9150,6 @@ "xpack.ingestManager.agentEnrollment.enrollStandaloneTabLabel": "スタンドアロンモード", "xpack.ingestManager.agentEnrollment.fleetNotInitializedText": "エージェントを登録する前に、フリートを設定する必要があります。{link}", "xpack.ingestManager.agentEnrollment.flyoutTitle": "エージェントの追加", - "xpack.ingestManager.agentEnrollment.goToFleetButton": "フリートに移動します。", "xpack.ingestManager.agentEnrollment.managedDescription": "必要なエージェントの数に関係なく、Fleetでは、簡単に一元的に更新を管理し、エージェントにデプロイすることができます。次の手順に従い、Elasticエージェントをダウンロードし、Fleetに登録してください。", "xpack.ingestManager.agentEnrollment.standaloneDescription": "スタンドアロンモードで実行中のエージェントは、構成を変更したい場合には、手動で更新する必要があります。次の手順に従い、スタンドアロンモードでElasticエージェントをダウンロードし、セットアップしてください。", "xpack.ingestManager.agentEnrollment.stepCheckForDataDescription": "エージェントを起動した後、エージェントはデータの送信を開始します。Ingest Managerのデータセットページでは、このデータを確認できます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9ffa81a921ba8..67de49aede2fb 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9153,7 +9153,6 @@ "xpack.ingestManager.agentEnrollment.enrollStandaloneTabLabel": "独立模式", "xpack.ingestManager.agentEnrollment.fleetNotInitializedText": "注册代理前需要设置 Fleet。{link}", "xpack.ingestManager.agentEnrollment.flyoutTitle": "添加代理", - "xpack.ingestManager.agentEnrollment.goToFleetButton": "前往 Fleet。", "xpack.ingestManager.agentEnrollment.managedDescription": "无论是需要一个代理还是需要数以千计的代理,Fleet 允许您轻松地集中管理并部署代理的更新。按照下面的说明下载 Elastic 代理并将代理注册到 Fleet。", "xpack.ingestManager.agentEnrollment.standaloneDescription": "如果希望对以独立模式运行的代理进行配置更改,则需要手动更新。按照下面的说明下载并设置独立模式的 Elastic 代理。", "xpack.ingestManager.agentEnrollment.stepCheckForDataDescription": "启动代理后,代理应开始发送数据。您可以在采集管理器的数据集页面查看此数据。",