From d6e0251111e585215e875dc06c6797c5f202eec0 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Fri, 23 Apr 2021 12:44:27 -0400 Subject: [PATCH 01/37] [ML] API Integration tests: adds test for Data Frame Analytics evaluate endpoint (#97856) * wip: add api test for evaluate endpoint * Add api test for evaluate endpoint * add tests for view only and unauthorized user Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apis/ml/data_frame_analytics/evaluate.ts | 188 ++++++++++++++++++ .../apis/ml/data_frame_analytics/index.ts | 1 + 2 files changed, 189 insertions(+) create mode 100644 x-pack/test/api_integration/apis/ml/data_frame_analytics/evaluate.ts diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/evaluate.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/evaluate.ts new file mode 100644 index 000000000000000..e1fa889d20daf23 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/evaluate.ts @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { DataFrameAnalyticsConfig } from '../../../../../plugins/ml/public/application/data_frame_analytics/common'; +import { DeepPartial } from '../../../../../plugins/ml/common/types/common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + const currentTime = `${Date.now()}`; + const generateDestinationIndex = (analyticsId: string) => `user-${analyticsId}`; + const jobEval: any = { + regression: { + index: generateDestinationIndex(`regression_${currentTime}`), + evaluation: { + regression: { + actual_field: 'stab', + predicted_field: 'ml.stab_prediction', + metrics: { + r_squared: {}, + mse: {}, + msle: {}, + huber: {}, + }, + }, + }, + }, + classification: { + index: generateDestinationIndex(`classification_${currentTime}`), + evaluation: { + classification: { + actual_field: 'y', + predicted_field: 'ml.y_prediction', + metrics: { multiclass_confusion_matrix: {}, accuracy: {}, recall: {} }, + }, + }, + }, + }; + const jobAnalysis: any = { + classification: { + source: { + index: ['ft_bank_marketing'], + query: { + match_all: {}, + }, + }, + analysis: { + classification: { + dependent_variable: 'y', + training_percent: 20, + }, + }, + }, + regression: { + source: { + index: ['ft_egs_regression'], + query: { + match_all: {}, + }, + }, + analysis: { + regression: { + dependent_variable: 'stab', + training_percent: 20, + }, + }, + }, + }; + + interface TestConfig { + jobType: string; + config: DeepPartial; + eval: any; + } + + const testJobConfigs: TestConfig[] = ['regression', 'classification'].map((jobType, idx) => { + const analyticsId = `${jobType}_${currentTime}`; + return { + jobType, + config: { + id: analyticsId, + description: `Testing ${jobType} evaluation`, + dest: { + index: generateDestinationIndex(analyticsId), + results_field: 'ml', + }, + analyzed_fields: { + includes: [], + excludes: [], + }, + model_memory_limit: '60mb', + ...jobAnalysis[jobType], + }, + eval: jobEval[jobType], + }; + }); + + async function createJobs(mockJobConfigs: TestConfig[]) { + for (const jobConfig of mockJobConfigs) { + await ml.api.createAndRunDFAJob(jobConfig.config as DataFrameAnalyticsConfig); + } + } + + describe('POST data_frame/_evaluate', () => { + before(async () => { + await esArchiver.loadIfNeeded('ml/bm_classification'); + await esArchiver.loadIfNeeded('ml/egs_regression'); + await ml.testResources.setKibanaTimeZoneToUTC(); + await createJobs(testJobConfigs); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + testJobConfigs.forEach((testConfig) => { + describe(`EvaluateDataFrameAnalytics ${testConfig.jobType}`, async () => { + it(`should evaluate ${testConfig.jobType} analytics job`, async () => { + const { body } = await supertest + .post(`/api/ml/data_frame/_evaluate`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(testConfig.eval) + .expect(200); + + if (testConfig.jobType === 'classification') { + const { classification } = body; + expect(body).to.have.property('classification'); + expect(classification).to.have.property('recall'); + expect(classification).to.have.property('accuracy'); + expect(classification).to.have.property('multiclass_confusion_matrix'); + } else { + const { regression } = body; + expect(body).to.have.property('regression'); + expect(regression).to.have.property('mse'); + expect(regression).to.have.property('msle'); + expect(regression).to.have.property('r_squared'); + } + }); + + it(`should evaluate ${testConfig.jobType} job for the user with only view permission`, async () => { + const { body } = await supertest + .post(`/api/ml/data_frame/_evaluate`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .send(testConfig.eval) + .expect(200); + + if (testConfig.jobType === 'classification') { + const { classification } = body; + expect(body).to.have.property('classification'); + expect(classification).to.have.property('recall'); + expect(classification).to.have.property('accuracy'); + expect(classification).to.have.property('multiclass_confusion_matrix'); + } else { + const { regression } = body; + expect(body).to.have.property('regression'); + expect(regression).to.have.property('mse'); + expect(regression).to.have.property('msle'); + expect(regression).to.have.property('r_squared'); + } + }); + + it(`should not allow unauthorized user to evaluate ${testConfig.jobType} job`, async () => { + const { body } = await supertest + .post(`/api/ml/data_frame/_evaluate`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .send(testConfig.eval) + .expect(403); + + expect(body.error).to.eql('Forbidden'); + expect(body.message).to.eql('Forbidden'); + }); + }); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/index.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/index.ts index 3fdefe2c4bbc241..21ff8f2cc64c16a 100644 --- a/x-pack/test/api_integration/apis/ml/data_frame_analytics/index.ts +++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/index.ts @@ -20,6 +20,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./get_spaces')); loadTestFile(require.resolve('./update_spaces')); loadTestFile(require.resolve('./delete_spaces')); + loadTestFile(require.resolve('./evaluate')); loadTestFile(require.resolve('./explain')); }); } From 00940dd0f59bbede61148176a4272fabdbc32da5 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 23 Apr 2021 17:48:48 +0100 Subject: [PATCH 02/37] chore(NA): moving @kbn/babel-code-parser into bazel (#97675) * chore(NA): moving @kbn/babel-code-parser into bazel * docs(NA): missing docs about new package * chore(NA): removing quiet arg * chore(NA): fix packages build srcs * chore(NA): change source order on tinymath * chore(NA): add babelrc * chore(NA): clear package build migration Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 1 + packages/elastic-datemath/BUILD.bazel | 3 +- packages/kbn-apm-utils/BUILD.bazel | 3 +- packages/kbn-babel-code-parser/BUILD.bazel | 71 +++++++++++++++++++ packages/kbn-babel-code-parser/package.json | 5 -- packages/kbn-babel-preset/BUILD.bazel | 3 +- packages/kbn-tinymath/BUILD.bazel | 3 +- yarn.lock | 2 +- 10 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 packages/kbn-babel-code-parser/BUILD.bazel diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 86f9f7562434e49..cfc33ce5a7f8f35 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -63,6 +63,7 @@ yarn kbn watch-bazel - @elastic/datemath - @kbn/apm-utils +- @kbn/babel-code-parser - @kbn/babel-preset - @kbn/config-schema - @kbn/std diff --git a/package.json b/package.json index 1625b0305554930..80b6c077db4e699 100644 --- a/package.json +++ b/package.json @@ -437,7 +437,7 @@ "@elastic/makelogs": "^6.0.0", "@istanbuljs/schema": "^0.1.2", "@jest/reporters": "^26.6.2", - "@kbn/babel-code-parser": "link:packages/kbn-babel-code-parser", + "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser/npm_module", "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset/npm_module", "@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode", "@kbn/dev-utils": "link:packages/kbn-dev-utils", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 5c3172a6c636a24..7822eb6391f9288 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -5,6 +5,7 @@ filegroup( srcs = [ "//packages/elastic-datemath:build", "//packages/kbn-apm-utils:build", + "//packages/kbn-babel-code-parser:build", "//packages/kbn-babel-preset:build", "//packages/kbn-config-schema:build", "//packages/kbn-std:build", diff --git a/packages/elastic-datemath/BUILD.bazel b/packages/elastic-datemath/BUILD.bazel index bc0c1412ef5f159..f3eb4548088cb86 100644 --- a/packages/elastic-datemath/BUILD.bazel +++ b/packages/elastic-datemath/BUILD.bazel @@ -54,7 +54,7 @@ ts_project( js_library( name = PKG_BASE_NAME, - srcs = [], + srcs = NPM_MODULE_EXTRA_FILES, deps = [":tsc"] + DEPS, package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], @@ -62,7 +62,6 @@ js_library( pkg_npm( name = "npm_module", - srcs = NPM_MODULE_EXTRA_FILES, deps = [ ":%s" % PKG_BASE_NAME, ] diff --git a/packages/kbn-apm-utils/BUILD.bazel b/packages/kbn-apm-utils/BUILD.bazel index 63adf2b77b51638..335494bea45f00b 100644 --- a/packages/kbn-apm-utils/BUILD.bazel +++ b/packages/kbn-apm-utils/BUILD.bazel @@ -53,7 +53,7 @@ ts_project( js_library( name = PKG_BASE_NAME, - srcs = [], + srcs = NPM_MODULE_EXTRA_FILES, deps = [":tsc"] + DEPS, package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], @@ -61,7 +61,6 @@ js_library( pkg_npm( name = "npm_module", - srcs = NPM_MODULE_EXTRA_FILES, deps = [ ":%s" % PKG_BASE_NAME, ] diff --git a/packages/kbn-babel-code-parser/BUILD.bazel b/packages/kbn-babel-code-parser/BUILD.bazel new file mode 100644 index 000000000000000..3c811f0bd09f571 --- /dev/null +++ b/packages/kbn-babel-code-parser/BUILD.bazel @@ -0,0 +1,71 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") +load("@npm//@babel/cli:index.bzl", "babel") + +PKG_BASE_NAME = "kbn-babel-code-parser" +PKG_REQUIRE_NAME = "@kbn/babel-code-parser" + +SOURCE_FILES = glob( + [ + "src/**/*", + ], + exclude = [ + "**/*.test.*" + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [ + "//packages/kbn-babel-preset", + "@npm//@babel/parser", + "@npm//@babel/traverse", + "@npm//lodash", +] + +babel( + name = "target", + data = [ + ":srcs", + ".babelrc", + ] + DEPS, + output_dir = True, + args = [ + "./%s/src" % package_name(), + "--out-dir", + "$(@D)", + "--quiet" + ], +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = [":target"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-babel-code-parser/package.json b/packages/kbn-babel-code-parser/package.json index a5e05da6f8ee471..da55565c6076c66 100755 --- a/packages/kbn-babel-code-parser/package.json +++ b/packages/kbn-babel-code-parser/package.json @@ -8,10 +8,5 @@ "repository": { "type": "git", "url": "https://github.com/elastic/kibana/tree/master/packages/kbn-babel-code-parser" - }, - "scripts": { - "build": "../../node_modules/.bin/babel src --out-dir target", - "kbn:bootstrap": "yarn build --quiet", - "kbn:watch": "yarn build --watch" } } diff --git a/packages/kbn-babel-preset/BUILD.bazel b/packages/kbn-babel-preset/BUILD.bazel index 13542ed6e73ad4b..06b788010bdf519 100644 --- a/packages/kbn-babel-preset/BUILD.bazel +++ b/packages/kbn-babel-preset/BUILD.bazel @@ -38,7 +38,7 @@ DEPS = [ js_library( name = PKG_BASE_NAME, - srcs = [ + srcs = NPM_MODULE_EXTRA_FILES + [ ":srcs", ], deps = DEPS, @@ -48,7 +48,6 @@ js_library( pkg_npm( name = "npm_module", - srcs = NPM_MODULE_EXTRA_FILES, deps = [ ":%s" % PKG_BASE_NAME, ] diff --git a/packages/kbn-tinymath/BUILD.bazel b/packages/kbn-tinymath/BUILD.bazel index ae029c88774e84f..2596a30ea2efa00 100644 --- a/packages/kbn-tinymath/BUILD.bazel +++ b/packages/kbn-tinymath/BUILD.bazel @@ -45,7 +45,7 @@ peggy( js_library( name = PKG_BASE_NAME, - srcs = [ + srcs = NPM_MODULE_EXTRA_FILES + [ ":srcs", ":grammar" ], @@ -56,7 +56,6 @@ js_library( pkg_npm( name = "npm_module", - srcs = NPM_MODULE_EXTRA_FILES, deps = [ ":%s" % PKG_BASE_NAME, ] diff --git a/yarn.lock b/yarn.lock index ed20146e4fa5f14..d1ff77429216c32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2585,7 +2585,7 @@ version "0.0.0" uid "" -"@kbn/babel-code-parser@link:packages/kbn-babel-code-parser": +"@kbn/babel-code-parser@link:bazel-bin/packages/kbn-babel-code-parser/npm_module": version "0.0.0" uid "" From 7ebb731d41145a5b5c2ada00891a908b5ddb7ce8 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Fri, 23 Apr 2021 14:01:00 -0400 Subject: [PATCH 03/37] skip flaky suite (#98190) --- x-pack/test/functional/apps/monitoring/kibana/instances_mb.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/monitoring/kibana/instances_mb.js b/x-pack/test/functional/apps/monitoring/kibana/instances_mb.js index e46b1d161e68a9a..3317513f8157d4b 100644 --- a/x-pack/test/functional/apps/monitoring/kibana/instances_mb.js +++ b/x-pack/test/functional/apps/monitoring/kibana/instances_mb.js @@ -13,7 +13,8 @@ export default function ({ getService, getPageObjects }) { const instances = getService('monitoringKibanaInstances'); const kibanaClusterSummaryStatus = getService('monitoringKibanaSummaryStatus'); - describe('Kibana instances listing mb', () => { + // Failing: See https://github.com/elastic/kibana/issues/98190 + describe.skip('Kibana instances listing mb', () => { const { setup, tearDown } = getLifecycleMethods(getService, getPageObjects); before(async () => { From bf4b6d285ab5ccf8950b82eb2880ba50b998bae4 Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Fri, 23 Apr 2021 11:07:37 -0700 Subject: [PATCH 04/37] [Canvas] Hide global banner list in fullscreen mode (#98058) --- .../canvas/public/components/fullscreen/fullscreen.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss index 3ab04e31eb9c191..8f5bef8668fbe74 100644 --- a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss +++ b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss @@ -19,6 +19,11 @@ body.canvas-isFullscreen { display: none; } + // hide global banners + #globalBannerList { + display: none; + } + // set the background color .canvasLayout { background: $euiColorInk; From c75d9505d0fc229b8756558824b024819a09233e Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Fri, 23 Apr 2021 14:48:29 -0400 Subject: [PATCH 05/37] [Snapshot and Restore] Update help text for common repository settings (#97652) --- .../helpers/repository_add.helpers.ts | 7 + .../client_integration/repository_add.test.ts | 266 ++++++++++++++++-- .../type_settings/azure_settings.tsx | 155 ++-------- .../type_settings/common/chunk_size.tsx | 78 +++++ .../type_settings/common/index.ts | 10 + .../type_settings/common/max_restore.tsx | 78 +++++ .../type_settings/common/max_snapshots.tsx | 79 ++++++ .../type_settings/fs_settings.tsx | 154 ++-------- .../type_settings/gcs_settings.tsx | 155 ++-------- .../type_settings/hdfs_settings.tsx | 154 ++-------- .../type_settings/s3_settings.tsx | 170 +++-------- .../public/application/services/text/text.ts | 12 - .../translations/translations/ja-JP.json | 45 --- .../translations/translations/zh-CN.json | 45 --- 14 files changed, 631 insertions(+), 777 deletions(-) create mode 100644 x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/chunk_size.tsx create mode 100644 x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/index.ts create mode 100644 x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/max_restore.tsx create mode 100644 x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/max_snapshots.tsx diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/repository_add.helpers.ts b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/repository_add.helpers.ts index e53c19f00d1b9c0..b369b20c122ebe7 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/repository_add.helpers.ts +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/repository_add.helpers.ts @@ -71,6 +71,13 @@ type TestSubjects = | 'compressToggle' | 'fsRepositoryType' | 'locationInput' + | 'clientInput' + | 'containerInput' + | 'basePathInput' + | 'bucketInput' + | 'pathInput' + | 'uriInput' + | 'bufferSizeInput' | 'maxRestoreBytesInput' | 'maxSnapshotBytesInput' | 'nameInput' diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/repository_add.test.ts b/x-pack/plugins/snapshot_restore/__jest__/client_integration/repository_add.test.ts index 9864b18c4b8cb7a..85d438fc5f3ae1f 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/repository_add.test.ts +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/repository_add.test.ts @@ -193,7 +193,14 @@ describe('', () => { }); describe('form payload & api errors', () => { - const repository = getRepository(); + const fsRepository = getRepository({ + settings: { + chunkSize: '10mb', + location: '/tmp/es-backups', + maxSnapshotBytesPerSec: '1g', + maxRestoreBytesPerSec: '1g', + }, + }); beforeEach(async () => { httpRequestsMockHelpers.setLoadRepositoryTypesResponse(repositoryTypes); @@ -202,33 +209,237 @@ describe('', () => { }); describe('not source only', () => { - beforeEach(() => { + test('should send the correct payload for FS repository', async () => { + const { form, actions, component } = testBed; + // Fill step 1 required fields and go to step 2 - testBed.form.setInputValue('nameInput', repository.name); - testBed.actions.selectRepositoryType(repository.type); - testBed.actions.clickNextButton(); + form.setInputValue('nameInput', fsRepository.name); + actions.selectRepositoryType(fsRepository.type); + actions.clickNextButton(); + + // Fill step 2 + form.setInputValue('locationInput', fsRepository.settings.location); + form.toggleEuiSwitch('compressToggle'); + form.setInputValue('chunkSizeInput', fsRepository.settings.chunkSize); + form.setInputValue('maxSnapshotBytesInput', fsRepository.settings.maxSnapshotBytesPerSec); + form.setInputValue('maxRestoreBytesInput', fsRepository.settings.maxRestoreBytesPerSec); + form.toggleEuiSwitch('readOnlyToggle'); + + await act(async () => { + actions.clickSubmitButton(); + }); + + component.update(); + + const latestRequest = server.requests[server.requests.length - 1]; + + expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ + name: fsRepository.name, + type: fsRepository.type, + settings: { + ...fsRepository.settings, + compress: true, + readonly: true, + }, + }); }); - test('should send the correct payload', async () => { - const { form, actions } = testBed; + test('should send the correct payload for Azure repository', async () => { + const azureRepository = getRepository({ + type: 'azure', + settings: { + chunkSize: '10mb', + maxSnapshotBytesPerSec: '1g', + maxRestoreBytesPerSec: '1g', + client: 'client', + container: 'container', + basePath: 'path', + }, + }); + + const { form, actions, component } = testBed; + + // Fill step 1 required fields and go to step 2 + form.setInputValue('nameInput', azureRepository.name); + actions.selectRepositoryType(azureRepository.type); + actions.clickNextButton(); // Fill step 2 - form.setInputValue('locationInput', repository.settings.location); + form.setInputValue('clientInput', azureRepository.settings.client); + form.setInputValue('containerInput', azureRepository.settings.container); + form.setInputValue('basePathInput', azureRepository.settings.basePath); form.toggleEuiSwitch('compressToggle'); + form.setInputValue('chunkSizeInput', azureRepository.settings.chunkSize); + form.setInputValue( + 'maxSnapshotBytesInput', + azureRepository.settings.maxSnapshotBytesPerSec + ); + form.setInputValue('maxRestoreBytesInput', azureRepository.settings.maxRestoreBytesPerSec); + form.toggleEuiSwitch('readOnlyToggle'); await act(async () => { actions.clickSubmitButton(); - await nextTick(); }); + component.update(); + const latestRequest = server.requests[server.requests.length - 1]; expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ - name: repository.name, - type: repository.type, + name: azureRepository.name, + type: azureRepository.type, settings: { - location: repository.settings.location, - compress: true, + ...azureRepository.settings, + compress: false, + readonly: true, + }, + }); + }); + + test('should send the correct payload for GCS repository', async () => { + const gcsRepository = getRepository({ + type: 'gcs', + settings: { + chunkSize: '10mb', + maxSnapshotBytesPerSec: '1g', + maxRestoreBytesPerSec: '1g', + client: 'test_client', + bucket: 'test_bucket', + basePath: 'test_path', + }, + }); + + const { form, actions, component } = testBed; + + // Fill step 1 required fields and go to step 2 + form.setInputValue('nameInput', gcsRepository.name); + actions.selectRepositoryType(gcsRepository.type); + actions.clickNextButton(); + + // Fill step 2 + form.setInputValue('clientInput', gcsRepository.settings.client); + form.setInputValue('bucketInput', gcsRepository.settings.bucket); + form.setInputValue('basePathInput', gcsRepository.settings.basePath); + form.toggleEuiSwitch('compressToggle'); + form.setInputValue('chunkSizeInput', gcsRepository.settings.chunkSize); + form.setInputValue('maxSnapshotBytesInput', gcsRepository.settings.maxSnapshotBytesPerSec); + form.setInputValue('maxRestoreBytesInput', gcsRepository.settings.maxRestoreBytesPerSec); + form.toggleEuiSwitch('readOnlyToggle'); + + await act(async () => { + actions.clickSubmitButton(); + }); + + component.update(); + + const latestRequest = server.requests[server.requests.length - 1]; + + expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ + name: gcsRepository.name, + type: gcsRepository.type, + settings: { + ...gcsRepository.settings, + compress: false, + readonly: true, + }, + }); + }); + + test('should send the correct payload for HDFS repository', async () => { + const hdfsRepository = getRepository({ + type: 'hdfs', + settings: { + uri: 'uri', + path: 'test_path', + chunkSize: '10mb', + maxSnapshotBytesPerSec: '1g', + maxRestoreBytesPerSec: '1g', + }, + }); + + const { form, actions, component } = testBed; + + // Fill step 1 required fields and go to step 2 + form.setInputValue('nameInput', hdfsRepository.name); + actions.selectRepositoryType(hdfsRepository.type); + actions.clickNextButton(); + + // Fill step 2 + form.setInputValue('uriInput', hdfsRepository.settings.uri); + form.setInputValue('pathInput', hdfsRepository.settings.path); + form.toggleEuiSwitch('compressToggle'); + form.setInputValue('chunkSizeInput', hdfsRepository.settings.chunkSize); + form.setInputValue('maxSnapshotBytesInput', hdfsRepository.settings.maxSnapshotBytesPerSec); + form.setInputValue('maxRestoreBytesInput', hdfsRepository.settings.maxRestoreBytesPerSec); + form.toggleEuiSwitch('readOnlyToggle'); + + await act(async () => { + actions.clickSubmitButton(); + }); + + component.update(); + + const latestRequest = server.requests[server.requests.length - 1]; + + expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ + name: hdfsRepository.name, + type: hdfsRepository.type, + settings: { + ...hdfsRepository.settings, + uri: `hdfs://${hdfsRepository.settings.uri}`, + compress: false, + readonly: true, + }, + }); + }); + + test('should send the correct payload for S3 repository', async () => { + const { form, actions, component } = testBed; + + const s3Repository = getRepository({ + type: 's3', + settings: { + bucket: 'test_bucket', + client: 'test_client', + basePath: 'test_path', + bufferSize: '1g', + chunkSize: '10mb', + maxSnapshotBytesPerSec: '1g', + maxRestoreBytesPerSec: '1g', + }, + }); + + // Fill step 1 required fields and go to step 2 + form.setInputValue('nameInput', s3Repository.name); + actions.selectRepositoryType(s3Repository.type); + actions.clickNextButton(); + + // Fill step 2 + form.setInputValue('bucketInput', s3Repository.settings.bucket); + form.setInputValue('clientInput', s3Repository.settings.client); + form.setInputValue('basePathInput', s3Repository.settings.basePath); + form.setInputValue('bufferSizeInput', s3Repository.settings.bufferSize); + form.toggleEuiSwitch('compressToggle'); + form.setInputValue('chunkSizeInput', s3Repository.settings.chunkSize); + form.setInputValue('maxSnapshotBytesInput', s3Repository.settings.maxSnapshotBytesPerSec); + form.setInputValue('maxRestoreBytesInput', s3Repository.settings.maxRestoreBytesPerSec); + form.toggleEuiSwitch('readOnlyToggle'); + + await act(async () => { + actions.clickSubmitButton(); + }); + + component.update(); + + const latestRequest = server.requests[server.requests.length - 1]; + + expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ + name: s3Repository.name, + type: s3Repository.type, + settings: { + ...s3Repository.settings, + compress: false, + readonly: true, }, }); }); @@ -236,7 +447,13 @@ describe('', () => { test('should surface the API errors from the "save" HTTP request', async () => { const { component, form, actions, find, exists } = testBed; - form.setInputValue('locationInput', repository.settings.location); + // Fill step 1 required fields and go to step 2 + form.setInputValue('nameInput', fsRepository.name); + actions.selectRepositoryType(fsRepository.type); + actions.clickNextButton(); + + // Fill step 2 + form.setInputValue('locationInput', fsRepository.settings.location); form.toggleEuiSwitch('compressToggle'); const error = { @@ -249,10 +466,10 @@ describe('', () => { await act(async () => { actions.clickSubmitButton(); - await nextTick(); - component.update(); }); + component.update(); + expect(exists('saveRepositoryApiError')).toBe(true); expect(find('saveRepositoryApiError').text()).toContain(error.message); }); @@ -261,31 +478,32 @@ describe('', () => { describe('source only', () => { beforeEach(() => { // Fill step 1 required fields and go to step 2 - testBed.form.setInputValue('nameInput', repository.name); - testBed.actions.selectRepositoryType(repository.type); + testBed.form.setInputValue('nameInput', fsRepository.name); + testBed.actions.selectRepositoryType(fsRepository.type); testBed.form.toggleEuiSwitch('sourceOnlyToggle'); // toggle source testBed.actions.clickNextButton(); }); test('should send the correct payload', async () => { - const { form, actions } = testBed; + const { form, actions, component } = testBed; // Fill step 2 - form.setInputValue('locationInput', repository.settings.location); + form.setInputValue('locationInput', fsRepository.settings.location); await act(async () => { actions.clickSubmitButton(); - await nextTick(); }); + component.update(); + const latestRequest = server.requests[server.requests.length - 1]; expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ - name: repository.name, + name: fsRepository.name, type: 'source', settings: { - delegateType: repository.type, - location: repository.settings.location, + delegateType: fsRepository.type, + location: fsRepository.settings.location, }, }); }); diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/azure_settings.tsx b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/azure_settings.tsx index adbbe81176bdedf..b2657d0bfc0fb97 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/azure_settings.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/azure_settings.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import { AzureRepository, Repository } from '../../../../../common/types'; import { RepositorySettingsValidation } from '../../../services/validation'; -import { textService } from '../../../services/text'; +import { ChunkSizeField, MaxSnapshotsField, MaxRestoreField } from './common'; interface Props { repository: AzureRepository; @@ -53,6 +53,12 @@ export const AzureSettings: React.FunctionComponent = ({ text: option, })); + const updateSettings = (name: string, value: string) => { + updateRepositorySettings({ + [name]: value, + }); + }; + return ( {/* Client field */} @@ -232,139 +238,28 @@ export const AzureSettings: React.FunctionComponent = ({ {/* Chunk size field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.chunkSize)} - error={settingErrors.chunkSize} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - chunkSize: e.target.value, - }); - }} - data-test-subj="chunkSizeInput" - /> - -
+ {/* Max snapshot bytes field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.maxSnapshotBytesPerSec)} - error={settingErrors.maxSnapshotBytesPerSec} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - maxSnapshotBytesPerSec: e.target.value, - }); - }} - data-test-subj="maxSnapshotBytesInput" - /> - -
+ {/* Max restore bytes field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.maxRestoreBytesPerSec)} - error={settingErrors.maxRestoreBytesPerSec} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - maxRestoreBytesPerSec: e.target.value, - }); - }} - data-test-subj="maxRestoreBytesInput" - /> - -
+ {/* Location mode field */} void; + error: RepositorySettingsValidation['chunkSize']; +} + +export const ChunkSizeField: React.FunctionComponent = ({ + isInvalid, + error, + defaultValue, + updateSettings, +}) => { + return ( + +

+ +

+ + } + description={ + + } + fullWidth + > + + } + fullWidth + isInvalid={isInvalid} + error={error} + helpText={ + 1g, + example2: 10mb, + example3: 5k, + example4: 1024B, + }} + /> + } + > + updateSettings('chunkSize', e.target.value)} + data-test-subj="chunkSizeInput" + /> + +
+ ); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/index.ts b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/index.ts new file mode 100644 index 000000000000000..173e13b1b6e1770 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ChunkSizeField } from './chunk_size'; +export { MaxRestoreField } from './max_restore'; +export { MaxSnapshotsField } from './max_snapshots'; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/max_restore.tsx b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/max_restore.tsx new file mode 100644 index 000000000000000..281fe26d5b9d34d --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/max_restore.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiDescribedFormGroup, EuiFieldText, EuiFormRow, EuiTitle, EuiCode } from '@elastic/eui'; +import { RepositorySettingsValidation } from '../../../../services/validation'; + +interface Props { + isInvalid: boolean; + defaultValue: string; + updateSettings: (name: string, value: string) => void; + error: RepositorySettingsValidation['maxRestoreBytesPerSec']; +} + +export const MaxRestoreField: React.FunctionComponent = ({ + isInvalid, + error, + defaultValue, + updateSettings, +}) => { + return ( + +

+ +

+ + } + description={ + + } + fullWidth + > + + } + fullWidth + isInvalid={isInvalid} + error={error} + helpText={ + 1g, + example2: 10mb, + example3: 5k, + example4: 1024B, + }} + /> + } + > + updateSettings('maxRestoreBytesPerSec', e.target.value)} + data-test-subj="maxRestoreBytesInput" + /> + +
+ ); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/max_snapshots.tsx b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/max_snapshots.tsx new file mode 100644 index 000000000000000..85b9153c711b9c8 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/common/max_snapshots.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiDescribedFormGroup, EuiFieldText, EuiFormRow, EuiTitle, EuiCode } from '@elastic/eui'; +import { RepositorySettingsValidation } from '../../../../services/validation'; + +interface Props { + isInvalid: boolean; + defaultValue: string; + updateSettings: (name: string, value: string) => void; + error: RepositorySettingsValidation['maxSnapshotBytesPerSec']; +} + +export const MaxSnapshotsField: React.FunctionComponent = ({ + isInvalid, + error, + defaultValue, + updateSettings, +}) => { + return ( + +

+ +

+ + } + description={ + + } + fullWidth + > + + } + fullWidth + isInvalid={isInvalid} + error={error} + helpText={ + 1g, + example2: 10mb, + example3: 5k, + example4: 1024B, + defaultSize: 40mb, + }} + /> + } + > + updateSettings('maxSnapshotBytesPerSec', e.target.value)} + data-test-subj="maxSnapshotBytesInput" + /> + +
+ ); +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/fs_settings.tsx b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/fs_settings.tsx index 2635cabfa1ef636..af3e6e82312624a 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/fs_settings.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/fs_settings.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import { FSRepository, Repository } from '../../../../../common/types'; import { RepositorySettingsValidation } from '../../../services/validation'; -import { textService } from '../../../services/text'; +import { ChunkSizeField, MaxRestoreField, MaxSnapshotsField } from './common'; interface Props { repository: FSRepository; @@ -44,6 +44,11 @@ export const FSSettings: React.FunctionComponent = ({ }, } = repository; const hasErrors: boolean = Boolean(Object.keys(settingErrors).length); + const updateSettings = (name: string, value: string) => { + updateRepositorySettings({ + [name]: value, + }); + }; return ( @@ -141,139 +146,28 @@ export const FSSettings: React.FunctionComponent = ({
{/* Chunk size field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.chunkSize)} - error={settingErrors.chunkSize} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - chunkSize: e.target.value, - }); - }} - data-test-subj="chunkSizeInput" - /> - -
+ {/* Max snapshot bytes field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.maxSnapshotBytesPerSec)} - error={settingErrors.maxSnapshotBytesPerSec} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - maxSnapshotBytesPerSec: e.target.value, - }); - }} - data-test-subj="maxSnapshotBytesInput" - /> - -
+ {/* Max restore bytes field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.maxRestoreBytesPerSec)} - error={settingErrors.maxRestoreBytesPerSec} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - maxRestoreBytesPerSec: e.target.value, - }); - }} - data-test-subj="maxRestoreBytesInput" - /> - -
+ {/* Readonly field */} = ({ } = repository; const hasErrors: boolean = Boolean(Object.keys(settingErrors).length); + const updateSettings = (name: string, value: string) => { + updateRepositorySettings({ + [name]: value, + }); + }; + return ( {/* Client field */} @@ -220,139 +226,28 @@ export const GCSSettings: React.FunctionComponent = ({ {/* Chunk size field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.chunkSize)} - error={settingErrors.chunkSize} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - chunkSize: e.target.value, - }); - }} - data-test-subj="chunkSizeInput" - /> - -
+ {/* Max snapshot bytes field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.maxSnapshotBytesPerSec)} - error={settingErrors.maxSnapshotBytesPerSec} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - maxSnapshotBytesPerSec: e.target.value, - }); - }} - data-test-subj="maxSnapshotBytesInput" - /> - -
+ {/* Max restore bytes field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.maxRestoreBytesPerSec)} - error={settingErrors.maxRestoreBytesPerSec} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - maxRestoreBytesPerSec: e.target.value, - }); - }} - data-test-subj="maxRestoreBytesInput" - /> - -
+ {/* Readonly field */} ; @@ -54,6 +54,11 @@ export const HDFSSettings: React.FunctionComponent = ({ }, } = repository; const hasErrors: boolean = Boolean(Object.keys(settingErrors).length); + const updateSettings = (settingName: string, value: string) => { + updateRepositorySettings({ + [settingName]: value, + }); + }; const [additionalConf, setAdditionalConf] = useState(JSON.stringify(rest, null, 2)); const [isConfInvalid, setIsConfInvalid] = useState(false); @@ -244,49 +249,12 @@ export const HDFSSettings: React.FunctionComponent = ({ {/* Chunk size field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.chunkSize)} - error={settingErrors.chunkSize} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - chunkSize: e.target.value, - }); - }} - data-test-subj="chunkSizeInput" - /> - -
+ {/* Security principal field */} = ({ {/* Max snapshot bytes field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.maxSnapshotBytesPerSec)} - error={settingErrors.maxSnapshotBytesPerSec} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - maxSnapshotBytesPerSec: e.target.value, - }); - }} - data-test-subj="maxSnapshotBytesInput" - /> - -
+ {/* Max restore bytes field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.maxRestoreBytesPerSec)} - error={settingErrors.maxRestoreBytesPerSec} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - maxRestoreBytesPerSec: e.target.value, - }); - }} - data-test-subj="maxRestoreBytesInput" - /> - -
+ {/* Readonly field */} = ({ text: option, })); const hasErrors: boolean = Boolean(Object.keys(settingErrors).length); + const updateSettings = (name: string, value: string) => { + updateRepositorySettings({ + [name]: value, + }); + }; const storageClassOptions = ['standard', 'reduced_redundancy', 'standard_ia'].map((option) => ({ value: option, @@ -249,49 +255,12 @@ export const S3Settings: React.FunctionComponent = ({ {/* Chunk size field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.chunkSize)} - error={settingErrors.chunkSize} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - chunkSize: e.target.value, - }); - }} - data-test-subj="chunkSizeInput" - /> - -
+ {/* Server side encryption field */} = ({ fullWidth isInvalid={Boolean(hasErrors && settingErrors.bufferSize)} error={settingErrors.bufferSize} - helpText={textService.getSizeNotationHelpText()} + helpText={ + 1g, + example2: 10mb, + example3: 5k, + example4: 1024B, + defaultSize: 100mb, + defaultPercentage: 5%, + }} + /> + } > = ({ {/* Max snapshot bytes field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.maxSnapshotBytesPerSec)} - error={settingErrors.maxSnapshotBytesPerSec} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - maxSnapshotBytesPerSec: e.target.value, - }); - }} - data-test-subj="maxSnapshotBytesInput" - /> - -
+ {/* Max restore bytes field */} - -

- -

- - } - description={ - - } - fullWidth - > - - } - fullWidth - isInvalid={Boolean(hasErrors && settingErrors.maxRestoreBytesPerSec)} - error={settingErrors.maxRestoreBytesPerSec} - helpText={textService.getSizeNotationHelpText()} - > - { - updateRepositorySettings({ - maxRestoreBytesPerSec: e.target.value, - }); - }} - data-test-subj="maxRestoreBytesInput" - /> - -
+ {/* Readonly field */} Date: Fri, 23 Apr 2021 15:00:55 -0400 Subject: [PATCH 06/37] Forbid setting the Location and Refresh custom response headers (#98129) --- src/core/server/http/http_config.test.ts | 28 ++++++++++++++++++++++++ src/core/server/http/http_config.ts | 13 +++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 2a140388cc184e8..56095336d970b96 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -280,6 +280,34 @@ test('accepts any type of objects for custom headers', () => { expect(() => httpSchema.validate(obj)).not.toThrow(); }); +test('forbids the "location" custom response header', () => { + const httpSchema = config.schema; + const obj = { + customResponseHeaders: { + location: 'string', + Location: 'string', + lOcAtIoN: 'string', + }, + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"[customResponseHeaders]: The following custom response headers are not allowed to be set: location, Location, lOcAtIoN"` + ); +}); + +test('forbids the "refresh" custom response header', () => { + const httpSchema = config.schema; + const obj = { + customResponseHeaders: { + refresh: 'string', + Refresh: 'string', + rEfReSh: 'string', + }, + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"[customResponseHeaders]: The following custom response headers are not allowed to be set: refresh, Refresh, rEfReSh"` + ); +}); + describe('with TLS', () => { test('throws if TLS is enabled but `redirectHttpFromPort` is equal to `port`', () => { const httpSchema = config.schema; diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 9d0008e1c4011d3..1f8fd95d69051f3 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -26,6 +26,9 @@ const hostURISchema = schema.uri({ scheme: ['http', 'https'] }); const match = (regex: RegExp, errorMsg: string) => (str: string) => regex.test(str) ? undefined : errorMsg; +// The lower-case set of response headers which are forbidden within `customResponseHeaders`. +const RESPONSE_HEADER_DENY_LIST = ['location', 'refresh']; + const configSchema = schema.object( { name: schema.string({ defaultValue: () => hostname() }), @@ -70,6 +73,16 @@ const configSchema = schema.object( securityResponseHeaders: securityResponseHeadersSchema, customResponseHeaders: schema.recordOf(schema.string(), schema.any(), { defaultValue: {}, + validate(value) { + const forbiddenKeys = Object.keys(value).filter((headerName) => + RESPONSE_HEADER_DENY_LIST.includes(headerName.toLowerCase()) + ); + if (forbiddenKeys.length > 0) { + return `The following custom response headers are not allowed to be set: ${forbiddenKeys.join( + ', ' + )}`; + } + }, }), host: schema.string({ defaultValue: 'localhost', From 2ebb3087534dd2ca305f82b40ede1d821452852b Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Fri, 23 Apr 2021 15:50:24 -0400 Subject: [PATCH 07/37] [alerting] encode rule/connector ids in http requests made from alerting UI (#97854) resolves: https://github.com/elastic/kibana/issues/97852 Adds `encodeURIComponent()` wrappers around references to rule, alert, and connector ids. Without this fix, if an alert id (which can contain customer-generated data) contains a character that needs to be URL encoded, the resulting API call from the web UI will fail. --- .../builtin_action_types/jira/api.test.ts | 16 ++--- .../builtin_action_types/jira/api.ts | 60 +++++++++++-------- .../resilient/api.test.ts | 12 ++-- .../builtin_action_types/resilient/api.ts | 30 ++++++---- .../servicenow/api.test.ts | 4 +- .../builtin_action_types/servicenow/api.ts | 15 +++-- .../lib/action_connector_api/delete.test.ts | 4 +- .../lib/action_connector_api/delete.ts | 4 +- .../lib/action_connector_api/execute.test.ts | 4 +- .../lib/action_connector_api/execute.ts | 9 ++- .../lib/action_connector_api/update.test.ts | 8 +-- .../lib/action_connector_api/update.ts | 2 +- .../lib/alert_api/alert_summary.test.ts | 8 +-- .../lib/alert_api/alert_summary.ts | 4 +- .../application/lib/alert_api/delete.test.ts | 4 +- .../application/lib/alert_api/delete.ts | 4 +- .../application/lib/alert_api/disable.test.ts | 8 +-- .../application/lib/alert_api/disable.ts | 2 +- .../application/lib/alert_api/enable.test.ts | 8 +-- .../application/lib/alert_api/enable.ts | 2 +- .../lib/alert_api/get_rule.test.ts | 9 +-- .../application/lib/alert_api/get_rule.ts | 2 +- .../application/lib/alert_api/mute.test.ts | 8 +-- .../public/application/lib/alert_api/mute.ts | 2 +- .../lib/alert_api/mute_alert.test.ts | 4 +- .../application/lib/alert_api/mute_alert.ts | 6 +- .../application/lib/alert_api/unmute.test.ts | 8 +-- .../application/lib/alert_api/unmute.ts | 2 +- .../lib/alert_api/unmute_alert.test.ts | 4 +- .../application/lib/alert_api/unmute_alert.ts | 6 +- .../application/lib/alert_api/update.test.ts | 6 +- .../application/lib/alert_api/update.ts | 2 +- .../apps/triggers_actions_ui/details.ts | 26 ++++---- 33 files changed, 168 insertions(+), 125 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.test.ts index 679bc3d53c40da3..38d65b923b37491 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.test.ts @@ -99,10 +99,10 @@ describe('Jira API', () => { test('should call get issue types API', async () => { const abortCtrl = new AbortController(); http.post.mockResolvedValueOnce(issueTypesResponse); - const res = await getIssueTypes({ http, signal: abortCtrl.signal, connectorId: 'test' }); + const res = await getIssueTypes({ http, signal: abortCtrl.signal, connectorId: 'te/st' }); expect(res).toEqual(issueTypesResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/te%2Fst/_execute', { body: '{"params":{"subAction":"issueTypes","subActionParams":{}}}', signal: abortCtrl.signal, }); @@ -116,12 +116,12 @@ describe('Jira API', () => { const res = await getFieldsByIssueType({ http, signal: abortCtrl.signal, - connectorId: 'test', + connectorId: 'te/st', id: '10006', }); expect(res).toEqual(fieldsResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/te%2Fst/_execute', { body: '{"params":{"subAction":"fieldsByIssueType","subActionParams":{"id":"10006"}}}', signal: abortCtrl.signal, }); @@ -135,12 +135,12 @@ describe('Jira API', () => { const res = await getIssues({ http, signal: abortCtrl.signal, - connectorId: 'test', + connectorId: 'te/st', title: 'test issue', }); expect(res).toEqual(issuesResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/te%2Fst/_execute', { body: '{"params":{"subAction":"issues","subActionParams":{"title":"test issue"}}}', signal: abortCtrl.signal, }); @@ -154,12 +154,12 @@ describe('Jira API', () => { const res = await getIssue({ http, signal: abortCtrl.signal, - connectorId: 'test', + connectorId: 'te/st', id: 'RJ-107', }); expect(res).toEqual(issuesResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/te%2Fst/_execute', { body: '{"params":{"subAction":"issue","subActionParams":{"id":"RJ-107"}}}', signal: abortCtrl.signal, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.ts index 46ea9dea3aa56cc..83e126ea9d2f6db 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/api.ts @@ -17,12 +17,15 @@ export async function getIssueTypes({ signal: AbortSignal; connectorId: string; }): Promise> { - return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { - body: JSON.stringify({ - params: { subAction: 'issueTypes', subActionParams: {} }, - }), - signal, - }); + return await http.post( + `${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(connectorId)}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'issueTypes', subActionParams: {} }, + }), + signal, + } + ); } export async function getFieldsByIssueType({ @@ -36,12 +39,15 @@ export async function getFieldsByIssueType({ connectorId: string; id: string; }): Promise> { - return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { - body: JSON.stringify({ - params: { subAction: 'fieldsByIssueType', subActionParams: { id } }, - }), - signal, - }); + return await http.post( + `${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(connectorId)}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'fieldsByIssueType', subActionParams: { id } }, + }), + signal, + } + ); } export async function getIssues({ @@ -55,12 +61,15 @@ export async function getIssues({ connectorId: string; title: string; }): Promise> { - return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { - body: JSON.stringify({ - params: { subAction: 'issues', subActionParams: { title } }, - }), - signal, - }); + return await http.post( + `${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(connectorId)}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'issues', subActionParams: { title } }, + }), + signal, + } + ); } export async function getIssue({ @@ -74,10 +83,13 @@ export async function getIssue({ connectorId: string; id: string; }): Promise> { - return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { - body: JSON.stringify({ - params: { subAction: 'issue', subActionParams: { id } }, - }), - signal, - }); + return await http.post( + `${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(connectorId)}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'issue', subActionParams: { id } }, + }), + signal, + } + ); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.test.ts index 01208f93405d254..0d4bf9148a92ff0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.test.ts @@ -32,7 +32,7 @@ const incidentTypesResponse = { { id: 16, name: 'TBD / Unknown' }, { id: 15, name: 'Vendor / 3rd party error' }, ], - actionId: 'test', + actionId: 'te/st', }; const severityResponse = { @@ -42,7 +42,7 @@ const severityResponse = { { id: 5, name: 'Medium' }, { id: 6, name: 'High' }, ], - actionId: 'test', + actionId: 'te/st', }; describe('Resilient API', () => { @@ -57,11 +57,11 @@ describe('Resilient API', () => { const res = await getIncidentTypes({ http, signal: abortCtrl.signal, - connectorId: 'test', + connectorId: 'te/st', }); expect(res).toEqual(incidentTypesResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/te%2Fst/_execute', { body: '{"params":{"subAction":"incidentTypes","subActionParams":{}}}', signal: abortCtrl.signal, }); @@ -75,11 +75,11 @@ describe('Resilient API', () => { const res = await getSeverity({ http, signal: abortCtrl.signal, - connectorId: 'test', + connectorId: 'te/st', }); expect(res).toEqual(severityResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/te%2Fst/_execute', { body: '{"params":{"subAction":"severity","subActionParams":{}}}', signal: abortCtrl.signal, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.ts index 8ea3c3c63e50f0f..6bd9c43105cf0e2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/api.ts @@ -17,12 +17,15 @@ export async function getIncidentTypes({ signal: AbortSignal; connectorId: string; }): Promise> { - return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { - body: JSON.stringify({ - params: { subAction: 'incidentTypes', subActionParams: {} }, - }), - signal, - }); + return await http.post( + `${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(connectorId)}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'incidentTypes', subActionParams: {} }, + }), + signal, + } + ); } export async function getSeverity({ @@ -34,10 +37,13 @@ export async function getSeverity({ signal: AbortSignal; connectorId: string; }): Promise> { - return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { - body: JSON.stringify({ - params: { subAction: 'severity', subActionParams: {} }, - }), - signal, - }); + return await http.post( + `${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(connectorId)}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'severity', subActionParams: {} }, + }), + signal, + } + ); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts index 5c814bbfd64505f..ba820efc8111fe3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts @@ -56,12 +56,12 @@ describe('ServiceNow API', () => { const res = await getChoices({ http, signal: abortCtrl.signal, - connectorId: 'test', + connectorId: 'te/st', fields: ['priority'], }); expect(res).toEqual(choicesResponse); - expect(http.post).toHaveBeenCalledWith('/api/actions/connector/test/_execute', { + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/te%2Fst/_execute', { body: '{"params":{"subAction":"getChoices","subActionParams":{"fields":["priority"]}}}', signal: abortCtrl.signal, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts index bb9091559128545..62347580e75ca13 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts @@ -19,10 +19,13 @@ export async function getChoices({ connectorId: string; fields: string[]; }): Promise> { - return await http.post(`${BASE_ACTION_API_PATH}/connector/${connectorId}/_execute`, { - body: JSON.stringify({ - params: { subAction: 'getChoices', subActionParams: { fields } }, - }), - signal, - }); + return await http.post( + `${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(connectorId)}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'getChoices', subActionParams: { fields } }, + }), + signal, + } + ); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.test.ts index bb00c8c30e4edef..ba4c62471555b4e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.test.ts @@ -14,7 +14,7 @@ beforeEach(() => jest.resetAllMocks()); describe('deleteActions', () => { test('should call delete API per action', async () => { - const ids = ['1', '2', '3']; + const ids = ['1', '2', '/']; const result = await deleteActions({ ids, http }); expect(result).toEqual({ errors: [], successes: [undefined, undefined, undefined] }); @@ -27,7 +27,7 @@ describe('deleteActions', () => { "/api/actions/connector/2", ], Array [ - "/api/actions/connector/3", + "/api/actions/connector/%2F", ], ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.ts index c9c25db676a06dd..868e5390045ccd8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/delete.ts @@ -16,7 +16,9 @@ export async function deleteActions({ }): Promise<{ successes: string[]; errors: string[] }> { const successes: string[] = []; const errors: string[] = []; - await Promise.all(ids.map((id) => http.delete(`${BASE_ACTION_API_PATH}/connector/${id}`))).then( + await Promise.all( + ids.map((id) => http.delete(`${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(id)}`)) + ).then( function (fulfilled) { successes.push(...fulfilled); }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.test.ts index 60cd3132aa756b9..2b0cdcb2ca69b4a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.test.ts @@ -14,7 +14,7 @@ beforeEach(() => jest.resetAllMocks()); describe('executeAction', () => { test('should call execute API', async () => { - const id = '123'; + const id = '12/3'; const params = { stringParams: 'someString', numericParams: 123, @@ -32,7 +32,7 @@ describe('executeAction', () => { }); expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "/api/actions/connector/123/_execute", + "/api/actions/connector/12%2F3/_execute", Object { "body": "{\\"params\\":{\\"stringParams\\":\\"someString\\",\\"numericParams\\":123}}", }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.ts index 638ceddb5652fb5..d97ad7d5962b74a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/execute.ts @@ -31,8 +31,11 @@ export async function executeAction({ http: HttpSetup; params: Record; }): Promise> { - const res = await http.post(`${BASE_ACTION_API_PATH}/connector/${id}/_execute`, { - body: JSON.stringify({ params }), - }); + const res = await http.post( + `${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(id)}/_execute`, + { + body: JSON.stringify({ params }), + } + ); return rewriteBodyRes(res); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.test.ts index 29e7a1e4bed3d09..3cee8d225b001e2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.test.ts @@ -15,9 +15,9 @@ beforeEach(() => jest.resetAllMocks()); describe('updateActionConnector', () => { test('should call the update API', async () => { - const id = '123'; + const id = '12/3'; const apiResponse = { - connector_type_id: 'test', + connector_type_id: 'te/st', is_preconfigured: false, name: 'My test', config: {}, @@ -27,7 +27,7 @@ describe('updateActionConnector', () => { http.put.mockResolvedValueOnce(apiResponse); const connector: ActionConnectorWithoutId<{}, {}> = { - actionTypeId: 'test', + actionTypeId: 'te/st', isPreconfigured: false, name: 'My test', config: {}, @@ -39,7 +39,7 @@ describe('updateActionConnector', () => { expect(result).toEqual(resolvedValue); expect(http.put.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "/api/actions/connector/123", + "/api/actions/connector/12%2F3", Object { "body": "{\\"name\\":\\"My test\\",\\"config\\":{},\\"secrets\\":{}}", }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.ts index 18b8871ce25d1ce..1bc0cefc2723b2e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/update.ts @@ -30,7 +30,7 @@ export async function updateActionConnector({ connector: Pick; id: string; }): Promise { - const res = await http.put(`${BASE_ACTION_API_PATH}/connector/${id}`, { + const res = await http.put(`${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(id)}`, { body: JSON.stringify({ name: connector.name, config: connector.config, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.test.ts index e94da81d0f5d510..c7b987f2b04bdfb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.test.ts @@ -18,7 +18,7 @@ describe('loadAlertInstanceSummary', () => { consumer: 'alerts', enabled: true, errorMessages: [], - id: 'test', + id: 'te/st', lastRun: '2021-04-01T22:18:27.609Z', muteAll: false, name: 'test', @@ -35,7 +35,7 @@ describe('loadAlertInstanceSummary', () => { consumer: 'alerts', enabled: true, error_messages: [], - id: 'test', + id: 'te/st', last_run: '2021-04-01T22:18:27.609Z', mute_all: false, name: 'test', @@ -47,11 +47,11 @@ describe('loadAlertInstanceSummary', () => { throttle: null, }); - const result = await loadAlertInstanceSummary({ http, alertId: 'test' }); + const result = await loadAlertInstanceSummary({ http, alertId: 'te/st' }); expect(result).toEqual(resolvedValue); expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "/internal/alerting/rule/test/_alert_summary", + "/internal/alerting/rule/te%2Fst/_alert_summary", ] `); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.ts index e37c0640ec1c8f4..cb924db74cea557 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/alert_summary.ts @@ -36,6 +36,8 @@ export async function loadAlertInstanceSummary({ http: HttpSetup; alertId: string; }): Promise { - const res = await http.get(`${INTERNAL_BASE_ALERTING_API_PATH}/rule/${alertId}/_alert_summary`); + const res = await http.get( + `${INTERNAL_BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(alertId)}/_alert_summary` + ); return rewriteBodyRes(res); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.test.ts index b279e4c0237d967..11e5f4763e775e3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.test.ts @@ -12,7 +12,7 @@ const http = httpServiceMock.createStartContract(); describe('deleteAlerts', () => { test('should call delete API for each alert', async () => { - const ids = ['1', '2', '3']; + const ids = ['1', '2', '/']; const result = await deleteAlerts({ http, ids }); expect(result).toEqual({ errors: [], successes: [undefined, undefined, undefined] }); expect(http.delete.mock.calls).toMatchInlineSnapshot(` @@ -24,7 +24,7 @@ describe('deleteAlerts', () => { "/api/alerting/rule/2", ], Array [ - "/api/alerting/rule/3", + "/api/alerting/rule/%2F", ], ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.ts index 870d5a409c3dda3..b853e722e6fc366 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/delete.ts @@ -16,7 +16,9 @@ export async function deleteAlerts({ }): Promise<{ successes: string[]; errors: string[] }> { const successes: string[] = []; const errors: string[] = []; - await Promise.all(ids.map((id) => http.delete(`${BASE_ALERTING_API_PATH}/rule/${id}`))).then( + await Promise.all( + ids.map((id) => http.delete(`${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}`)) + ).then( function (fulfilled) { successes.push(...fulfilled); }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.test.ts index 90d1cd13096e848..4323816221c6ed6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.test.ts @@ -13,12 +13,12 @@ beforeEach(() => jest.resetAllMocks()); describe('disableAlert', () => { test('should call disable alert API', async () => { - const result = await disableAlert({ http, id: '1' }); + const result = await disableAlert({ http, id: '1/' }); expect(result).toEqual(undefined); expect(http.post.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "/api/alerting/rule/1/_disable", + "/api/alerting/rule/1%2F/_disable", ], ] `); @@ -27,7 +27,7 @@ describe('disableAlert', () => { describe('disableAlerts', () => { test('should call disable alert API per alert', async () => { - const ids = ['1', '2', '3']; + const ids = ['1', '2', '/']; const result = await disableAlerts({ http, ids }); expect(result).toEqual(undefined); expect(http.post.mock.calls).toMatchInlineSnapshot(` @@ -39,7 +39,7 @@ describe('disableAlerts', () => { "/api/alerting/rule/2/_disable", ], Array [ - "/api/alerting/rule/3/_disable", + "/api/alerting/rule/%2F/_disable", ], ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.ts index cc0939fbebfbded..758e66644b34e79 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/disable.ts @@ -8,7 +8,7 @@ import { HttpSetup } from 'kibana/public'; import { BASE_ALERTING_API_PATH } from '../../constants'; export async function disableAlert({ id, http }: { id: string; http: HttpSetup }): Promise { - await http.post(`${BASE_ALERTING_API_PATH}/rule/${id}/_disable`); + await http.post(`${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/_disable`); } export async function disableAlerts({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.test.ts index ef65e8b605cba4a..3a54a0772664b87 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.test.ts @@ -13,12 +13,12 @@ beforeEach(() => jest.resetAllMocks()); describe('enableAlert', () => { test('should call enable alert API', async () => { - const result = await enableAlert({ http, id: '1' }); + const result = await enableAlert({ http, id: '1/' }); expect(result).toEqual(undefined); expect(http.post.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "/api/alerting/rule/1/_enable", + "/api/alerting/rule/1%2F/_enable", ], ] `); @@ -27,7 +27,7 @@ describe('enableAlert', () => { describe('enableAlerts', () => { test('should call enable alert API per alert', async () => { - const ids = ['1', '2', '3']; + const ids = ['1', '2', '/']; const result = await enableAlerts({ http, ids }); expect(result).toEqual(undefined); expect(http.post.mock.calls).toMatchInlineSnapshot(` @@ -39,7 +39,7 @@ describe('enableAlerts', () => { "/api/alerting/rule/2/_enable", ], Array [ - "/api/alerting/rule/3/_enable", + "/api/alerting/rule/%2F/_enable", ], ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.ts index 3c16ffaec6223fd..4bb3e3d45fcaea0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/enable.ts @@ -8,7 +8,7 @@ import { HttpSetup } from 'kibana/public'; import { BASE_ALERTING_API_PATH } from '../../constants'; export async function enableAlert({ id, http }: { id: string; http: HttpSetup }): Promise { - await http.post(`${BASE_ALERTING_API_PATH}/rule/${id}/_enable`); + await http.post(`${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/_enable`); } export async function enableAlerts({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.test.ts index f2d8337eb4091ce..5c71f6433f2b9ab 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.test.ts @@ -13,9 +13,10 @@ const http = httpServiceMock.createStartContract(); describe('loadAlert', () => { test('should call get API with base parameters', async () => { - const alertId = uuid.v4(); + const alertId = `${uuid.v4()}/`; + const alertIdEncoded = encodeURIComponent(alertId); const resolvedValue = { - id: '1', + id: '1/', params: { aggType: 'count', termSize: 5, @@ -56,7 +57,7 @@ describe('loadAlert', () => { http.get.mockResolvedValueOnce(resolvedValue); expect(await loadAlert({ http, alertId })).toEqual({ - id: '1', + id: '1/', params: { aggType: 'count', termSize: 5, @@ -94,6 +95,6 @@ describe('loadAlert', () => { }, ], }); - expect(http.get).toHaveBeenCalledWith(`/api/alerting/rule/${alertId}`); + expect(http.get).toHaveBeenCalledWith(`/api/alerting/rule/${alertIdEncoded}`); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.ts index 2e4cbc9b50c51b8..9fa882c02fa228f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/get_rule.ts @@ -16,6 +16,6 @@ export async function loadAlert({ http: HttpSetup; alertId: string; }): Promise { - const res = await http.get(`${BASE_ALERTING_API_PATH}/rule/${alertId}`); + const res = await http.get(`${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(alertId)}`); return transformAlert(res); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.test.ts index 75143dd6b7f8570..804096dbafac895 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.test.ts @@ -13,12 +13,12 @@ beforeEach(() => jest.resetAllMocks()); describe('muteAlert', () => { test('should call mute alert API', async () => { - const result = await muteAlert({ http, id: '1' }); + const result = await muteAlert({ http, id: '1/' }); expect(result).toEqual(undefined); expect(http.post.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "/api/alerting/rule/1/_mute_all", + "/api/alerting/rule/1%2F/_mute_all", ], ] `); @@ -27,7 +27,7 @@ describe('muteAlert', () => { describe('muteAlerts', () => { test('should call mute alert API per alert', async () => { - const ids = ['1', '2', '3']; + const ids = ['1', '2', '/']; const result = await muteAlerts({ http, ids }); expect(result).toEqual(undefined); expect(http.post.mock.calls).toMatchInlineSnapshot(` @@ -39,7 +39,7 @@ describe('muteAlerts', () => { "/api/alerting/rule/2/_mute_all", ], Array [ - "/api/alerting/rule/3/_mute_all", + "/api/alerting/rule/%2F/_mute_all", ], ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.ts index 22a96d7a11ff38f..888cdfa92c8f5e6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute.ts @@ -8,7 +8,7 @@ import { HttpSetup } from 'kibana/public'; import { BASE_ALERTING_API_PATH } from '../../constants'; export async function muteAlert({ id, http }: { id: string; http: HttpSetup }): Promise { - await http.post(`${BASE_ALERTING_API_PATH}/rule/${id}/_mute_all`); + await http.post(`${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/_mute_all`); } export async function muteAlerts({ ids, http }: { ids: string[]; http: HttpSetup }): Promise { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.test.ts index 4365cce42c8c3eb..384bc65754b0334 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.test.ts @@ -12,12 +12,12 @@ const http = httpServiceMock.createStartContract(); describe('muteAlertInstance', () => { test('should call mute instance alert API', async () => { - const result = await muteAlertInstance({ http, id: '1', instanceId: '123' }); + const result = await muteAlertInstance({ http, id: '1/', instanceId: '12/3' }); expect(result).toEqual(undefined); expect(http.post.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "/api/alerting/rule/1/alert/123/_mute", + "/api/alerting/rule/1%2F/alert/12%2F3/_mute", ], ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.ts index 0bb05010cfa3c59..05f2417db947220 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/mute_alert.ts @@ -16,5 +16,9 @@ export async function muteAlertInstance({ instanceId: string; http: HttpSetup; }): Promise { - await http.post(`${BASE_ALERTING_API_PATH}/rule/${id}/alert/${instanceId}/_mute`); + await http.post( + `${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/alert/${encodeURIComponent( + instanceId + )}/_mute` + ); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.test.ts index 68a6feeb65e1e74..dfaceffcf8f00a3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.test.ts @@ -13,7 +13,7 @@ beforeEach(() => jest.resetAllMocks()); describe('unmuteAlerts', () => { test('should call unmute alert API per alert', async () => { - const ids = ['1', '2', '3']; + const ids = ['1', '2', '/']; const result = await unmuteAlerts({ http, ids }); expect(result).toEqual(undefined); expect(http.post.mock.calls).toMatchInlineSnapshot(` @@ -25,7 +25,7 @@ describe('unmuteAlerts', () => { "/api/alerting/rule/2/_unmute_all", ], Array [ - "/api/alerting/rule/3/_unmute_all", + "/api/alerting/rule/%2F/_unmute_all", ], ] `); @@ -34,12 +34,12 @@ describe('unmuteAlerts', () => { describe('unmuteAlert', () => { test('should call unmute alert API', async () => { - const result = await unmuteAlert({ http, id: '1' }); + const result = await unmuteAlert({ http, id: '1/' }); expect(result).toEqual(undefined); expect(http.post.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "/api/alerting/rule/1/_unmute_all", + "/api/alerting/rule/1%2F/_unmute_all", ], ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.ts index c65be6a670a897c..bd2139f05264513 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute.ts @@ -8,7 +8,7 @@ import { HttpSetup } from 'kibana/public'; import { BASE_ALERTING_API_PATH } from '../../constants'; export async function unmuteAlert({ id, http }: { id: string; http: HttpSetup }): Promise { - await http.post(`${BASE_ALERTING_API_PATH}/rule/${id}/_unmute_all`); + await http.post(`${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/_unmute_all`); } export async function unmuteAlerts({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.test.ts index c0131cbab0ebf1d..d95c95158b0b7e6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.test.ts @@ -12,12 +12,12 @@ const http = httpServiceMock.createStartContract(); describe('unmuteAlertInstance', () => { test('should call mute instance alert API', async () => { - const result = await unmuteAlertInstance({ http, id: '1', instanceId: '123' }); + const result = await unmuteAlertInstance({ http, id: '1/', instanceId: '12/3' }); expect(result).toEqual(undefined); expect(http.post.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "/api/alerting/rule/1/alert/123/_unmute", + "/api/alerting/rule/1%2F/alert/12%2F3/_unmute", ], ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.ts index 60d2cca72b85e60..2e37aa2c0ee295f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/unmute_alert.ts @@ -16,5 +16,9 @@ export async function unmuteAlertInstance({ instanceId: string; http: HttpSetup; }): Promise { - await http.post(`${BASE_ALERTING_API_PATH}/rule/${id}/alert/${instanceId}/_unmute`); + await http.post( + `${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/alert/${encodeURIComponent( + instanceId + )}/_unmute` + ); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.test.ts index 745a94b8d1134b1..3a6059248a3b0b2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.test.ts @@ -32,7 +32,7 @@ describe('updateAlert', () => { }; const resolvedValue: Alert = { ...alertToUpdate, - id: '123', + id: '12/3', enabled: true, alertTypeId: 'test', createdBy: null, @@ -46,11 +46,11 @@ describe('updateAlert', () => { }; http.put.mockResolvedValueOnce(resolvedValue); - const result = await updateAlert({ http, id: '123', alert: alertToUpdate }); + const result = await updateAlert({ http, id: '12/3', alert: alertToUpdate }); expect(result).toEqual(resolvedValue); expect(http.put.mock.calls[0]).toMatchInlineSnapshot(` Array [ - "/api/alerting/rule/123", + "/api/alerting/rule/12%2F3", Object { "body": "{\\"throttle\\":\\"1m\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"notify_when\\":\\"onThrottleInterval\\",\\"actions\\":[]}", }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.ts index 44b9306949f8103..930c0c2fb21a08a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/update.ts @@ -41,7 +41,7 @@ export async function updateAlert({ >; id: string; }): Promise { - const res = await http.put(`${BASE_ALERTING_API_PATH}/rule/${id}`, { + const res = await http.put(`${BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}`, { body: JSON.stringify( rewriteBodyRequest( pick(alert, ['throttle', 'name', 'tags', 'schedule', 'params', 'actions', 'notifyWhen']) diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index 97f8b3f61dc892a..b38b605bc1b6788 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -93,14 +93,18 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { async function getAlertInstanceSummary(alertId: string) { const { body: summary } = await supertest - .get(`/internal/alerting/rule/${alertId}/_alert_summary`) + .get(`/internal/alerting/rule/${encodeURIComponent(alertId)}/_alert_summary`) .expect(200); return summary; } async function muteAlertInstance(alertId: string, alertInstanceId: string) { const { body: response } = await supertest - .post(`/api/alerting/rule/${alertId}/alert/${alertInstanceId}/_mute`) + .post( + `/api/alerting/rule/${encodeURIComponent(alertId)}/alert/${encodeURIComponent( + alertInstanceId + )}/_mute` + ) .set('kbn-xsrf', 'foo') .expect(204); @@ -640,17 +644,17 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('renders the muted inactive alert instances', async () => { // mute an alert instance that doesn't exist - await muteAlertInstance(alert.id, 'eu-east'); + await muteAlertInstance(alert.id, 'eu/east'); // refresh to see alert await browser.refresh(); const instancesList: any[] = await pageObjects.alertDetailsUI.getAlertInstancesList(); expect( - instancesList.filter((alertInstance) => alertInstance.instance === 'eu-east') + instancesList.filter((alertInstance) => alertInstance.instance === 'eu/east') ).to.eql([ { - instance: 'eu-east', + instance: 'eu/east', status: 'OK', start: '', duration: '', @@ -693,14 +697,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('allows the user unmute an inactive instance', async () => { - log.debug(`Ensuring eu-east is muted`); - await pageObjects.alertDetailsUI.ensureAlertInstanceMute('eu-east', true); + log.debug(`Ensuring eu/east is muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('eu/east', true); - log.debug(`Unmuting eu-east`); - await pageObjects.alertDetailsUI.clickAlertInstanceMuteButton('eu-east'); + log.debug(`Unmuting eu/east`); + await pageObjects.alertDetailsUI.clickAlertInstanceMuteButton('eu/east'); - log.debug(`Ensuring eu-east is removed from list`); - await pageObjects.alertDetailsUI.ensureAlertInstanceExistance('eu-east', false); + log.debug(`Ensuring eu/east is removed from list`); + await pageObjects.alertDetailsUI.ensureAlertInstanceExistance('eu/east', false); }); }); From a304bb357718b5bc84d44715352255cd67d635e9 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 23 Apr 2021 21:28:37 +0100 Subject: [PATCH 08/37] chore(NA): moving @elastic/safer-lodash-set into bazel (#98187) --- .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 1 + packages/elastic-safer-lodash-set/BUILD.bazel | 65 +++++++++++++++++++ .../elastic-safer-lodash-set/tsconfig.json | 2 +- packages/kbn-apm-config-loader/package.json | 3 - packages/kbn-config/package.json | 1 - x-pack/package.json | 1 - yarn.lock | 2 +- 9 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 packages/elastic-safer-lodash-set/BUILD.bazel diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index cfc33ce5a7f8f35..217bb03549343d4 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -62,6 +62,7 @@ yarn kbn watch-bazel === List of Already Migrated Packages to Bazel - @elastic/datemath +- @elastic/safer-lodash-set - @kbn/apm-utils - @kbn/babel-code-parser - @kbn/babel-preset diff --git a/package.json b/package.json index 80b6c077db4e699..6af5c256c57fa7c 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "@elastic/numeral": "^2.5.0", "@elastic/react-search-ui": "^1.5.1", "@elastic/request-crypto": "1.1.4", - "@elastic/safer-lodash-set": "link:packages/elastic-safer-lodash-set", + "@elastic/safer-lodash-set": "link:bazel-bin/packages/elastic-safer-lodash-set/npm_module", "@elastic/search-ui-app-search-connector": "^1.5.0", "@elastic/ui-ace": "0.2.3", "@hapi/boom": "^9.1.1", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 7822eb6391f9288..7f5182e9071078b 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -4,6 +4,7 @@ filegroup( name = "build", srcs = [ "//packages/elastic-datemath:build", + "//packages/elastic-safer-lodash-set:build", "//packages/kbn-apm-utils:build", "//packages/kbn-babel-code-parser:build", "//packages/kbn-babel-preset:build", diff --git a/packages/elastic-safer-lodash-set/BUILD.bazel b/packages/elastic-safer-lodash-set/BUILD.bazel new file mode 100644 index 000000000000000..cba719ee4f0effa --- /dev/null +++ b/packages/elastic-safer-lodash-set/BUILD.bazel @@ -0,0 +1,65 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "elastic-safer-lodash-set" +PKG_REQUIRE_NAME = "@elastic/safer-lodash-set" + +SOURCE_FILES = glob( + [ + "fp/**/*", + "lodash/**/*", + "index.js", + "set.js", + "setWith.js", + ], + exclude = [ + "**/*.d.ts" + ], +) + +TYPE_FILES = glob([ + "fp/**/*.d.ts", + "index.d.ts", + "set.d.ts", + "setWith.d.ts", +]) + +SRCS = SOURCE_FILES + TYPE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [ + "@npm//lodash", +] + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [ + ":srcs", + ], + deps = DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/elastic-safer-lodash-set/tsconfig.json b/packages/elastic-safer-lodash-set/tsconfig.json index 6517e5c60ee01a5..5a29c6ff2dd8818 100644 --- a/packages/elastic-safer-lodash-set/tsconfig.json +++ b/packages/elastic-safer-lodash-set/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "tsBuildInfoFile": "../../build/tsbuildinfo/packages/elastic-safer-lodash-set" + "incremental": false, }, "include": [ "**/*", diff --git a/packages/kbn-apm-config-loader/package.json b/packages/kbn-apm-config-loader/package.json index d198ee57c619d4c..b9dc324ec5e7888 100644 --- a/packages/kbn-apm-config-loader/package.json +++ b/packages/kbn-apm-config-loader/package.json @@ -9,8 +9,5 @@ "build": "../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" - }, - "dependencies": { - "@elastic/safer-lodash-set": "link:../elastic-safer-lodash-set" } } \ No newline at end of file diff --git a/packages/kbn-config/package.json b/packages/kbn-config/package.json index 9bf491e300871c8..1611da9aa60d4fb 100644 --- a/packages/kbn-config/package.json +++ b/packages/kbn-config/package.json @@ -10,7 +10,6 @@ "kbn:bootstrap": "yarn build" }, "dependencies": { - "@elastic/safer-lodash-set": "link:../elastic-safer-lodash-set", "@kbn/logging": "link:../kbn-logging" }, "devDependencies": { diff --git a/x-pack/package.json b/x-pack/package.json index 0c0924b51264af4..c09db674831210c 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -35,7 +35,6 @@ "@kbn/test": "link:../packages/kbn-test" }, "dependencies": { - "@elastic/safer-lodash-set": "link:../packages/elastic-safer-lodash-set", "@kbn/i18n": "link:../packages/kbn-i18n", "@kbn/interpreter": "link:../packages/kbn-interpreter", "@kbn/ui-framework": "link:../packages/kbn-ui-framework" diff --git a/yarn.lock b/yarn.lock index d1ff77429216c32..1c33d64afbec05f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1550,7 +1550,7 @@ "@types/node-jose" "1.1.0" node-jose "1.1.0" -"@elastic/safer-lodash-set@link:packages/elastic-safer-lodash-set": +"@elastic/safer-lodash-set@link:bazel-bin/packages/elastic-safer-lodash-set/npm_module": version "0.0.0" uid "" From 26315aa7510e996f8fe402ab3c20caa82bebfc0c Mon Sep 17 00:00:00 2001 From: ymao1 Date: Fri, 23 Apr 2021 16:54:54 -0400 Subject: [PATCH 09/37] [Alerting] Fixing null accessor error in index threshold alert (#98055) * Fixing null accessor error in index threshold alert * PR fixes Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../alert_types/index_threshold/alert_type.ts | 14 +- .../server/data/lib/time_series_query.test.ts | 140 +++++++++++++++++- .../server/data/lib/time_series_query.ts | 6 +- 3 files changed, 155 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index 4c0fafc95a5790d..a242c1e0eb29e3d 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -175,7 +175,19 @@ export function getAlertType( // console.log(`index_threshold: response: ${JSON.stringify(groupResults, null, 4)}`); for (const groupResult of groupResults) { const instanceId = groupResult.group; - const value = groupResult.metrics[0][1]; + const metric = + groupResult.metrics && groupResult.metrics.length > 0 ? groupResult.metrics[0] : null; + const value = metric && metric.length === 2 ? metric[1] : null; + + if (!value) { + logger.debug( + `alert ${ID}:${alertId} "${name}": no metrics found for group ${instanceId}} from groupResult ${JSON.stringify( + groupResult + )}` + ); + continue; + } + const met = compareFn(value, params.threshold); if (!met) continue; diff --git a/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.test.ts b/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.test.ts index 86d18d98fa0e125..37f6219cf30a561 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.test.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.test.ts @@ -7,8 +7,14 @@ // test error conditions of calling timeSeriesQuery - postive results tested in FT +import type { estypes } from '@elastic/elasticsearch'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { TimeSeriesQueryParameters, TimeSeriesQuery, timeSeriesQuery } from './time_series_query'; +import { + TimeSeriesQueryParameters, + TimeSeriesQuery, + timeSeriesQuery, + getResultFromEs, +} from './time_series_query'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from '../../../../../../src/core/server/elasticsearch/client/mocks'; @@ -53,3 +59,135 @@ describe('timeSeriesQuery', () => { ); }); }); + +describe('getResultFromEs', () => { + it('correctly parses time series results for count aggregation', () => { + expect( + getResultFromEs(true, false, { + took: 0, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 0, relation: 'eq' }, hits: [] }, + aggregations: { + dateAgg: { + buckets: [ + { + key: '2021-04-22T15:14:31.075Z-2021-04-22T15:19:31.075Z', + from: 1619104471075, + from_as_string: '2021-04-22T15:14:31.075Z', + to: 1619104771075, + to_as_string: '2021-04-22T15:19:31.075Z', + doc_count: 0, + }, + ], + }, + }, + } as estypes.SearchResponse) + ).toEqual({ + results: [ + { + group: 'all documents', + metrics: [['2021-04-22T15:19:31.075Z', 0]], + }, + ], + }); + }); + + it('correctly parses time series results with no aggregation data for count aggregation', () => { + // this could happen with cross cluster searches when cluster permissions are incorrect + // the query completes but doesn't return any aggregations + expect( + getResultFromEs(true, false, { + took: 0, + timed_out: false, + _shards: { total: 0, successful: 0, skipped: 0, failed: 0 }, + _clusters: { total: 1, successful: 1, skipped: 0 }, + hits: { total: { value: 0, relation: 'eq' }, hits: [] }, + } as estypes.SearchResponse) + ).toEqual({ + results: [], + }); + }); + + it('correctly parses time series results for group aggregation', () => { + expect( + getResultFromEs(false, true, { + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 298, relation: 'eq' }, hits: [] }, + aggregations: { + groupAgg: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'host-2', + doc_count: 149, + sortValueAgg: { value: 0.5000000018251423 }, + dateAgg: { + buckets: [ + { + key: '2021-04-22T15:18:43.191Z-2021-04-22T15:23:43.191Z', + from: 1619104723191, + from_as_string: '2021-04-22T15:18:43.191Z', + to: 1619105023191, + to_as_string: '2021-04-22T15:23:43.191Z', + doc_count: 149, + metricAgg: { value: 0.5000000018251423 }, + }, + ], + }, + }, + { + key: 'host-1', + doc_count: 149, + sortValueAgg: { value: 0.5000000011000857 }, + dateAgg: { + buckets: [ + { + key: '2021-04-22T15:18:43.191Z-2021-04-22T15:23:43.191Z', + from: 1619104723191, + from_as_string: '2021-04-22T15:18:43.191Z', + to: 1619105023191, + to_as_string: '2021-04-22T15:23:43.191Z', + doc_count: 149, + metricAgg: { value: 0.5000000011000857 }, + }, + ], + }, + }, + ], + }, + }, + } as estypes.SearchResponse) + ).toEqual({ + results: [ + { + group: 'host-2', + metrics: [['2021-04-22T15:23:43.191Z', 0.5000000018251423]], + }, + { + group: 'host-1', + metrics: [['2021-04-22T15:23:43.191Z', 0.5000000011000857]], + }, + ], + }); + }); + + it('correctly parses time series results with no aggregation data for group aggregation', () => { + // this could happen with cross cluster searches when cluster permissions are incorrect + // the query completes but doesn't return any aggregations + expect( + getResultFromEs(false, true, { + took: 0, + timed_out: false, + _shards: { total: 0, successful: 0, skipped: 0, failed: 0 }, + _clusters: { total: 1, successful: 1, skipped: 0 }, + hits: { total: { value: 0, relation: 'eq' }, hits: [] }, + } as estypes.SearchResponse) + ).toEqual({ + results: [], + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.ts b/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.ts index ad044f4570ea3d4..a2ba8d43c9c60ca 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/lib/time_series_query.ts @@ -147,7 +147,7 @@ export async function timeSeriesQuery( return getResultFromEs(isCountAgg, isGroupAgg, esResult); } -function getResultFromEs( +export function getResultFromEs( isCountAgg: boolean, isGroupAgg: boolean, esResult: estypes.SearchResponse @@ -155,8 +155,8 @@ function getResultFromEs( const aggregations = esResult?.aggregations || {}; // add a fake 'all documents' group aggregation, if a group aggregation wasn't used - if (!isGroupAgg) { - const dateAgg = aggregations.dateAgg || {}; + if (!isGroupAgg && aggregations.dateAgg) { + const dateAgg = aggregations.dateAgg; aggregations.groupAgg = { buckets: [{ key: 'all documents', dateAgg }], From 846aed54d979de30894967d1cf36cc1559aebcad Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Fri, 23 Apr 2021 14:24:41 -0700 Subject: [PATCH 10/37] Fixed Failing test for alerts list disable/mute operations. (#97057) * Fixed Failing test for alerts list disable/mute operations. * fixed tests * removed awating check for switch status changed * replaced api methos with ui call * fixed merge * reopen and close actions item menu * fixed mute * Update collapsed_item_actions.tsx Remove changes * removed retry * add retry * replaced try with try for time * inceased retry Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apps/triggers_actions_ui/alerts_list.ts | 45 +++++++------------ .../page_objects/triggers_actions_ui_page.ts | 9 +--- 2 files changed, 19 insertions(+), 35 deletions(-) diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts index 7b760dfb8b6a196..cbb1d2729e74c1f 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts @@ -50,12 +50,25 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { return createdAction; } + async function muteAlert(alertId: string) { + const { body: alert } = await supertest + .post(`/api/alerting/rule/${alertId}/_mute_all`) + .set('kbn-xsrf', 'foo'); + return alert; + } + + async function disableAlert(alertId: string) { + const { body: alert } = await supertest + .post(`/api/alerting/rule/${alertId}/_disable`) + .set('kbn-xsrf', 'foo'); + return alert; + } + async function refreshAlertsList() { await testSubjects.click('rulesTab'); } - // FLAKY: https://github.com/elastic/kibana/issues/95591 - describe.skip('alerts list', function () { + describe('alerts list', function () { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); await testSubjects.click('rulesTab'); @@ -138,25 +151,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should re-enable single alert', async () => { const createdAlert = await createAlert(); + await disableAlert(createdAlert.id); await refreshAlertsList(); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await testSubjects.click('collapsedItemActions'); await pageObjects.triggersActionsUI.toggleSwitch('disableSwitch'); - - await pageObjects.triggersActionsUI.ensureRuleActionToggleApplied( - createdAlert.name, - 'disableSwitch', - 'true' - ); - - await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); - - await testSubjects.click('collapsedItemActions'); - - await pageObjects.triggersActionsUI.toggleSwitch('disableSwitch'); - await pageObjects.triggersActionsUI.ensureRuleActionToggleApplied( createdAlert.name, 'disableSwitch', @@ -172,7 +173,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('collapsedItemActions'); await pageObjects.triggersActionsUI.toggleSwitch('muteSwitch'); - await pageObjects.triggersActionsUI.ensureRuleActionToggleApplied( createdAlert.name, 'muteSwitch', @@ -182,25 +182,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should unmute single alert', async () => { const createdAlert = await createAlert(); + await muteAlert(createdAlert.id); await refreshAlertsList(); - await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); - - await testSubjects.click('collapsedItemActions'); - - await pageObjects.triggersActionsUI.toggleSwitch('muteSwitch'); - - await pageObjects.triggersActionsUI.ensureRuleActionToggleApplied( - createdAlert.name, - 'muteSwitch', - 'true' - ); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await testSubjects.click('collapsedItemActions'); await pageObjects.triggersActionsUI.toggleSwitch('muteSwitch'); - await pageObjects.triggersActionsUI.ensureRuleActionToggleApplied( createdAlert.name, 'muteSwitch', diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts index 5fa442e289037ea..8eeabf1f5d670dd 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -146,13 +146,7 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) }, async toggleSwitch(testSubject: string) { const switchBtn = await testSubjects.find(testSubject); - const valueBefore = await switchBtn.getAttribute('aria-checked'); await switchBtn.click(); - await retry.try(async () => { - const switchBtnAfter = await testSubjects.find(testSubject); - const valueAfter = await switchBtnAfter.getAttribute('aria-checked'); - expect(valueAfter).not.to.eql(valueBefore); - }); }, async clickCreateAlertButton() { const createBtn = await find.byCssSelector( @@ -194,9 +188,10 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) switchName: string, shouldBeCheckedAsString: string ) { - await retry.try(async () => { + await retry.tryForTime(30000, async () => { await this.searchAlerts(ruleName); await testSubjects.click('collapsedItemActions'); + const switchControl = await testSubjects.find(switchName); const isChecked = await switchControl.getAttribute('aria-checked'); expect(isChecked).to.eql(shouldBeCheckedAsString); From 7b8d0b9bd8107447d12b9e62099d228101a742f6 Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Fri, 23 Apr 2021 18:10:16 -0400 Subject: [PATCH 11/37] [Maps] Hide label UX for 3rd party mvt lines and polygons (#97731) --- x-pack/plugins/maps/common/constants.ts | 2 + .../maps/public/classes/fields/mvt_field.ts | 3 +- .../maps/public/classes/layers/layer.tsx | 7 +- .../tiled_vector_layer.test.tsx | 47 +- .../tiled_vector_layer/tiled_vector_layer.tsx | 15 +- .../es_geo_grid_source/es_geo_grid_source.tsx | 13 +- .../es_search_source/es_search_source.tsx | 17 +- .../mvt_single_layer_vector_source.tsx | 6 +- .../tiled_single_layer_vector_source/index.ts | 8 + .../tiled_single_layer_vector_source.ts | 26 + .../sources/vector_source/vector_source.tsx | 14 - .../vector_style_editor.test.tsx.snap | 540 ++++++++++++++++++ .../components/vector_style_editor.test.tsx | 46 +- .../vector/components/vector_style_editor.tsx | 24 +- 14 files changed, 718 insertions(+), 50 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/index.ts create mode 100644 x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/tiled_single_layer_vector_source.ts diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 44e5f9d445c3da9..007368f0997df29 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -54,6 +54,8 @@ export const KBN_TOO_MANY_FEATURES_IMAGE_ID = '__kbn_too_many_features_image_id_ // Centroids are a single point for representing lines, multiLines, polygons, and multiPolygons export const KBN_IS_CENTROID_FEATURE = '__kbn_is_centroid_feature__'; +export const MVT_TOKEN_PARAM_NAME = 'token'; + const MAP_BASE_URL = `/${MAPS_APP_PATH}/${MAP_PATH}`; export function getNewMapPath() { return MAP_BASE_URL; diff --git a/x-pack/plugins/maps/public/classes/fields/mvt_field.ts b/x-pack/plugins/maps/public/classes/fields/mvt_field.ts index 2a837f831198a68..ed2955a1cc16f3c 100644 --- a/x-pack/plugins/maps/public/classes/fields/mvt_field.ts +++ b/x-pack/plugins/maps/public/classes/fields/mvt_field.ts @@ -7,7 +7,8 @@ import { AbstractField, IField } from './field'; import { FIELD_ORIGIN, MVT_FIELD_TYPE } from '../../../common/constants'; -import { ITiledSingleLayerVectorSource, IVectorSource } from '../sources/vector_source'; +import { IVectorSource } from '../sources/vector_source'; +import { ITiledSingleLayerVectorSource } from '../sources/tiled_single_layer_vector_source'; import { MVTFieldDescriptor } from '../../../common/descriptor_types'; export class MVTField extends AbstractField implements IField { diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 5786b5fb194b80d..59edaa8ed1b9511 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -18,6 +18,7 @@ import { DataRequest } from '../util/data_request'; import { AGG_TYPE, FIELD_ORIGIN, + LAYER_TYPE, MAX_ZOOM, MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, MIN_ZOOM, @@ -81,7 +82,7 @@ export interface ILayer { isInitialDataLoadComplete(): boolean; getIndexPatternIds(): string[]; getQueryableIndexPatternIds(): string[]; - getType(): string | undefined; + getType(): LAYER_TYPE | undefined; isVisible(): boolean; cloneDescriptor(): Promise; renderStyleEditor( @@ -483,8 +484,8 @@ export class AbstractLayer implements ILayer { mbMap.setLayoutProperty(mbLayerId, 'visibility', this.isVisible() ? 'visible' : 'none'); } - getType(): string | undefined { - return this._descriptor.type; + getType(): LAYER_TYPE | undefined { + return this._descriptor.type as LAYER_TYPE; } areLabelsOnTop(): boolean { diff --git a/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.test.tsx b/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.test.tsx index 408c2ec18164d89..e71d32669a564f9 100644 --- a/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.test.tsx @@ -7,6 +7,7 @@ import { MockSyncContext } from '../__fixtures__/mock_sync_context'; import sinon from 'sinon'; +import url from 'url'; jest.mock('../../../kibana_services', () => { return { @@ -38,7 +39,8 @@ const defaultConfig = { function createLayer( layerOptions: Partial = {}, sourceOptions: Partial = {}, - isTimeAware: boolean = false + isTimeAware: boolean = false, + includeToken: boolean = false ): TiledVectorLayer { const sourceDescriptor: TiledSingleLayerVectorSourceDescriptor = { type: SOURCE_TYPES.MVT_SINGLE_LAYER, @@ -57,6 +59,19 @@ function createLayer( }; } + if (includeToken) { + mvtSource.getUrlTemplateWithMeta = async (...args) => { + const superReturn = await MVTSingleLayerVectorSource.prototype.getUrlTemplateWithMeta.call( + mvtSource, + ...args + ); + return { + ...superReturn, + refreshTokenParamName: 'token', + }; + }; + } + const defaultLayerOptions = { ...layerOptions, sourceDescriptor, @@ -115,7 +130,7 @@ describe('syncData', () => { expect(call.args[2]!.minSourceZoom).toEqual(defaultConfig.minSourceZoom); expect(call.args[2]!.maxSourceZoom).toEqual(defaultConfig.maxSourceZoom); expect(call.args[2]!.layerName).toEqual(defaultConfig.layerName); - expect(call.args[2]!.urlTemplate!.startsWith(defaultConfig.urlTemplate)).toEqual(true); + expect(call.args[2]!.urlTemplate).toEqual(defaultConfig.urlTemplate); }); it('Should not resync when no changes to source params', async () => { @@ -193,8 +208,34 @@ describe('syncData', () => { expect(call.args[2]!.minSourceZoom).toEqual(newMeta.minSourceZoom); expect(call.args[2]!.maxSourceZoom).toEqual(newMeta.maxSourceZoom); expect(call.args[2]!.layerName).toEqual(newMeta.layerName); - expect(call.args[2]!.urlTemplate!.startsWith(newMeta.urlTemplate)).toEqual(true); + expect(call.args[2]!.urlTemplate).toEqual(newMeta.urlTemplate); }); }); }); + + describe('refresh token', () => { + const uuidRegex = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/; + + it(`should add token in url`, async () => { + const layer: TiledVectorLayer = createLayer({}, {}, false, true); + + const syncContext = new MockSyncContext({ dataFilters: {} }); + + await layer.syncData(syncContext); + // @ts-expect-error + sinon.assert.calledOnce(syncContext.startLoading); + // @ts-expect-error + sinon.assert.calledOnce(syncContext.stopLoading); + + // @ts-expect-error + const call = syncContext.stopLoading.getCall(0); + expect(call.args[2]!.minSourceZoom).toEqual(defaultConfig.minSourceZoom); + expect(call.args[2]!.maxSourceZoom).toEqual(defaultConfig.maxSourceZoom); + expect(call.args[2]!.layerName).toEqual(defaultConfig.layerName); + expect(call.args[2]!.urlTemplate.startsWith(defaultConfig.urlTemplate)).toBe(true); + + const parsedUrl = url.parse(call.args[2]!.urlTemplate, true); + expect(!!(parsedUrl.query.token! as string).match(uuidRegex)).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.tsx index 90c4896f2a287e9..d452096250576d3 100644 --- a/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/tiled_vector_layer/tiled_vector_layer.tsx @@ -14,10 +14,11 @@ import { import { EuiIcon } from '@elastic/eui'; import { Feature } from 'geojson'; import uuid from 'uuid/v4'; +import { parse as parseUrl } from 'url'; import { IVectorStyle, VectorStyle } from '../../styles/vector/vector_style'; import { SOURCE_DATA_REQUEST_ID, LAYER_TYPE } from '../../../../common/constants'; import { VectorLayer, VectorLayerArguments } from '../vector_layer'; -import { ITiledSingleLayerVectorSource } from '../../sources/vector_source'; +import { ITiledSingleLayerVectorSource } from '../../sources/tiled_single_layer_vector_source'; import { DataRequestContext } from '../../../actions'; import { VectorLayerDescriptor, @@ -103,10 +104,20 @@ export class TiledVectorLayer extends VectorLayer { : prevData.urlToken; const newUrlTemplateAndMeta = await this._source.getUrlTemplateWithMeta(searchFilters); + + let urlTemplate; + if (newUrlTemplateAndMeta.refreshTokenParamName) { + const parsedUrl = parseUrl(newUrlTemplateAndMeta.urlTemplate, true); + const separator = !parsedUrl.query || Object.keys(parsedUrl.query).length === 0 ? '?' : '&'; + urlTemplate = `${newUrlTemplateAndMeta.urlTemplate}${separator}${newUrlTemplateAndMeta.refreshTokenParamName}=${urlToken}`; + } else { + urlTemplate = newUrlTemplateAndMeta.urlTemplate; + } + const urlTemplateAndMetaWithToken = { ...newUrlTemplateAndMeta, urlToken, - urlTemplate: newUrlTemplateAndMeta.urlTemplate + `&token=${urlToken}`, + urlTemplate, }; stopLoading(SOURCE_DATA_REQUEST_ID, requestToken, urlTemplateAndMetaWithToken, {}); } catch (error) { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index e9cf62d8f408934..7bca22df9b870ba 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -27,6 +27,7 @@ import { GRID_RESOLUTION, MVT_GETGRIDTILE_API_PATH, MVT_SOURCE_LAYER_NAME, + MVT_TOKEN_PARAM_NAME, RENDER_AS, SOURCE_TYPES, VECTOR_SHAPE_TYPE, @@ -38,7 +39,8 @@ import { registerSource } from '../source_registry'; import { LICENSED_FEATURES } from '../../../licensed_features'; import { getHttp } from '../../../kibana_services'; -import { GeoJsonWithMeta, ITiledSingleLayerVectorSource } from '../vector_source'; +import { GeoJsonWithMeta } from '../vector_source'; +import { ITiledSingleLayerVectorSource } from '../tiled_single_layer_vector_source'; import { ESGeoGridSourceDescriptor, MapExtent, @@ -50,6 +52,7 @@ import { ISearchSource } from '../../../../../../../src/plugins/data/common/sear import { IndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; import { isValidStringConfig } from '../../util/valid_string_config'; +import { ITiledSingleLayerMvtParams } from '../tiled_single_layer_vector_source/tiled_single_layer_vector_source'; export const MAX_GEOTILE_LEVEL = 29; @@ -420,12 +423,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle async getUrlTemplateWithMeta( searchFilters: VectorSourceRequestMeta - ): Promise<{ - layerName: string; - urlTemplate: string; - minSourceZoom: number; - maxSourceZoom: number; - }> { + ): Promise { const indexPattern = await this.getIndexPattern(); const searchSource = await this.makeSearchSource(searchFilters, 0); @@ -453,6 +451,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle &geoFieldType=${geoField.type}`; return { + refreshTokenParamName: MVT_TOKEN_PARAM_NAME, layerName: this.getLayerName(), minSourceZoom: this.getMinZoom(), maxSourceZoom: this.getMaxZoom(), diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index ff4675413985c79..3de98fd54582774 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -31,6 +31,7 @@ import { GIS_API_PATH, MVT_GETTILE_API_PATH, MVT_SOURCE_LAYER_NAME, + MVT_TOKEN_PARAM_NAME, SCALING_TYPES, SOURCE_TYPES, VECTOR_SHAPE_TYPE, @@ -51,17 +52,15 @@ import { import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; import { ImmutableSourceProperty, SourceEditorArgs } from '../source'; import { IField } from '../../fields/field'; -import { - GeoJsonWithMeta, - ITiledSingleLayerVectorSource, - SourceTooltipConfig, -} from '../vector_source'; +import { GeoJsonWithMeta, SourceTooltipConfig } from '../vector_source'; +import { ITiledSingleLayerVectorSource } from '../tiled_single_layer_vector_source'; import { ITooltipProperty } from '../../tooltips/tooltip_property'; import { DataRequest } from '../../util/data_request'; import { SortDirection, SortDirectionNumeric } from '../../../../../../../src/plugins/data/common'; import { isValidStringConfig } from '../../util/valid_string_config'; import { TopHitsUpdateSourceEditor } from './top_hits'; import { getDocValueAndSourceFields, ScriptField } from './get_docvalue_source_fields'; +import { ITiledSingleLayerMvtParams } from '../tiled_single_layer_vector_source/tiled_single_layer_vector_source'; export const sourceTitle = i18n.translate('xpack.maps.source.esSearchTitle', { defaultMessage: 'Documents', @@ -674,12 +673,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye async getUrlTemplateWithMeta( searchFilters: VectorSourceRequestMeta - ): Promise<{ - layerName: string; - urlTemplate: string; - minSourceZoom: number; - maxSourceZoom: number; - }> { + ): Promise { const indexPattern = await this.getIndexPattern(); const indexSettings = await loadIndexSettings(indexPattern.title); @@ -722,6 +716,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye &geoFieldType=${geoField.type}`; return { + refreshTokenParamName: MVT_TOKEN_PARAM_NAME, layerName: this.getLayerName(), minSourceZoom: this.getMinZoom(), maxSourceZoom: this.getMaxZoom(), diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx index 4e4d9e9eee5d20f..92b643643ba2a82 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx @@ -10,7 +10,8 @@ import uuid from 'uuid/v4'; import React from 'react'; import { GeoJsonProperties } from 'geojson'; import { AbstractSource, ImmutableSourceProperty, SourceEditorArgs } from '../source'; -import { BoundsFilters, GeoJsonWithMeta, ITiledSingleLayerVectorSource } from '../vector_source'; +import { BoundsFilters, GeoJsonWithMeta } from '../vector_source'; +import { ITiledSingleLayerVectorSource } from '../tiled_single_layer_vector_source'; import { FIELD_ORIGIN, MAX_ZOOM, @@ -30,6 +31,7 @@ import { MVTField } from '../../fields/mvt_field'; import { UpdateSourceEditor } from './update_source_editor'; import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_property'; import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; +import { ITiledSingleLayerMvtParams } from '../tiled_single_layer_vector_source/tiled_single_layer_vector_source'; export const sourceTitle = i18n.translate( 'xpack.maps.source.MVTSingleLayerVectorSource.sourceTitle', @@ -154,7 +156,7 @@ export class MVTSingleLayerVectorSource return this.getLayerName(); } - async getUrlTemplateWithMeta() { + async getUrlTemplateWithMeta(): Promise { return { urlTemplate: this._descriptor.urlTemplate, layerName: this._descriptor.layerName, diff --git a/x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/index.ts b/x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/index.ts new file mode 100644 index 000000000000000..30177751a8d5552 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ITiledSingleLayerVectorSource } from './tiled_single_layer_vector_source'; diff --git a/x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/tiled_single_layer_vector_source.ts b/x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/tiled_single_layer_vector_source.ts new file mode 100644 index 000000000000000..013c3f9f0d7e17d --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/tiled_single_layer_vector_source/tiled_single_layer_vector_source.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { VectorSourceRequestMeta } from '../../../../common/descriptor_types'; +import { IVectorSource } from '../vector_source'; + +export interface ITiledSingleLayerMvtParams { + layerName: string; + urlTemplate: string; + minSourceZoom: number; + maxSourceZoom: number; + refreshTokenParamName?: string; +} + +export interface ITiledSingleLayerVectorSource extends IVectorSource { + getUrlTemplateWithMeta( + searchFilters: VectorSourceRequestMeta + ): Promise; + getMinZoom(): number; + getMaxZoom(): number; + getLayerName(): string; +} diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx index e86e459851c7064..b28cd7365d69e56 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx @@ -66,20 +66,6 @@ export interface IVectorSource extends ISource { getSourceTooltipContent(sourceDataRequest?: DataRequest): SourceTooltipConfig; } -export interface ITiledSingleLayerVectorSource extends IVectorSource { - getUrlTemplateWithMeta( - searchFilters: VectorSourceRequestMeta - ): Promise<{ - layerName: string; - urlTemplate: string; - minSourceZoom: number; - maxSourceZoom: number; - }>; - getMinZoom(): number; - getMaxZoom(): number; - getLayerName(): string; -} - export class AbstractVectorSource extends AbstractSource implements IVectorSource { getFieldNames(): string[] { return []; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap index be8c9b0750b94ed..64da5777988d1b8 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap @@ -384,6 +384,546 @@ exports[`should render 1`] = `
`; +exports[`should render line-style with label properties when ES-source is rendered as mvt 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`should render polygon-style without label properties when 3rd party mvt 1`] = ` + + + + + + + + + + + +`; + exports[`should render with no style fields 1`] = ` { class MockField extends AbstractField {} -function createLayerMock(numFields: number, supportedShapeTypes: VECTOR_SHAPE_TYPE[]) { +function createLayerMock( + numFields: number, + supportedShapeTypes: VECTOR_SHAPE_TYPE[], + layerType: LAYER_TYPE = LAYER_TYPE.VECTOR, + isESSource: boolean = false +) { const fields: IField[] = []; for (let i = 0; i < numFields; i++) { fields.push(new MockField({ fieldName: `field${i}`, origin: FIELD_ORIGIN.SOURCE })); @@ -39,11 +45,17 @@ function createLayerMock(numFields: number, supportedShapeTypes: VECTOR_SHAPE_TY getStyleEditorFields: async () => { return fields; }, + getType() { + return layerType; + }, getSource: () => { return ({ getSupportedShapeTypes: async () => { return supportedShapeTypes; }, + isESSource() { + return isESSource; + }, } as unknown) as IVectorSource; }, } as unknown) as IVectorLayer; @@ -99,3 +111,35 @@ test('should render with no style fields', async () => { expect(component).toMatchSnapshot(); }); + +test('should render polygon-style without label properties when 3rd party mvt', async () => { + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); +}); + +test('should render line-style with label properties when ES-source is rendered as mvt', async () => { + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx index 91bcc2dc0685977..4fb2887c52876fb 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx @@ -9,7 +9,7 @@ import _ from 'lodash'; import React, { Component, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiButtonGroup, EuiFormRow, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; +import { EuiButtonGroup, EuiFormRow, EuiSpacer, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import { VectorStyleColorEditor } from './color/vector_style_color_editor'; import { VectorStyleSizeEditor } from './size/vector_style_size_editor'; // @ts-expect-error @@ -25,9 +25,10 @@ import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_palettes'; import { LABEL_BORDER_SIZES, - VECTOR_STYLES, + LAYER_TYPE, STYLE_TYPE, VECTOR_SHAPE_TYPE, + VECTOR_STYLES, } from '../../../../../common/constants'; import { createStyleFieldsHelper, StyleField, StyleFieldsHelper } from '../style_fields_helper'; import { @@ -257,7 +258,18 @@ export class VectorStyleEditor extends Component { ); } - _renderLabelProperties() { + _renderLabelProperties(isPoints: boolean) { + if ( + !isPoints && + this.props.layer.getType() === LAYER_TYPE.TILED_VECTOR && + !this.props.layer.getSource().isESSource() + ) { + // This handles and edge-case + // 3rd party lines and polygons from mvt sources cannot be labeled, because they do not have label-centroid geometries inside the tile. + // These label-centroids are only added for ES-sources + return; + } + const hasLabel = this._hasLabel(); const hasLabelBorder = this._hasLabelBorder(); return ( @@ -456,7 +468,7 @@ export class VectorStyleEditor extends Component { /> - {this._renderLabelProperties()} + {this._renderLabelProperties(true)} ); } @@ -470,7 +482,7 @@ export class VectorStyleEditor extends Component { {this._renderLineWidth()} - {this._renderLabelProperties()} + {this._renderLabelProperties(false)} ); } @@ -487,7 +499,7 @@ export class VectorStyleEditor extends Component { {this._renderLineWidth()} - {this._renderLabelProperties()} + {this._renderLabelProperties(false)} ); } From 4ab00998f269334279358a133da6312d770c03df Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 23 Apr 2021 16:58:49 -0600 Subject: [PATCH 12/37] [maps] convert RightSideControls to TS (#98092) * convert RightSideControls to TS * rename WidgetOverlayControl to RightSideControls * fix rename in scss --- .../public/connected_components/_index.scss | 2 +- .../map_container/map_container.tsx | 5 ++--- .../_index.scss | 4 ++-- .../_mixins.scss | 0 .../_right_side_controls.scss} | 0 .../attribution_control.test.tsx.snap} | 2 +- .../_attribution_control.scss | 0 .../attribution_control.test.tsx} | 17 ++++++++++------- .../attribution_control.tsx} | 19 ++++++++++++++++--- .../attribution_control/index.ts} | 13 +++++-------- .../index.js => right_side_controls/index.ts} | 10 +++++----- .../__snapshots__/layer_control.test.tsx.snap | 0 .../layer_control/_index.scss | 0 .../layer_control/_layer_control.scss | 0 .../layer_control/index.ts | 0 .../layer_control/layer_control.test.tsx | 0 .../layer_control/layer_control.tsx | 0 .../__snapshots__/layer_toc.test.tsx.snap | 0 .../layer_control/layer_toc/index.ts | 0 .../layer_toc/layer_toc.test.tsx | 0 .../layer_control/layer_toc/layer_toc.tsx | 0 .../__snapshots__/toc_entry.test.tsx.snap | 0 .../layer_toc/toc_entry/_toc_entry.scss | 0 .../layer_toc/toc_entry/action_labels.ts | 0 .../layer_toc/toc_entry/index.ts | 0 .../layer_toc/toc_entry/toc_entry.test.tsx | 0 .../layer_toc/toc_entry/toc_entry.tsx | 0 .../toc_entry_actions_popover.test.tsx.snap | 0 .../toc_entry_actions_popover/index.ts | 0 .../toc_entry_actions_popover.test.tsx | 0 .../toc_entry_actions_popover.tsx | 0 .../toc_entry/toc_entry_button/index.ts | 0 .../toc_entry_button/toc_entry_button.tsx | 0 .../_mouse_coordinates_control.scss} | 0 .../mouse_coordinates_control/index.ts} | 9 +++++---- .../mouse_coordinates_control.tsx} | 12 ++++++++++-- .../right_side_controls.tsx} | 13 ++++++++++--- 37 files changed, 67 insertions(+), 39 deletions(-) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/_index.scss (51%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/_mixins.scss (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay/_widget_overlay.scss => right_side_controls/_right_side_controls.scss} (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay/attribution_control/__snapshots__/view.test.js.snap => right_side_controls/attribution_control/__snapshots__/attribution_control.test.tsx.snap} (85%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/attribution_control/_attribution_control.scss (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay/attribution_control/view.test.js => right_side_controls/attribution_control/attribution_control.test.tsx} (68%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay/attribution_control/view.js => right_side_controls/attribution_control/attribution_control.tsx} (82%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay/attribution_control/index.js => right_side_controls/attribution_control/index.ts} (64%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay/index.js => right_side_controls/index.ts} (60%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/__snapshots__/layer_control.test.tsx.snap (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/_index.scss (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/_layer_control.scss (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/index.ts (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_control.test.tsx (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_control.tsx (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/__snapshots__/layer_toc.test.tsx.snap (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/index.ts (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/layer_toc.test.tsx (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/layer_toc.tsx (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/toc_entry/__snapshots__/toc_entry.test.tsx.snap (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/toc_entry/_toc_entry.scss (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/toc_entry/action_labels.ts (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/toc_entry/index.ts (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/toc_entry/toc_entry.test.tsx (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/toc_entry/toc_entry.tsx (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/toc_entry/toc_entry_button/index.ts (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay => right_side_controls}/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay/view_control/_view_control.scss => right_side_controls/mouse_coordinates_control/_mouse_coordinates_control.scss} (100%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay/view_control/index.js => right_side_controls/mouse_coordinates_control/index.ts} (61%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay/view_control/view_control.js => right_side_controls/mouse_coordinates_control/mouse_coordinates_control.tsx} (87%) rename x-pack/plugins/maps/public/connected_components/{widget_overlay/widget_overlay.js => right_side_controls/right_side_controls.tsx} (71%) diff --git a/x-pack/plugins/maps/public/connected_components/_index.scss b/x-pack/plugins/maps/public/connected_components/_index.scss index a1a65796dc94ac9..2a6e1a8982e6393 100644 --- a/x-pack/plugins/maps/public/connected_components/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/_index.scss @@ -1,6 +1,6 @@ @import 'map_container/map_container'; @import 'layer_panel/index'; -@import 'widget_overlay/index'; +@import 'right_side_controls/index'; @import 'toolbar_overlay/index'; @import 'mb_map/features_tooltip/index'; @import 'mb_map/scale_control/index'; diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index 525ba394ed50370..e0cfe978bf45cf4 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -14,8 +14,7 @@ import uuid from 'uuid/v4'; import { Filter } from 'src/plugins/data/public'; import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; import { MBMap } from '../mb_map'; -// @ts-expect-error -import { WidgetOverlay } from '../widget_overlay'; +import { RightSideControls } from '../right_side_controls'; import { ToolbarOverlay } from '../toolbar_overlay'; // @ts-expect-error import { LayerPanel } from '../layer_panel'; @@ -263,7 +262,7 @@ export class MapContainer extends Component { getActionContext={getActionContext} /> )} - + { test('is rendered', async () => { - const mockLayer1 = { + const mockLayer1 = ({ getAttributions: async () => { return [{ url: '', label: 'attribution with no link' }]; }, - }; - const mockLayer2 = { + } as unknown) as ILayer; + const mockLayer2 = ({ getAttributions: async () => { return [{ url: 'https://coolmaps.com', label: 'attribution with link' }]; }, - }; - const component = shallowWithIntl(); + } as unknown) as ILayer; + const component = shallow( + + ); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/view.js b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx similarity index 82% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/view.js rename to x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx index 2eb776134286a40..3d36f629446366c 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/view.js +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx @@ -5,12 +5,24 @@ * 2.0. */ -import React, { Fragment } from 'react'; +import React, { Component, Fragment } from 'react'; import _ from 'lodash'; import { EuiText, EuiLink } from '@elastic/eui'; import classNames from 'classnames'; +import { Attribution } from '../../../classes/sources/source'; +import { ILayer } from '../../../classes/layers/layer'; -export class AttributionControl extends React.Component { +export interface Props { + isFullScreen: boolean; + layerList: ILayer[]; +} + +interface State { + uniqueAttributions: Attribution[]; +} + +export class AttributionControl extends Component { + private _isMounted = false; state = { uniqueAttributions: [], }; @@ -60,7 +72,7 @@ export class AttributionControl extends React.Component { } }; - _renderAttribution({ url, label }) { + _renderAttribution({ url, label }: Attribution) { if (!url) { return label; } @@ -90,6 +102,7 @@ export class AttributionControl extends React.Component { return (
diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/index.js b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/index.ts similarity index 64% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/index.js rename to x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/index.ts index 32e93465c3c489b..9c1dfee6e011141 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/index.js +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/index.ts @@ -6,20 +6,17 @@ */ import { connect } from 'react-redux'; -import { AttributionControl } from './view'; +import { AttributionControl } from './attribution_control'; import { getLayerList } from '../../../selectors/map_selectors'; import { getIsFullScreen } from '../../../selectors/ui_selectors'; +import { MapStoreState } from '../../../reducers/store'; -function mapStateToProps(state = {}) { +function mapStateToProps(state: MapStoreState) { return { layerList: getLayerList(state), isFullScreen: getIsFullScreen(state), }; } -function mapDispatchToProps() { - return {}; -} - -const connectedViewControl = connect(mapStateToProps, mapDispatchToProps)(AttributionControl); -export { connectedViewControl as AttributionControl }; +const connected = connect(mapStateToProps, {})(AttributionControl); +export { connected as AttributionControl }; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/index.js b/x-pack/plugins/maps/public/connected_components/right_side_controls/index.ts similarity index 60% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/index.js rename to x-pack/plugins/maps/public/connected_components/right_side_controls/index.ts index d1f003ae4bc3d87..8b77726e5514dee 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/index.js +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/index.ts @@ -6,15 +6,15 @@ */ import { connect } from 'react-redux'; -import { WidgetOverlay } from './widget_overlay'; - +import { RightSideControls } from './right_side_controls'; import { getMapSettings } from '../../selectors/map_selectors'; +import { MapStoreState } from '../../reducers/store'; -function mapStateToProps(state = {}) { +function mapStateToProps(state: MapStoreState) { return { settings: getMapSettings(state), }; } -const connectedWidgetOverlay = connect(mapStateToProps, null)(WidgetOverlay); -export { connectedWidgetOverlay as WidgetOverlay }; +const connected = connect(mapStateToProps, {})(RightSideControls); +export { connected as RightSideControls }; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/__snapshots__/layer_control.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/__snapshots__/layer_control.test.tsx.snap similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/__snapshots__/layer_control.test.tsx.snap rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/__snapshots__/layer_control.test.tsx.snap diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/_index.scss b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/_index.scss similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/_index.scss rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/_index.scss diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/_layer_control.scss b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/_layer_control.scss similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/_layer_control.scss rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/_layer_control.scss diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/index.ts b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/index.ts similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/index.ts rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/index.ts diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_control.test.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.test.tsx similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_control.test.tsx rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.test.tsx diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_control.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.tsx similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_control.tsx rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.tsx diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/__snapshots__/layer_toc.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/__snapshots__/layer_toc.test.tsx.snap similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/__snapshots__/layer_toc.test.tsx.snap rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/__snapshots__/layer_toc.test.tsx.snap diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/index.ts b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/index.ts similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/index.ts rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/index.ts diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/layer_toc.test.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/layer_toc.test.tsx similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/layer_toc.test.tsx rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/layer_toc.test.tsx diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/layer_toc.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/layer_toc.tsx similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/layer_toc.tsx rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/layer_toc.tsx diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/__snapshots__/toc_entry.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/__snapshots__/toc_entry.test.tsx.snap similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/__snapshots__/toc_entry.test.tsx.snap rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/__snapshots__/toc_entry.test.tsx.snap diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/_toc_entry.scss b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/_toc_entry.scss similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/_toc_entry.scss rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/_toc_entry.scss diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/action_labels.ts b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/action_labels.ts similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/action_labels.ts rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/action_labels.ts diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/index.ts b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/index.ts similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/index.ts rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/index.ts diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry.test.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry.test.tsx similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry.test.tsx rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry.test.tsx diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry.tsx similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry.tsx rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry.tsx diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_button/index.ts b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_button/index.ts similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_button/index.ts rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_button/index.ts diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx rename to x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/_view_control.scss b/x-pack/plugins/maps/public/connected_components/right_side_controls/mouse_coordinates_control/_mouse_coordinates_control.scss similarity index 100% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/_view_control.scss rename to x-pack/plugins/maps/public/connected_components/right_side_controls/mouse_coordinates_control/_mouse_coordinates_control.scss diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/index.js b/x-pack/plugins/maps/public/connected_components/right_side_controls/mouse_coordinates_control/index.ts similarity index 61% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/index.js rename to x-pack/plugins/maps/public/connected_components/right_side_controls/mouse_coordinates_control/index.ts index a3a7865b61cb654..fa094dd0d6b7ff1 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/index.js +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/mouse_coordinates_control/index.ts @@ -6,15 +6,16 @@ */ import { connect } from 'react-redux'; -import { ViewControl } from './view_control'; +import { MouseCoordinatesControl } from './mouse_coordinates_control'; import { getMouseCoordinates, getMapZoom } from '../../../selectors/map_selectors'; +import { MapStoreState } from '../../../reducers/store'; -function mapStateToProps(state = {}) { +function mapStateToProps(state: MapStoreState) { return { mouseCoordinates: getMouseCoordinates(state), zoom: getMapZoom(state), }; } -const connectedViewControl = connect(mapStateToProps, null)(ViewControl); -export { connectedViewControl as ViewControl }; +const connected = connect(mapStateToProps, {})(MouseCoordinatesControl); +export { connected as MouseCoordinatesControl }; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/view_control.js b/x-pack/plugins/maps/public/connected_components/right_side_controls/mouse_coordinates_control/mouse_coordinates_control.tsx similarity index 87% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/view_control.js rename to x-pack/plugins/maps/public/connected_components/right_side_controls/mouse_coordinates_control/mouse_coordinates_control.tsx index 409c6fd5ca44cbc..32c9f2f58ecf2e8 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/view_control.js +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/mouse_coordinates_control/mouse_coordinates_control.tsx @@ -8,10 +8,18 @@ import _ from 'lodash'; import React, { Fragment } from 'react'; import { EuiText } from '@elastic/eui'; -import { DECIMAL_DEGREES_PRECISION } from '../../../../common/constants'; import { FormattedMessage } from '@kbn/i18n/react'; +import { DECIMAL_DEGREES_PRECISION } from '../../../../common/constants'; + +export interface Props { + mouseCoordinates?: { + lat: number; + lon: number; + }; + zoom: number; +} -export function ViewControl({ mouseCoordinates, zoom }) { +export function MouseCoordinatesControl({ mouseCoordinates, zoom }: Props) { let latLon; if (mouseCoordinates) { latLon = ( diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/widget_overlay.js b/x-pack/plugins/maps/public/connected_components/right_side_controls/right_side_controls.tsx similarity index 71% rename from x-pack/plugins/maps/public/connected_components/widget_overlay/widget_overlay.js rename to x-pack/plugins/maps/public/connected_components/right_side_controls/right_side_controls.tsx index f7a362c79dcc87e..12f283597f42adf 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/widget_overlay.js +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/right_side_controls.tsx @@ -8,10 +8,15 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { LayerControl } from './layer_control'; -import { ViewControl } from './view_control'; +import { MouseCoordinatesControl } from './mouse_coordinates_control'; import { AttributionControl } from './attribution_control'; +import { MapSettings } from '../../reducers/map'; -export function WidgetOverlay({ settings }) { +export interface Props { + settings: MapSettings; +} + +export function RightSideControls({ settings }: Props) { return ( {!settings.hideLayerControl && } - {!settings.hideViewControl && } + + {!settings.hideViewControl && } + From a510c275651d558caefda351985f17d5bfdb0274 Mon Sep 17 00:00:00 2001 From: Constance Date: Fri, 23 Apr 2021 17:14:51 -0700 Subject: [PATCH 13/37] [App Search] Synonyms: initial logic, cards, & pagination (#98101) * Set up initial SynonymsLogic * Set up SynonymCard & Icon components * Set up EmptyState component * Update Synonyms view with new components --- .../synonyms/components/empty_state.test.tsx | 27 ++++ .../synonyms/components/empty_state.tsx | 52 ++++++++ .../components/synonyms/components/index.ts | 10 ++ .../synonyms/components/synonym_card.test.tsx | 38 ++++++ .../synonyms/components/synonym_card.tsx | 45 +++++++ .../synonyms/components/synonym_icon.test.tsx | 19 +++ .../synonyms/components/synonym_icon.tsx | 26 ++++ .../components/synonyms/constants.ts | 9 ++ .../app_search/components/synonyms/index.ts | 1 + .../components/synonyms/synonyms.test.tsx | 110 ++++++++++++++- .../components/synonyms/synonyms.tsx | 71 +++++++++- .../synonyms/synonyms_logic.test.ts | 125 ++++++++++++++++++ .../components/synonyms/synonyms_logic.ts | 79 +++++++++++ .../app_search/components/synonyms/types.ts | 18 +++ 14 files changed, 624 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/types.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.test.tsx new file mode 100644 index 000000000000000..f1382bb5972b21f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.test.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; + +import { EmptyState } from './'; + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow() + .find(EuiEmptyPrompt) + .dive(); + + expect(wrapper.find('h2').text()).toEqual('Create your first synonym set'); + expect(wrapper.find(EuiButton).prop('href')).toEqual( + expect.stringContaining('/synonyms-guide.html') + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.tsx new file mode 100644 index 000000000000000..2eb6643bda5032b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/empty_state.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiPanel, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { DOCS_PREFIX } from '../../../routes'; + +import { SynonymIcon } from './'; + +export const EmptyState: React.FC = () => { + return ( + + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.synonyms.empty.title', { + defaultMessage: 'Create your first synonym set', + })} + + } + body={ +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.engine.synonyms.empty.description', { + defaultMessage: + 'Synonyms relate queries with similar context or meaning together. Use them to guide users to relevant content.', + })} +

+ } + actions={ + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.synonyms.empty.buttonLabel', { + defaultMessage: 'Read the synonyms guide', + })} + + } + /> +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/index.ts new file mode 100644 index 000000000000000..8a2bf1c0d2f7820 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SynonymIcon } from './synonym_icon'; +export { SynonymCard } from './synonym_card'; +export { EmptyState } from './empty_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.test.tsx new file mode 100644 index 000000000000000..ef24e206ed6814b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCard, EuiButton } from '@elastic/eui'; + +import { SynonymCard, SynonymIcon } from './'; + +describe('SynonymCard', () => { + const MOCK_SYNONYM_SET = { + id: 'syn-1234567890', + synonyms: ['lorem', 'ipsum', 'dolor', 'sit', 'amet'], + }; + + const wrapper = shallow() + .find(EuiCard) + .dive(); + + it('renders with the first synonym as the title', () => { + expect(wrapper.find('h2').text()).toEqual('lorem'); + }); + + it('renders a synonym icon for each subsequent synonym', () => { + expect(wrapper.find(SynonymIcon)).toHaveLength(4); + }); + + it('renders a manage synonym button', () => { + wrapper.find(EuiButton).simulate('click'); + // TODO: expect open modal action + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.tsx new file mode 100644 index 000000000000000..77363306527c3a8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_card.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiText, EuiButton } from '@elastic/eui'; + +import { MANAGE_BUTTON_LABEL } from '../../../../shared/constants'; + +import { SynonymSet } from '../types'; + +import { SynonymIcon } from './'; + +export const SynonymCard: React.FC = (synonymSet) => { + const [firstSynonym, ...remainingSynonyms] = synonymSet.synonyms; + + return ( + + + {} /* TODO */}>{MANAGE_BUTTON_LABEL} + +
+ } + > + + {remainingSynonyms.map((synonym) => ( +
+ {synonym} +
+ ))} +
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.test.tsx new file mode 100644 index 000000000000000..8120532fbd6f613 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.test.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { SynonymIcon } from './'; + +describe('SynonymIcon', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.hasClass('euiIcon')).toBe(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.tsx new file mode 100644 index 000000000000000..f76b8be818c4743 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/components/synonym_icon.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; + +export const SynonymIcon: React.FC = ({ ...props }) => ( + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/constants.ts index cbbd1e631b7ef4a..2cb50b6cba1b3e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/constants.ts @@ -7,6 +7,15 @@ import { i18n } from '@kbn/i18n'; +import { DEFAULT_META } from '../../../shared/constants'; + +export const SYNONYMS_PAGE_META = { + page: { + ...DEFAULT_META.page, + size: 12, // Use a multiple of 3, since synonym cards are in rows of 3 + }, +}; + export const SYNONYMS_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.synonyms.title', { defaultMessage: 'Synonyms' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/index.ts index 177bc5eade0f67d..4b9de7ef9060330 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/index.ts @@ -7,3 +7,4 @@ export { SYNONYMS_TITLE } from './constants'; export { Synonyms } from './synonyms'; +export { SynonymsLogic } from './synonyms_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx index e093442f77b773d..11692a1542c4d90 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.test.tsx @@ -5,17 +5,123 @@ * 2.0. */ +import { setMockValues, setMockActions, rerender } from '../../../__mocks__'; +import '../../../__mocks__/shallow_useeffect.mock'; import '../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; +import { EuiPageHeader, EuiButton, EuiPagination } from '@elastic/eui'; + +import { Loading } from '../../../shared/loading'; + +import { SynonymCard, EmptyState } from './components'; + import { Synonyms } from './'; describe('Synonyms', () => { + const MOCK_SYNONYM_SET = { + id: 'syn-1234567890', + synonyms: ['a', 'b', 'c'], + }; + + const values = { + synonymSets: [MOCK_SYNONYM_SET, MOCK_SYNONYM_SET, MOCK_SYNONYM_SET], + meta: { page: { current: 1 } }, + dataLoading: false, + }; + const actions = { + loadSynonyms: jest.fn(), + onPaginate: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + it('renders', () => { - shallow(); - // TODO: Check for Synonym cards, Synonym modal + const wrapper = shallow(); + + expect(wrapper.find(SynonymCard)).toHaveLength(3); + // TODO: Check for synonym modal + }); + + it('renders a create action button', () => { + const wrapper = shallow() + .find(EuiPageHeader) + .dive() + .children() + .dive(); + + wrapper.find(EuiButton).simulate('click'); + // TODO: Expect open modal action + }); + + it('renders an empty state if no synonyms exist', () => { + setMockValues({ ...values, synonymSets: [] }); + const wrapper = shallow(); + + expect(wrapper.find(EmptyState)).toHaveLength(1); + }); + + describe('loading', () => { + it('renders a loading state on initial page load', () => { + setMockValues({ ...values, synonymSets: [], dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('does not render a full loading state after initial page load', () => { + setMockValues({ ...values, synonymSets: [MOCK_SYNONYM_SET], dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(0); + }); + }); + + describe('API & pagination', () => { + it('loads synonyms on page load and on pagination', () => { + const wrapper = shallow(); + expect(actions.loadSynonyms).toHaveBeenCalledTimes(1); + + setMockValues({ ...values, meta: { page: { current: 5 } } }); + rerender(wrapper); + expect(actions.loadSynonyms).toHaveBeenCalledTimes(2); + }); + + it('automatically paginations users back a page if they delete the only remaining synonym on the page', () => { + setMockValues({ ...values, meta: { page: { current: 5 } }, synonymSets: [] }); + shallow(); + + expect(actions.onPaginate).toHaveBeenCalledWith(4); + }); + + it('does not paginate backwards if the user is on the first page (should show the state instead)', () => { + setMockValues({ ...values, meta: { page: { current: 1 } }, synonymSets: [] }); + const wrapper = shallow(); + + expect(actions.onPaginate).not.toHaveBeenCalled(); + expect(wrapper.find(EmptyState)).toHaveLength(1); + }); + + it('handles off-by-one shenanigans between EuiPagination and our API', () => { + setMockValues({ + ...values, + meta: { page: { total_pages: 10, current: 1 } }, + }); + const wrapper = shallow(); + const pagination = wrapper.find(EuiPagination); + + expect(pagination.prop('pageCount')).toEqual(10); + expect(pagination.prop('activePage')).toEqual(0); + + pagination.simulate('pageClick', 4); + expect(actions.onPaginate).toHaveBeenCalledWith(5); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx index 0b18271660911f8..59bd501f5468116 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms.tsx @@ -5,23 +5,86 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; -import { EuiPageHeader, EuiPageContentBody } from '@elastic/eui'; +import { useValues, useActions } from 'kea'; + +import { + EuiPageHeader, + EuiButton, + EuiPageContentBody, + EuiSpacer, + EuiFlexGrid, + EuiFlexItem, + EuiPagination, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { Loading } from '../../../shared/loading'; import { getEngineBreadcrumbs } from '../engine'; +import { SynonymCard, EmptyState } from './components'; import { SYNONYMS_TITLE } from './constants'; +import { SynonymsLogic } from './'; + export const Synonyms: React.FC = () => { + const { loadSynonyms, onPaginate } = useActions(SynonymsLogic); + const { synonymSets, meta, dataLoading } = useValues(SynonymsLogic); + const hasSynonyms = synonymSets.length > 0; + + useEffect(() => { + loadSynonyms(); + }, [meta.page.current]); + + useEffect(() => { + // If users delete the only synonym set on the page, send them back to the previous page + if (!hasSynonyms && meta.page.current !== 1) { + onPaginate(meta.page.current - 1); + } + }, [synonymSets]); + + if (dataLoading && !hasSynonyms) return ; + return ( <> - + {} /* TODO */}> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.synonyms.createSynonymSetButtonLabel', + { defaultMessage: 'Create a synonym set' } + )} + , + ]} + /> - TODO + + + {hasSynonyms ? ( + <> + + {synonymSets.map(({ id, synonyms }) => ( + + + + ))} + + + onPaginate(pageIndex + 1)} + /> + + ) : ( + + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.test.ts new file mode 100644 index 000000000000000..2497787a55f1e2c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.test.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; +import '../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { SYNONYMS_PAGE_META } from './constants'; + +import { SynonymsLogic } from './'; + +describe('SynonymsLogic', () => { + const { mount } = new LogicMounter(SynonymsLogic); + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + const MOCK_SYNONYMS_RESPONSE = { + meta: { + page: { + current: 1, + size: 12, + total_results: 1, + total_pages: 1, + }, + }, + results: [ + { + id: 'some-synonym-id', + synonyms: ['hello', 'world'], + }, + ], + }; + + const DEFAULT_VALUES = { + dataLoading: true, + synonymSets: [], + meta: SYNONYMS_PAGE_META, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(SynonymsLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onSynonymsLoad', () => { + it('should set synonyms and meta state, & dataLoading to false', () => { + mount(); + + SynonymsLogic.actions.onSynonymsLoad(MOCK_SYNONYMS_RESPONSE); + + expect(SynonymsLogic.values).toEqual({ + ...DEFAULT_VALUES, + synonymSets: MOCK_SYNONYMS_RESPONSE.results, + meta: MOCK_SYNONYMS_RESPONSE.meta, + dataLoading: false, + }); + }); + }); + + describe('onPaginate', () => { + it('should set meta.page.current state', () => { + mount(); + + SynonymsLogic.actions.onPaginate(3); + + expect(SynonymsLogic.values).toEqual({ + ...DEFAULT_VALUES, + meta: { page: { ...DEFAULT_VALUES.meta.page, current: 3 } }, + }); + }); + }); + }); + + describe('listeners', () => { + describe('loadSynonyms', () => { + it('should set dataLoading state', () => { + mount({ dataLoading: false }); + + SynonymsLogic.actions.loadSynonyms(); + + expect(SynonymsLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: true, + }); + }); + + it('should make an API call and set synonyms & meta state', async () => { + http.get.mockReturnValueOnce(Promise.resolve(MOCK_SYNONYMS_RESPONSE)); + mount(); + jest.spyOn(SynonymsLogic.actions, 'onSynonymsLoad'); + + SynonymsLogic.actions.loadSynonyms(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/synonyms', { + query: { + 'page[current]': 1, + 'page[size]': 12, + }, + }); + expect(SynonymsLogic.actions.onSynonymsLoad).toHaveBeenCalledWith(MOCK_SYNONYMS_RESPONSE); + }); + + it('handles errors', async () => { + http.get.mockReturnValueOnce(Promise.reject('error')); + mount(); + + SynonymsLogic.actions.loadSynonyms(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.ts new file mode 100644 index 000000000000000..a55fcf83a5f8ba1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/synonyms_logic.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { Meta } from '../../../../../common/types'; +import { flashAPIErrors } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { updateMetaPageIndex } from '../../../shared/table_pagination'; +import { EngineLogic } from '../engine'; + +import { SYNONYMS_PAGE_META } from './constants'; +import { SynonymSet, SynonymsApiResponse } from './types'; + +interface SynonymsValues { + dataLoading: boolean; + synonymSets: SynonymSet[]; + meta: Meta; +} + +interface SynonymsActions { + loadSynonyms(): void; + onSynonymsLoad(response: SynonymsApiResponse): SynonymsApiResponse; + onPaginate(newPageIndex: number): { newPageIndex: number }; +} + +export const SynonymsLogic = kea>({ + path: ['enterprise_search', 'app_search', 'synonyms_logic'], + actions: () => ({ + loadSynonyms: true, + onSynonymsLoad: ({ results, meta }) => ({ results, meta }), + onPaginate: (newPageIndex) => ({ newPageIndex }), + }), + reducers: () => ({ + dataLoading: [ + true, + { + loadSynonyms: () => true, + onSynonymsLoad: () => false, + }, + ], + synonymSets: [ + [], + { + onSynonymsLoad: (_, { results }) => results, + }, + ], + meta: [ + SYNONYMS_PAGE_META, + { + onSynonymsLoad: (_, { meta }) => meta, + onPaginate: (state, { newPageIndex }) => updateMetaPageIndex(state, newPageIndex), + }, + ], + }), + listeners: ({ actions, values }) => ({ + loadSynonyms: async () => { + const { meta } = values; + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get(`/api/app_search/engines/${engineName}/synonyms`, { + query: { + 'page[current]': meta.page.current, + 'page[size]': meta.page.size, + }, + }); + actions.onSynonymsLoad(response); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/types.ts new file mode 100644 index 000000000000000..2f6da766a6d50b6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/synonyms/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Meta } from '../../../../../common/types'; + +export interface SynonymSet { + id: string; + synonyms: string[]; +} + +export interface SynonymsApiResponse { + results: SynonymSet[]; + meta: Meta; +} From ee2b644c4dc6a70cf82e0b469893b6c0b022bc0c Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Fri, 23 Apr 2021 17:18:47 -0700 Subject: [PATCH 14/37] [ML] Add tooltip for interval in Single Metric Viewer (#98174) --- .../timeseriesexplorer/timeseriesexplorer.js | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 06a0f7e17e16494..8e5bf249ae2831d 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -26,9 +26,11 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiIcon, EuiSpacer, EuiPanel, EuiTitle, + EuiToolTip, EuiAccordion, EuiBadge, } from '@elastic/eui'; @@ -1259,9 +1261,21 @@ export class TimeSeriesExplorer extends React.Component { + + {i18n.translate('xpack.ml.timeSeriesExplorer.intervalLabel', { + defaultMessage: 'Interval', + })} + + + + } > From 6fbc39d722f143678029337fcce980dc22747648 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Fri, 23 Apr 2021 23:41:18 -0400 Subject: [PATCH 15/37] Skip flaky monitoring/*_mb tests (#98238) --- .../test/functional/apps/monitoring/index.js | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/x-pack/test/functional/apps/monitoring/index.js b/x-pack/test/functional/apps/monitoring/index.js index d595400f3e335fe..37d5d2083c4b1d2 100644 --- a/x-pack/test/functional/apps/monitoring/index.js +++ b/x-pack/test/functional/apps/monitoring/index.js @@ -15,32 +15,34 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./cluster/overview')); // loadTestFile(require.resolve('./cluster/license')); + // NOTE: All _mb tests skipped because of various failures: https://github.com/elastic/kibana/issues/98239 + loadTestFile(require.resolve('./elasticsearch/overview')); - loadTestFile(require.resolve('./elasticsearch/overview_mb')); + // loadTestFile(require.resolve('./elasticsearch/overview_mb')); loadTestFile(require.resolve('./elasticsearch/nodes')); - loadTestFile(require.resolve('./elasticsearch/nodes_mb')); + // loadTestFile(require.resolve('./elasticsearch/nodes_mb')); loadTestFile(require.resolve('./elasticsearch/node_detail')); - loadTestFile(require.resolve('./elasticsearch/node_detail_mb')); + // loadTestFile(require.resolve('./elasticsearch/node_detail_mb')); loadTestFile(require.resolve('./elasticsearch/indices')); - loadTestFile(require.resolve('./elasticsearch/indices_mb')); + // loadTestFile(require.resolve('./elasticsearch/indices_mb')); loadTestFile(require.resolve('./elasticsearch/index_detail')); - loadTestFile(require.resolve('./elasticsearch/index_detail_mb')); + // loadTestFile(require.resolve('./elasticsearch/index_detail_mb')); loadTestFile(require.resolve('./elasticsearch/shards')); // loadTestFile(require.resolve('./elasticsearch/shard_activity')); loadTestFile(require.resolve('./kibana/overview')); - loadTestFile(require.resolve('./kibana/overview_mb')); + // loadTestFile(require.resolve('./kibana/overview_mb')); loadTestFile(require.resolve('./kibana/instances')); - loadTestFile(require.resolve('./kibana/instances_mb')); + // loadTestFile(require.resolve('./kibana/instances_mb')); loadTestFile(require.resolve('./kibana/instance')); - loadTestFile(require.resolve('./kibana/instance_mb')); + // loadTestFile(require.resolve('./kibana/instance_mb')); // loadTestFile(require.resolve('./logstash/overview')); // loadTestFile(require.resolve('./logstash/nodes')); // loadTestFile(require.resolve('./logstash/node')); loadTestFile(require.resolve('./logstash/pipelines')); - loadTestFile(require.resolve('./logstash/pipelines_mb')); + // loadTestFile(require.resolve('./logstash/pipelines_mb')); loadTestFile(require.resolve('./beats/cluster')); loadTestFile(require.resolve('./beats/overview')); @@ -51,6 +53,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./enable_monitoring')); loadTestFile(require.resolve('./setup/metricbeat_migration')); - loadTestFile(require.resolve('./setup/metricbeat_migration_mb')); + // loadTestFile(require.resolve('./setup/metricbeat_migration_mb')); }); } From b4cd9a63dda8963289fa3e353fe8113aebc72d9a Mon Sep 17 00:00:00 2001 From: John Schulz Date: Sat, 24 Apr 2021 13:15:38 -0400 Subject: [PATCH 16/37] [Fleet] Only override settings for badges; not all package icons (#98143) ## Summary fixes #97865 [[Fleet] Bug: Netscout icon breaks the alignment of the integrations page](https://github.com/elastic/kibana/issues/97865)
7.127.13-SNAPSHOTPR
Screen Shot 2021-04-23 at 9 48 04 AM Screen Shot 2021-04-23 at 9 49 19 AM Screen Shot 2021-04-23 at 9 57 04 AM
Reverts the overly broad changes icon changes from 77b3906b68e4cfcc9898c7d901eef86081cf0d53 and applies them to the only place they were intended -- badges: Screen Shot 2021-04-23 at 10 03 18 AM Screen Shot 2021-04-23 at 10 03 28 AM --- .../fleet/components/package_icon.tsx | 15 +---- .../agent_policy_package_badges.tsx | 13 +++- .../sections/epm/components/icon_panel.tsx | 62 ------------------- 3 files changed, 14 insertions(+), 76 deletions(-) delete mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/icon_panel.tsx diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/package_icon.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/package_icon.tsx index e7fd1da394bb32c..cb0b02527f756ae 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/package_icon.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/package_icon.tsx @@ -14,18 +14,7 @@ import { usePackageIconType } from '../hooks'; export const PackageIcon: React.FunctionComponent< UsePackageIconType & Omit -> = ({ size = 's', packageName, version, icons, tryApi, ...euiIconProps }) => { +> = ({ packageName, version, icons, tryApi, ...euiIconProps }) => { const iconType = usePackageIconType({ packageName, version, icons, tryApi }); - return ( - - // this collides with some EuiText (+img) CSS from the EuiIcon component - // which makes the button large, wide, and poorly layed out - // override those styles until the bug is fixed or we find a better approach - style={{ margin: 'unset', width: 'unset' }} - size={size} - type={iconType} - {...euiIconProps} - /> - ); + return ; }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_policy_package_badges.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_policy_package_badges.tsx index dcc87b0032d77f7..cff0dc55515c4c7 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_policy_package_badges.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_policy_package_badges.tsx @@ -71,7 +71,18 @@ export const AgentPolicyPackageBadges: React.FunctionComponent = ({ - + + // this collides with some EuiText (+img) CSS from the EuiIcon component + // which makes the button large, wide, and poorly layed out + // override those styles until the bug is fixed or we find a better approach + { margin: 'unset', width: '16px' } + } + /> {pkg.title} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/icon_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/icon_panel.tsx deleted file mode 100644 index 63c6897021f4e62..000000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/icon_panel.tsx +++ /dev/null @@ -1,62 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import styled from 'styled-components'; -import { EuiIcon, EuiPanel } from '@elastic/eui'; - -import type { UsePackageIconType } from '../../../hooks'; -import { usePackageIconType } from '../../../hooks'; -import { Loading } from '../../../components'; - -const PanelWrapper = styled.div` - // NOTE: changes to the width here will impact navigation tabs page layout under integration package details - width: ${(props) => - parseFloat(props.theme.eui.euiSize) * 6 + parseFloat(props.theme.eui.euiSizeXL) * 2}px; - height: 1px; - z-index: 1; -`; - -const Panel = styled(EuiPanel)` - padding: ${(props) => props.theme.eui.spacerSizes.xl}; - margin-bottom: -100%; - svg, - img { - height: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px; - width: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px; - } - .euiFlexItem { - height: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px; - justify-content: center; - } -`; - -export function IconPanel({ - packageName, - version, - icons, -}: Pick) { - const iconType = usePackageIconType({ packageName, version, icons }); - - return ( - - - - - - ); -} - -export function LoadingIconPanel() { - return ( - - - - - - ); -} From 23e7b7fe8875250eb0e0323a0fe01655ae843c9a Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Mon, 26 Apr 2021 14:12:11 +0300 Subject: [PATCH 17/37] [TSVB] Unify styles for YesNo components (#97796) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/annotations_editor.js | 50 +++++++------ .../application/components/index_pattern.js | 1 + .../components/panel_config/gauge.tsx | 30 ++++---- .../components/panel_config/markdown.tsx | 71 ++++++++++--------- .../components/panel_config/metric.tsx | 29 ++++---- .../components/panel_config/table.tsx | 26 +++---- .../components/panel_config/timeseries.tsx | 69 +++++++++--------- .../components/panel_config/top_n.tsx | 28 ++++---- .../application/components/series_config.js | 27 ++++--- .../components/vis_types/table/config.js | 18 ++--- .../components/vis_types/timeseries/config.js | 69 +++++++++--------- 11 files changed, 213 insertions(+), 205 deletions(-) diff --git a/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js b/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js index 4d93a5207fa9e4c..09ce57639b9523c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import _ from 'lodash'; @@ -24,7 +25,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiFormLabel, EuiSpacer, EuiFieldText, EuiTitle, @@ -156,32 +156,36 @@ export class AnnotationsEditor extends Component {
- - + - - - + - - + - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js index c5b3d86f61b5d4b..556a3f2f691fb4d 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js +++ b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js @@ -126,6 +126,7 @@ export const IndexPattern = ({ ); const isTimeSeries = model.type === PANEL_TYPES.TIMESERIES; const isDataTimerangeModeInvalid = + !disabled && selectedTimeRangeOption && !isTimerangeModeEnabled(selectedTimeRangeOption.value, uiRestrictions); diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx index 99c3fa8ea9673f4..f5cc90ee49acdec 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx @@ -5,7 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import React, { Component } from 'react'; import uuid from 'uuid'; import { @@ -23,8 +24,7 @@ import { EuiTitle, EuiHorizontalRule, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; + import type { Writable } from '@kbn/utility-types'; // @ts-ignore @@ -157,18 +157,20 @@ export class GaugePanelConfig extends Component< - - + - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.tsx index c3f0f00125769ca..c33b4df914a8161 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.tsx @@ -172,18 +172,20 @@ export class MarkdownPanelConfig extends Component< - - + - - - + @@ -218,35 +220,34 @@ export class MarkdownPanelConfig extends Component< /> - - + - - - - + - - + - - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx index f38d0ec83e95744..68486d0d1e83fe8 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx @@ -5,7 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; import uuid from 'uuid'; import { @@ -16,12 +17,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiFormLabel, EuiSpacer, EuiTitle, EuiHorizontalRule, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; // @ts-expect-error import { SeriesEditor } from '../series_editor'; @@ -121,18 +120,20 @@ export class MetricPanelConfig extends Component< - - + - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx index 0847a350664945b..4eae56c7486713c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx @@ -17,7 +17,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiFormLabel, EuiSpacer, EuiFieldText, EuiTitle, @@ -28,6 +27,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { FieldSelect } from '../aggs/field_select'; // @ts-expect-error not typed yet import { SeriesEditor } from '../series_editor'; @@ -246,18 +246,20 @@ export class TablePanelConfig extends Component< - - + - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx index ae36408a08b46bc..ae9d7326140a7f5 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx @@ -5,6 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; import { @@ -22,8 +24,6 @@ import { EuiTitle, EuiHorizontalRule, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; // @ts-expect-error not typed yet import { SeriesEditor } from '../series_editor'; @@ -212,18 +212,20 @@ export class TimeseriesPanelConfig extends Component< - - + - - - + @@ -333,19 +335,17 @@ export class TimeseriesPanelConfig extends Component< /> - - + - - - - + @@ -366,15 +366,16 @@ export class TimeseriesPanelConfig extends Component< /> - - - - - - + + + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx index a537a769cac11d2..30d65f6edd84596 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx @@ -5,7 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; import uuid from 'uuid'; import { @@ -23,7 +24,6 @@ import { EuiHorizontalRule, EuiCode, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; // @ts-expect-error not typed yet import { SeriesEditor } from '../series_editor'; @@ -149,18 +149,20 @@ export class TopNPanelConfig extends Component< - - + - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/series_config.js b/src/plugins/vis_type_timeseries/public/application/components/series_config.js index 8f3893feb89bdf6..86781c9922e463d 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/series_config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/series_config.js @@ -5,7 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import PropTypes from 'prop-types'; import React from 'react'; import { DataFormatPicker } from './data_format_picker'; @@ -21,10 +22,7 @@ import { EuiFormRow, EuiCode, EuiHorizontalRule, - EuiFormLabel, - EuiSpacer, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import { SeriesConfigQueryBarWithIgnoreGlobalFilter } from './series_config_query_bar_with_ignore_global_filter'; export const SeriesConfig = (props) => { @@ -104,18 +102,17 @@ export const SeriesConfig = (props) => { - - + - - - + - - + - - - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js index 1c3a0411998b0f6..ebb3141cb4c8c40 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js @@ -5,6 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import PropTypes from 'prop-types'; import React, { useState, useEffect } from 'react'; @@ -23,8 +24,6 @@ import { EuiCode, EuiHorizontalRule, EuiFieldNumber, - EuiFormLabel, - EuiSpacer, } from '@elastic/eui'; import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; import { SeriesConfigQueryBarWithIgnoreGlobalFilter } from '../../series_config_query_bar_with_ignore_global_filter'; @@ -235,14 +234,13 @@ export const TimeseriesConfig = injectI18n(function (props) { - - - - - + + + ); @@ -408,14 +406,13 @@ export const TimeseriesConfig = injectI18n(function (props) { - - - - - + + + {palettesRegistry && ( @@ -443,14 +440,13 @@ export const TimeseriesConfig = injectI18n(function (props) { - - - - - + + + - - + - - - + Date: Mon, 26 Apr 2021 13:20:43 +0200 Subject: [PATCH 18/37] Disable context menu "Explore underlying data" by default (#98039) --- docs/user/dashboard/dashboard.asciidoc | 7 +++++++ x-pack/plugins/discover_enhanced/common/config.ts | 5 ++++- x-pack/plugins/discover_enhanced/public/plugin.ts | 6 ++++-- x-pack/plugins/discover_enhanced/server/config.ts | 3 +++ x-pack/test/functional/apps/dashboard/drilldowns/index.ts | 2 ++ x-pack/test/functional/apps/lens/dashboard.ts | 2 ++ x-pack/test/functional/config.js | 1 + 7 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/user/dashboard/dashboard.asciidoc b/docs/user/dashboard/dashboard.asciidoc index 89fa564b0ac7105..070d511ed8073e8 100644 --- a/docs/user/dashboard/dashboard.asciidoc +++ b/docs/user/dashboard/dashboard.asciidoc @@ -290,6 +290,13 @@ To add a panel to another dashboard, copy the panel. View the underlying documents in a panel, or in a data series. +. In kibana.yml, add the following: ++ +["source","yml"] +----------- +xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled: true +----------- + TIP: *Explore underlying data* is supported only for visualization panels with a single index pattern. To view the underlying documents in the panel: diff --git a/x-pack/plugins/discover_enhanced/common/config.ts b/x-pack/plugins/discover_enhanced/common/config.ts index f8de31aed719acd..26b4cc6520c1d40 100644 --- a/x-pack/plugins/discover_enhanced/common/config.ts +++ b/x-pack/plugins/discover_enhanced/common/config.ts @@ -6,5 +6,8 @@ */ export interface Config { - actions: { exploreDataInChart: { enabled: boolean } }; + actions: { + exploreDataInChart: { enabled: boolean }; + exploreDataInContextMenu: { enabled: boolean }; + }; } diff --git a/x-pack/plugins/discover_enhanced/public/plugin.ts b/x-pack/plugins/discover_enhanced/public/plugin.ts index a5425307aec6fea..60f242d682ffc37 100644 --- a/x-pack/plugins/discover_enhanced/public/plugin.ts +++ b/x-pack/plugins/discover_enhanced/public/plugin.ts @@ -56,8 +56,10 @@ export class DiscoverEnhancedPlugin if (isSharePluginInstalled) { const params = { start }; - const exploreDataAction = new ExploreDataContextMenuAction(params); - uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, exploreDataAction); + if (this.config.actions.exploreDataInContextMenu.enabled) { + const exploreDataAction = new ExploreDataContextMenuAction(params); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, exploreDataAction); + } if (this.config.actions.exploreDataInChart.enabled) { const exploreDataChartAction = new ExploreDataChartAction(params); diff --git a/x-pack/plugins/discover_enhanced/server/config.ts b/x-pack/plugins/discover_enhanced/server/config.ts index f57b162dc5b4d45..95ac46a662ea060 100644 --- a/x-pack/plugins/discover_enhanced/server/config.ts +++ b/x-pack/plugins/discover_enhanced/server/config.ts @@ -13,6 +13,9 @@ export const configSchema = schema.object({ exploreDataInChart: schema.object({ enabled: schema.boolean({ defaultValue: false }), }), + exploreDataInContextMenu: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), }), }); diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/index.ts b/x-pack/test/functional/apps/dashboard/drilldowns/index.ts index a88389f2498d586..fa24a4ba6a19ee8 100644 --- a/x-pack/test/functional/apps/dashboard/drilldowns/index.ts +++ b/x-pack/test/functional/apps/dashboard/drilldowns/index.ts @@ -26,6 +26,8 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { loadTestFile(require.resolve('./dashboard_to_dashboard_drilldown')); loadTestFile(require.resolve('./dashboard_to_url_drilldown')); + // Requires xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled + // setting set in kibana.yml to work (not enabled by default) loadTestFile(require.resolve('./explore_data_panel_action')); // Disabled for now as it requires xpack.discoverEnhanced.actions.exploreDataInChart.enabled diff --git a/x-pack/test/functional/apps/lens/dashboard.ts b/x-pack/test/functional/apps/lens/dashboard.ts index 1490abb320ca64e..9998f1dd4cdcb88 100644 --- a/x-pack/test/functional/apps/lens/dashboard.ts +++ b/x-pack/test/functional/apps/lens/dashboard.ts @@ -86,6 +86,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(hasIpFilter).to.be(true); }); + // Requires xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled + // setting set in kibana.yml to work (not enabled by default) it('should be able to drill down to discover', async () => { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.clickNewDashboard(); diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 0b22ab920287c96..f171e247472f104 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -90,6 +90,7 @@ export default async function ({ readConfigFile }) { '--usageCollection.maximumWaitTimeForAllCollectorsInS=1', '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', // server restarts should not invalidate active sessions '--xpack.encryptedSavedObjects.encryptionKey="DkdXazszSCYexXqz4YktBGHCRkV6hyNK"', + '--xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled=true', '--timelion.ui.enabled=true', '--savedObjects.maxImportPayloadBytes=10485760', // for OSS test management/_import_objects ], From 0578090e445d5296f04dc1e70920ac93f6407bbd Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 26 Apr 2021 13:21:35 +0200 Subject: [PATCH 19/37] [Lens] Embeddable error telemetry (#98042) --- x-pack/plugins/lens/kibana.json | 3 +- .../embeddable/embeddable.tsx | 19 ++++++++++- .../embeddable/embeddable_factory.ts | 4 +++ .../embeddable/expression_wrapper.tsx | 33 +++++++++++-------- .../public/editor_frame_service/service.tsx | 3 ++ x-pack/plugins/lens/public/plugin.ts | 4 +++ 6 files changed, 50 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index a5c19911f60b94f..bfcc20cc88b817d 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -35,6 +35,7 @@ "savedObjects", "kibanaUtils", "kibanaReact", - "embeddable" + "embeddable", + "usageCollection" ] } diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index a3316e0083d35d2..214ce6d11cff212 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -24,6 +24,8 @@ import { toExpression, Ast } from '@kbn/interpreter/common'; import { DefaultInspectorAdapters, RenderMode } from 'src/plugins/expressions'; import { map, distinctUntilChanged, skip } from 'rxjs/operators'; import isEqual from 'fast-deep-equal'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; +import { METRIC_TYPE } from '../../../../../../src/plugins/usage_collection/public'; import { ExpressionRendererEvent, ReactExpressionRendererType, @@ -51,7 +53,7 @@ import { } from '../../types'; import { IndexPatternsContract } from '../../../../../../src/plugins/data/public'; -import { getEditPath, DOC_TYPE } from '../../../common'; +import { getEditPath, DOC_TYPE, PLUGIN_ID } from '../../../common'; import { IBasePath } from '../../../../../../src/core/public'; import { LensAttributeService } from '../../lens_attribute_service'; import type { ErrorMessage } from '../types'; @@ -95,6 +97,7 @@ export interface LensEmbeddableDeps { getTrigger?: UiActionsStart['getTrigger'] | undefined; getTriggerCompatibleActions?: UiActionsStart['getTriggerCompatibleActions']; capabilities: { canSaveVisualizations: boolean; canSaveDashboards: boolean }; + usageCollection?: UsageCollectionSetup; } export class Embeddable @@ -113,6 +116,14 @@ export class Embeddable private inputReloadSubscriptions: Subscription[]; private isDestroyed?: boolean; + private logError(type: 'runtime' | 'validation') { + this.deps.usageCollection?.reportUiCounter( + PLUGIN_ID, + METRIC_TYPE.COUNT, + type === 'runtime' ? 'embeddable_runtime_error' : 'embeddable_validation_error' + ); + } + private externalSearchContext: { timeRange?: TimeRange; query?: Query; @@ -255,6 +266,9 @@ export class Embeddable const { ast, errors } = await this.deps.documentToExpression(this.savedVis); this.errors = errors; this.expression = ast ? toExpression(ast) : null; + if (errors) { + this.logError('validation'); + } await this.initializeOutput(); this.isInitialized = true; } @@ -326,6 +340,9 @@ export class Embeddable className={input.className} style={input.style} canEdit={this.getIsEditable() && input.viewMode === 'edit'} + onRuntimeError={() => { + this.logError('runtime'); + }} />, domNode ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts index 1a4962bd1fe8e2d..095e18e3fb5eb19 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { RecursiveReadonly } from '@kbn/utility-types'; import { Ast } from '@kbn/interpreter/target/common'; import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; import { IndexPatternsContract, TimefilterContract, @@ -34,6 +35,7 @@ export interface LensEmbeddableStartServices { expressionRenderer: ReactExpressionRendererType; indexPatternService: IndexPatternsContract; uiActions?: UiActionsStart; + usageCollection?: UsageCollectionSetup; documentToExpression: ( doc: Document ) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>; @@ -87,6 +89,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { attributeService, indexPatternService, capabilities, + usageCollection, } = await this.getStartServices(); const { Embeddable } = await import('../../async_services'); @@ -105,6 +108,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { canSaveDashboards: Boolean(capabilities.dashboard?.showWriteControls), canSaveVisualizations: Boolean(capabilities.visualize.save), }, + usageCollection, }, input, parent diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx index f4d0c85ecbbce00..15d168465ec71ad 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx @@ -38,6 +38,7 @@ export interface ExpressionWrapperProps { style?: React.CSSProperties; className?: string; canEdit: boolean; + onRuntimeError: () => void; } interface VisualizationErrorProps { @@ -106,6 +107,7 @@ export function ExpressionWrapper({ className, errors, canEdit, + onRuntimeError, }: ExpressionWrapperProps) { return ( @@ -123,20 +125,23 @@ export function ExpressionWrapper({ onData$={onData$} renderMode={renderMode} syncColors={syncColors} - renderError={(errorMessage, error) => ( -
- - - - - - {(getOriginalRequestErrorMessages(error) || [errorMessage]).map((message) => ( - {message} - ))} - - -
- )} + renderError={(errorMessage, error) => { + onRuntimeError(); + return ( +
+ + + + + + {(getOriginalRequestErrorMessages(error) || [errorMessage]).map((message) => ( + {message} + ))} + + +
+ ); + }} onEvent={handleEvent} hasCompatibleActions={hasCompatibleActions} /> diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index 8769aceca3bfd0a..849baa93652cc66 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { CoreSetup, CoreStart } from 'kibana/public'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; import { ExpressionsSetup, ExpressionsStart } from '../../../../../src/plugins/expressions/public'; import { EmbeddableSetup, EmbeddableStart } from '../../../../../src/plugins/embeddable/public'; import { @@ -35,6 +36,7 @@ export interface EditorFrameSetupPlugins { embeddable?: EmbeddableSetup; expressions: ExpressionsSetup; charts: ChartsPluginSetup; + usageCollection?: UsageCollectionSetup; } export interface EditorFrameStartPlugins { @@ -101,6 +103,7 @@ export class EditorFrameService { documentToExpression: this.documentToExpression, indexPatternService: deps.data.indexPatterns, uiActions: deps.uiActions, + usageCollection: plugins.usageCollection, }; }; diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 81937f3f4155707..99e7199c2d8020f 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -6,6 +6,7 @@ */ import { AppMountParameters, CoreSetup, CoreStart } from 'kibana/public'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import { DashboardStart } from '../../../../src/plugins/dashboard/public'; @@ -62,6 +63,7 @@ export interface LensPluginSetupDependencies { visualizations: VisualizationsSetup; charts: ChartsPluginSetup; globalSearch?: GlobalSearchPluginSetup; + usageCollection?: UsageCollectionSetup; } export interface LensPluginStartDependencies { @@ -139,6 +141,7 @@ export class LensPlugin { visualizations, charts, globalSearch, + usageCollection, }: LensPluginSetupDependencies ) { this.attributeService = async () => { @@ -153,6 +156,7 @@ export class LensPlugin { embeddable, charts, expressions, + usageCollection, }, this.attributeService ); From eeee32e025582ff378215fa58ff75d7e10d5b3e1 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 26 Apr 2021 14:46:00 +0300 Subject: [PATCH 20/37] [TSVB] Visualization crashes when it is opened from Metrics Ui (#98120) * [TSVB] Metrics UI crashes when it is opened from Metrics Ui * Fix bug on initialization Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/vis_types/timeseries/config.js | 13 +++++++++---- .../application/lib/get_split_by_terms_color.ts | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js index ebb3141cb4c8c40..72f5034cfc61b51 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js @@ -329,12 +329,17 @@ export const TimeseriesConfig = injectI18n(function (props) { ? props.model.series_index_pattern : props.indexPatternForQuery; - const initialPalette = { - ...model.palette, + const initialPalette = model.palette ?? { + type: 'palette', + name: 'default', + }; + + const palette = { + ...initialPalette, name: model.split_color_mode === 'kibana' ? 'kibana_palette' - : model.split_color_mode || model.palette.name, + : model.split_color_mode || initialPalette.name, }; return ( @@ -427,7 +432,7 @@ export const TimeseriesConfig = injectI18n(function (props) { > diff --git a/src/plugins/vis_type_timeseries/public/application/lib/get_split_by_terms_color.ts b/src/plugins/vis_type_timeseries/public/application/lib/get_split_by_terms_color.ts index e8f81bd8c604533..13f1fe5a1f79da4 100644 --- a/src/plugins/vis_type_timeseries/public/application/lib/get_split_by_terms_color.ts +++ b/src/plugins/vis_type_timeseries/public/application/lib/get_split_by_terms_color.ts @@ -57,7 +57,7 @@ export const getSplitByTermsColor = ({ } : seriesPalette.params; - const outputColor = palettesRegistry?.get(paletteName).getColor( + const outputColor = palettesRegistry?.get(paletteName || 'default').getColor( [ { name: seriesName || emptyLabel, From a99bccff6aff01bd119dfd900e396bdbdfe3612b Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 26 Apr 2021 13:06:45 +0100 Subject: [PATCH 21/37] skip flaky suite (#97067) --- x-pack/plugins/uptime/public/pages/settings.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/uptime/public/pages/settings.test.tsx b/x-pack/plugins/uptime/public/pages/settings.test.tsx index 95fed208f6b0a89..e0b7b70ad46fbe1 100644 --- a/x-pack/plugins/uptime/public/pages/settings.test.tsx +++ b/x-pack/plugins/uptime/public/pages/settings.test.tsx @@ -13,7 +13,8 @@ import { act } from 'react-dom/test-utils'; import * as alertApi from '../state/api/alerts'; describe('settings', () => { - describe('form', () => { + // FLAKY: https://github.com/elastic/kibana/issues/97067 + describe.skip('form', () => { beforeAll(() => { jest.spyOn(alertApi, 'fetchActionTypes').mockImplementation(async () => [ { From 7c9475e36c68902cad821f89c3d8a89bfddda9d2 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Mon, 26 Apr 2021 08:00:09 -0500 Subject: [PATCH 22/37] Fix metric unit wrapping on observability overview sparklines (#97718) Wrap these in a flex group the same way the APM sparklines work to prevent the wrapping of the units. --- .../section/metrics/metric_with_sparkline.tsx | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/observability/public/components/app/section/metrics/metric_with_sparkline.tsx b/x-pack/plugins/observability/public/components/app/section/metrics/metric_with_sparkline.tsx index 3cb61f85d57f0e1..828038fd754361a 100644 --- a/x-pack/plugins/observability/public/components/app/section/metrics/metric_with_sparkline.tsx +++ b/x-pack/plugins/observability/public/components/app/section/metrics/metric_with_sparkline.tsx @@ -6,7 +6,7 @@ */ import { Chart, Settings, AreaSeries } from '@elastic/charts'; -import { EuiIcon, EuiTextColor } from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup, EuiIcon, EuiTextColor } from '@elastic/eui'; import React, { useContext } from 'react'; import { EUI_CHARTS_THEME_DARK, @@ -43,19 +43,22 @@ export function MetricWithSparkline({ id, formatter, value, timeseries, color }: ); } return ( - <> - - - - -   - {formatter(value)} - + + + + + + + + + {formatter(value)} + + ); } From 52a650d9474acdbd8e06a5c5fd6ae13ff936cecd Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 26 Apr 2021 15:41:58 +0200 Subject: [PATCH 23/37] [user Experience] Fix popover padding and closing state (#97981) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../URLFilter/URLSearch/SelectableUrlList.tsx | 130 +++++++++--------- 1 file changed, 66 insertions(+), 64 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx index 965449b78f3e086..b8c232f9685234e 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx @@ -26,6 +26,8 @@ import { EuiText, EuiIcon, EuiBadge, + EuiButtonIcon, + EuiOutsideClickDetector, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -87,7 +89,6 @@ export function SelectableUrlList({ }: Props) { const [darkMode] = useUiSetting$('theme:darkMode'); - const [popoverRef, setPopoverRef] = useState(null); const [searchRef, setSearchRef] = useState(null); const titleRef = useRef(null); @@ -105,7 +106,7 @@ export function SelectableUrlList({ // @ts-ignore - not sure, why it's not working useEvent('keydown', onEnterKey, searchRef); - const searchOnFocus = (e: React.FocusEvent) => { + const onInputClick = (e: React.MouseEvent) => { setPopoverIsOpen(true); }; @@ -114,15 +115,6 @@ export function SelectableUrlList({ setPopoverIsOpen(true); }; - const searchOnBlur = (e: React.FocusEvent) => { - if ( - !popoverRef?.contains(e.relatedTarget as HTMLElement) && - !popoverRef?.contains(titleRef.current as HTMLDivElement) - ) { - setPopoverIsOpen(false); - } - }; - const formattedOptions = formatOptions(data.items ?? []); const closePopover = () => { @@ -163,11 +155,21 @@ export function SelectableUrlList({ function PopOverTitle() { return ( - + {loading ? : titleText} + + closePopover()} + aria-label={i18n.translate('xpack.apm.csm.search.url.close', { + defaultMessage: 'Close', + })} + iconType={'cross'} + /> + ); @@ -183,8 +185,7 @@ export function SelectableUrlList({ singleSelection={false} searchProps={{ isClearable: true, - onFocus: searchOnFocus, - onBlur: searchOnBlur, + onClick: onInputClick, onInput: onSearchInput, inputRef: setSearchRef, placeholder: I18LABELS.searchByUrl, @@ -199,56 +200,57 @@ export function SelectableUrlList({ noMatchesMessage={emptyMessage} > {(list, search) => ( - -
- - {searchValue && ( - - - {searchValue}, - icon: ( - - Enter - - ), - }} - /> - - - )} - {list} - - - - { - onTermChange(); - closePopover(); - }} - > - {i18n.translate('xpack.apm.apply.label', { - defaultMessage: 'Apply', - })} - - - - -
-
+ closePopover()}> + +
+ + {searchValue && ( + + + {searchValue}, + icon: ( + + Enter + + ), + }} + /> + + + )} + {list} + + + + { + onTermChange(); + closePopover(); + }} + > + {i18n.translate('xpack.apm.apply.label', { + defaultMessage: 'Apply', + })} + + + + +
+
+
)} ); From e6ba8ccdc21b2b03b65597f218db4b32d5110746 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Mon, 26 Apr 2021 15:57:29 +0200 Subject: [PATCH 24/37] Rewriting SO id during migration (#97222) * some typos * implement an alternative client-side migration algorithm required to enforce idempotent id generation for SO * update tests * lol * remove unnecessary param from request generic * remove unused parameter * optimize search when quierying SO for migration * fix wrong type in fixtures * try shard_doc asc * add an integration test * cleanup * track_total_hits: false to improve perf * add happy path test for transformDocs action * remove unused types * fix wrong typing * add cleanup phase * add an integration test for cleanup phase * add unit-tests for cleanup function * address comments * Fix functional test * set defaultIndex before each test. otherwise it is deleted in the first test file during cleanup phase * sourceIndex: Option.some<> for consistency * Revert "set defaultIndex before each test. otherwise it is deleted in the first test file during cleanup phase" This reverts commit a128d7b7c03493a06d76662902877e535a424b9a. * address comments from Pierre * fix test * Revert "fix test" This reverts commit 97315b6dc2bc133bdb55a523cacd35f162eac53b. * revert min convert version back to 8.0 Co-authored-by: Matthias Wilhelm --- .../migrations/core/document_migrator.ts | 3 +- .../migrations/core/elastic_index.ts | 35 +-- .../migrations/core/index_migrator.ts | 3 +- .../migrations/core/migrate_raw_docs.test.ts | 20 +- .../migrations/core/migrate_raw_docs.ts | 4 +- .../migrations/kibana/kibana_migrator.test.ts | 60 ++-- .../migrations/kibana/kibana_migrator.ts | 8 +- .../migrationsv2/actions/index.test.ts | 50 +++- .../migrationsv2/actions/index.ts | 166 ++++++++++- .../saved_objects/migrationsv2/index.ts | 6 +- .../migrationsv2/integration_tests/.gitignore | 2 +- .../integration_tests/actions.test.ts | 277 +++++++++++++++--- .../7.13.0_so_with_multiple_namespaces.zip | Bin 0 -> 56841 bytes .../archives/7.13.0_with_corrupted_so.zip | Bin 0 -> 49885 bytes .../integration_tests/cleanup.test.ts | 131 +++++++++ .../integration_tests/migration.test.ts | 2 + .../integration_tests/rewriting_id.test.ts | 240 +++++++++++++++ .../migrations_state_action_machine.test.ts | 47 ++- .../migrations_state_action_machine.ts | 22 +- .../migrations_state_machine_cleanup.mocks.ts | 12 + .../migrations_state_machine_cleanup.ts | 31 ++ .../saved_objects/migrationsv2/model.test.ts | 188 +++++++----- .../saved_objects/migrationsv2/model.ts | 80 ++--- .../server/saved_objects/migrationsv2/next.ts | 73 +++-- .../saved_objects/migrationsv2/types.ts | 44 ++- .../saved_objects/service/lib/repository.ts | 5 +- src/core/test_helpers/kbn_server.ts | 4 +- .../apps/context/_discover_navigation.js | 5 +- .../fixtures/es_archiver/visualize/data.json | 2 +- 29 files changed, 1209 insertions(+), 311 deletions(-) create mode 100644 src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_so_with_multiple_namespaces.zip create mode 100644 src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_with_corrupted_so.zip create mode 100644 src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.mocks.ts create mode 100644 src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.ts diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index cccd38bf5cc9eeb..8e538f6e12384d6 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -850,7 +850,8 @@ function assertNoDowngrades( * that we can later regenerate any inbound object references to match. * * @note This is only intended to be used when single-namespace object types are converted into multi-namespace object types. + * @internal */ -function deterministicallyRegenerateObjectId(namespace: string, type: string, id: string) { +export function deterministicallyRegenerateObjectId(namespace: string, type: string, id: string) { return uuidv5(`${namespace}:${type}:${id}`, uuidv5.DNS); // the uuidv5 namespace constant (uuidv5.DNS) is arbitrary } diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index 460aabbc77415cc..44dd60097f1cd35 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -14,7 +14,6 @@ import _ from 'lodash'; import { estypes } from '@elastic/elasticsearch'; import { MigrationEsClient } from './migration_es_client'; -import { CountResponse, SearchResponse } from '../../../elasticsearch'; import { IndexMapping } from '../../mappings'; import { SavedObjectsMigrationVersion } from '../../types'; import { AliasAction, RawDoc } from './call_cluster'; @@ -95,11 +94,11 @@ export async function fetchInfo(client: MigrationEsClient, index: string): Promi * Creates a reader function that serves up batches of documents from the index. We aren't using * an async generator, as that feature currently breaks Kibana's tooling. * - * @param {CallCluster} callCluster - The elastic search connection - * @param {string} - The index to be read from + * @param client - The elastic search connection + * @param index - The index to be read from * @param {opts} - * @prop {number} batchSize - The number of documents to read at a time - * @prop {string} scrollDuration - The scroll duration used for scrolling through the index + * @prop batchSize - The number of documents to read at a time + * @prop scrollDuration - The scroll duration used for scrolling through the index */ export function reader( client: MigrationEsClient, @@ -111,11 +110,11 @@ export function reader( const nextBatch = () => scrollId !== undefined - ? client.scroll>({ + ? client.scroll({ scroll, scroll_id: scrollId, }) - : client.search>({ + : client.search({ body: { size: batchSize, query: excludeUnusedTypesQuery, @@ -143,10 +142,6 @@ export function reader( /** * Writes the specified documents to the index, throws an exception * if any of the documents fail to save. - * - * @param {CallCluster} callCluster - * @param {string} index - * @param {RawDoc[]} docs */ export async function write(client: MigrationEsClient, index: string, docs: RawDoc[]) { const { body } = await client.bulk({ @@ -184,9 +179,9 @@ export async function write(client: MigrationEsClient, index: string, docs: RawD * it performs the check *each* time it is called, rather than memoizing itself, * as this is used to determine if migrations are complete. * - * @param {CallCluster} callCluster - * @param {string} index - * @param {SavedObjectsMigrationVersion} migrationVersion - The latest versions of the migrations + * @param client - The connection to ElasticSearch + * @param index + * @param migrationVersion - The latest versions of the migrations */ export async function migrationsUpToDate( client: MigrationEsClient, @@ -207,7 +202,7 @@ export async function migrationsUpToDate( return true; } - const { body } = await client.count({ + const { body } = await client.count({ body: { query: { bool: { @@ -271,9 +266,9 @@ export async function createIndex( * is a concrete index. This function will reindex `alias` into a new index, delete the `alias` * index, and then create an alias `alias` that points to the new index. * - * @param {CallCluster} callCluster - The connection to ElasticSearch - * @param {FullIndexInfo} info - Information about the mappings and name of the new index - * @param {string} alias - The name of the index being converted to an alias + * @param client - The ElasticSearch connection + * @param info - Information about the mappings and name of the new index + * @param alias - The name of the index being converted to an alias */ export async function convertToAlias( client: MigrationEsClient, @@ -297,7 +292,7 @@ export async function convertToAlias( * alias, meaning that it will only point to one index at a time, so we * remove any other indices from the alias. * - * @param {CallCluster} callCluster + * @param {CallCluster} client * @param {string} index * @param {string} alias * @param {AliasAction[]} aliasActions - Optional actions to be added to the updateAliases call @@ -377,7 +372,7 @@ async function reindex( ) { // We poll instead of having the request wait for completion, as for large indices, // the request times out on the Elasticsearch side of things. We have a relatively tight - // polling interval, as the request is fairly efficent, and we don't + // polling interval, as the request is fairly efficient, and we don't // want to block index migrations for too long on this. const pollInterval = 250; const { body: reindexBody } = await client.reindex({ diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index 5bf5ae26f6a0ad1..472fb4f8d1a397b 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -189,8 +189,7 @@ async function migrateSourceToDest(context: Context) { serializer, documentMigrator.migrateAndConvert, // @ts-expect-error @elastic/elasticsearch `Hit._id` may be a string | number in ES, but we always expect strings in the SO index. - docs, - log + docs ) ); } diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts index 66750a8abf1db20..45e73f7dfae305c 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts @@ -11,7 +11,6 @@ import _ from 'lodash'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsSerializer } from '../../serialization'; import { migrateRawDocs } from './migrate_raw_docs'; -import { createSavedObjectsMigrationLoggerMock } from '../../migrations/mocks'; describe('migrateRawDocs', () => { test('converts raw docs to saved objects', async () => { @@ -24,8 +23,7 @@ describe('migrateRawDocs', () => { [ { _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }, { _id: 'c:d', _source: { type: 'c', c: { name: 'DDD' } } }, - ], - createSavedObjectsMigrationLoggerMock() + ] ); expect(result).toEqual([ @@ -59,7 +57,6 @@ describe('migrateRawDocs', () => { }); test('throws when encountering a corrupt saved object document', async () => { - const logger = createSavedObjectsMigrationLoggerMock(); const transform = jest.fn((doc: any) => [ set(_.cloneDeep(doc), 'attributes.name', 'TADA'), ]); @@ -69,8 +66,7 @@ describe('migrateRawDocs', () => { [ { _id: 'foo:b', _source: { type: 'a', a: { name: 'AAA' } } }, { _id: 'c:d', _source: { type: 'c', c: { name: 'DDD' } } }, - ], - logger + ] ); expect(result).rejects.toMatchInlineSnapshot( @@ -88,8 +84,7 @@ describe('migrateRawDocs', () => { const result = await migrateRawDocs( new SavedObjectsSerializer(new SavedObjectTypeRegistry()), transform, - [{ _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }], - createSavedObjectsMigrationLoggerMock() + [{ _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }] ); expect(result).toEqual([ @@ -119,12 +114,9 @@ describe('migrateRawDocs', () => { throw new Error('error during transform'); }); await expect( - migrateRawDocs( - new SavedObjectsSerializer(new SavedObjectTypeRegistry()), - transform, - [{ _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }], - createSavedObjectsMigrationLoggerMock() - ) + migrateRawDocs(new SavedObjectsSerializer(new SavedObjectTypeRegistry()), transform, [ + { _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }, + ]) ).rejects.toThrowErrorMatchingInlineSnapshot(`"error during transform"`); }); }); diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts index e75f29e54c87693..102ec81646a9264 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts @@ -16,7 +16,6 @@ import { SavedObjectUnsanitizedDoc, } from '../../serialization'; import { MigrateAndConvertFn } from './document_migrator'; -import { SavedObjectsMigrationLogger } from '.'; /** * Error thrown when saved object migrations encounter a corrupt saved object. @@ -46,8 +45,7 @@ export class CorruptSavedObjectError extends Error { export async function migrateRawDocs( serializer: SavedObjectsSerializer, migrateDoc: MigrateAndConvertFn, - rawDocs: SavedObjectsRawDoc[], - log: SavedObjectsMigrationLogger + rawDocs: SavedObjectsRawDoc[] ): Promise { const migrateDocWithoutBlocking = transformNonBlocking(migrateDoc); const processedDocs = []; diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index 221e78e3e12e26e..c6dfd2c2d180901 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -229,48 +229,6 @@ describe('KibanaMigrator', () => { jest.clearAllMocks(); }); - it('creates a V2 migrator that initializes a new index and migrates an existing index', async () => { - const options = mockV2MigrationOptions(); - const migrator = new KibanaMigrator(options); - const migratorStatus = migrator.getStatus$().pipe(take(3)).toPromise(); - migrator.prepareMigrations(); - await migrator.runMigrations(); - - // Basic assertions that we're creating and reindexing the expected indices - expect(options.client.indices.create).toHaveBeenCalledTimes(3); - expect(options.client.indices.create.mock.calls).toEqual( - expect.arrayContaining([ - // LEGACY_CREATE_REINDEX_TARGET - expect.arrayContaining([expect.objectContaining({ index: '.my-index_pre8.2.3_001' })]), - // CREATE_REINDEX_TEMP - expect.arrayContaining([ - expect.objectContaining({ index: '.my-index_8.2.3_reindex_temp' }), - ]), - // CREATE_NEW_TARGET - expect.arrayContaining([expect.objectContaining({ index: 'other-index_8.2.3_001' })]), - ]) - ); - // LEGACY_REINDEX - expect(options.client.reindex.mock.calls[0][0]).toEqual( - expect.objectContaining({ - body: expect.objectContaining({ - source: expect.objectContaining({ index: '.my-index' }), - dest: expect.objectContaining({ index: '.my-index_pre8.2.3_001' }), - }), - }) - ); - // REINDEX_SOURCE_TO_TEMP - expect(options.client.reindex.mock.calls[1][0]).toEqual( - expect.objectContaining({ - body: expect.objectContaining({ - source: expect.objectContaining({ index: '.my-index_pre8.2.3_001' }), - dest: expect.objectContaining({ index: '.my-index_8.2.3_reindex_temp' }), - }), - }) - ); - const { status } = await migratorStatus; - return expect(status).toEqual('completed'); - }); it('emits results on getMigratorResult$()', async () => { const options = mockV2MigrationOptions(); const migrator = new KibanaMigrator(options); @@ -378,6 +336,24 @@ const mockV2MigrationOptions = () => { } as estypes.GetTaskResponse) ); + options.client.search = jest + .fn() + .mockImplementation(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ hits: { hits: [] } }) + ); + + options.client.openPointInTime = jest + .fn() + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ id: 'pit_id' }) + ); + + options.client.closePointInTime = jest + .fn() + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ succeeded: true }) + ); + return options; }; diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index 29852f8ac64452a..58dcae7309eeafa 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -36,7 +36,6 @@ import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsType } from '../../types'; import { runResilientMigrator } from '../../migrationsv2'; import { migrateRawDocs } from '../core/migrate_raw_docs'; -import { MigrationLogger } from '../core/migration_logger'; export interface KibanaMigratorOptions { client: ElasticsearchClient; @@ -185,12 +184,7 @@ export class KibanaMigrator { logger: this.log, preMigrationScript: indexMap[index].script, transformRawDocs: (rawDocs: SavedObjectsRawDoc[]) => - migrateRawDocs( - this.serializer, - this.documentMigrator.migrateAndConvert, - rawDocs, - new MigrationLogger(this.log) - ), + migrateRawDocs(this.serializer, this.documentMigrator.migrateAndConvert, rawDocs), migrationVersionPerType: this.documentMigrator.migrationVersion, indexPrefix: index, migrationsConfig: this.soMigrationsConfig, diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/index.test.ts index bee17f42d7bdbbe..b144905cf01ad21 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.test.ts @@ -78,6 +78,54 @@ describe('actions', () => { }); }); + describe('openPit', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = Actions.openPit(client, 'my_index'); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); + + describe('readWithPit', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = Actions.readWithPit(client, 'pitId', Option.none, 10_000); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); + + describe('closePit', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = Actions.closePit(client, 'pitId'); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); + + describe('transformDocs', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = Actions.transformDocs(client, () => Promise.resolve([]), [], 'my_index', false); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); + describe('reindex', () => { it('calls catchRetryableEsClientErrors when the promise rejects', async () => { const task = Actions.reindex( @@ -205,7 +253,7 @@ describe('actions', () => { describe('bulkOverwriteTransformedDocuments', () => { it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.bulkOverwriteTransformedDocuments(client, 'new_index', []); + const task = Actions.bulkOverwriteTransformedDocuments(client, 'new_index', [], 'wait_for'); try { await task(); } catch (e) { diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts index 02d3f8e21a51061..049cdc41b75274a 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts @@ -16,7 +16,8 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { flow } from 'fp-ts/lib/function'; import { ElasticsearchClient } from '../../../elasticsearch'; import { IndexMapping } from '../../mappings'; -import { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; +import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; +import type { TransformRawDocs } from '../types'; import { catchRetryableEsClientErrors, RetryableEsClientError, @@ -419,6 +420,133 @@ export const pickupUpdatedMappings = ( .catch(catchRetryableEsClientErrors); }; +/** @internal */ +export interface OpenPitResponse { + pitId: string; +} + +// how long ES should keep PIT alive +const pitKeepAlive = '10m'; +/* + * Creates a lightweight view of data when the request has been initiated. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const openPit = ( + client: ElasticsearchClient, + index: string +): TaskEither.TaskEither => () => { + return client + .openPointInTime({ + index, + keep_alive: pitKeepAlive, + }) + .then((response) => Either.right({ pitId: response.body.id })) + .catch(catchRetryableEsClientErrors); +}; + +/** @internal */ +export interface ReadWithPit { + outdatedDocuments: SavedObjectsRawDoc[]; + readonly lastHitSortValue: number[] | undefined; +} + +/* + * Requests documents from the index using PIT mechanism. + * Filter unusedTypesToExclude documents out to exclude them from being migrated. + * */ +export const readWithPit = ( + client: ElasticsearchClient, + pitId: string, + /* When reading we use a source query to exclude saved objects types which + * are no longer used. These saved objects will still be kept in the outdated + * index for backup purposes, but won't be available in the upgraded index. + */ + unusedTypesQuery: Option.Option, + batchSize: number, + searchAfter?: number[] +): TaskEither.TaskEither => () => { + return client + .search({ + body: { + // Sort fields are required to use searchAfter + sort: { + // the most efficient option as order is not important for the migration + _shard_doc: { order: 'asc' }, + }, + pit: { id: pitId, keep_alive: pitKeepAlive }, + size: batchSize, + search_after: searchAfter, + // Improve performance by not calculating the total number of hits + // matching the query. + track_total_hits: false, + // Exclude saved object types + query: Option.isSome(unusedTypesQuery) ? unusedTypesQuery.value : undefined, + }, + }) + .then((response) => { + const hits = response.body.hits.hits; + + if (hits.length > 0) { + return Either.right({ + // @ts-expect-error @elastic/elasticsearch _source is optional + outdatedDocuments: hits as SavedObjectsRawDoc[], + lastHitSortValue: hits[hits.length - 1].sort as number[], + }); + } + + return Either.right({ + outdatedDocuments: [], + lastHitSortValue: undefined, + }); + }) + .catch(catchRetryableEsClientErrors); +}; + +/* + * Closes PIT. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const closePit = ( + client: ElasticsearchClient, + pitId: string +): TaskEither.TaskEither => () => { + return client + .closePointInTime({ + body: { id: pitId }, + }) + .then((response) => { + if (!response.body.succeeded) { + throw new Error(`Failed to close PointInTime with id: ${pitId}`); + } + return Either.right({}); + }) + .catch(catchRetryableEsClientErrors); +}; + +/* + * Transform outdated docs and write them to the index. + * */ +export const transformDocs = ( + client: ElasticsearchClient, + transformRawDocs: TransformRawDocs, + outdatedDocuments: SavedObjectsRawDoc[], + index: string, + refresh: estypes.Refresh +): TaskEither.TaskEither< + RetryableEsClientError | IndexNotFound | TargetIndexHadWriteBlock, + 'bulk_index_succeeded' +> => + pipe( + TaskEither.tryCatch( + () => transformRawDocs(outdatedDocuments), + (e) => { + throw e; + } + ), + TaskEither.chain((docs) => bulkOverwriteTransformedDocuments(client, index, docs, refresh)) + ); + +/** @internal */ export interface ReindexResponse { taskId: string; } @@ -489,10 +617,12 @@ interface WaitForReindexTaskFailure { readonly cause: { type: string; reason: string }; } +/** @internal */ export interface TargetIndexHadWriteBlock { type: 'target_index_had_write_block'; } +/** @internal */ export interface IncompatibleMappingException { type: 'incompatible_mapping_exception'; } @@ -605,14 +735,17 @@ export const waitForPickupUpdatedMappingsTask = flow( ) ); +/** @internal */ export interface AliasNotFound { type: 'alias_not_found_exception'; } +/** @internal */ export interface RemoveIndexNotAConcreteIndex { type: 'remove_index_not_a_concrete_index'; } +/** @internal */ export type AliasAction = | { remove_index: { index: string } } | { remove: { index: string; alias: string; must_exist: boolean } } @@ -679,11 +812,19 @@ export const updateAliases = ( .catch(catchRetryableEsClientErrors); }; +/** @internal */ export interface AcknowledgeResponse { acknowledged: boolean; shardsAcknowledged: boolean; } +function aliasArrayToRecord(aliases: string[]): Record { + const result: Record = {}; + for (const alias of aliases) { + result[alias] = {}; + } + return result; +} /** * Creates an index with the given mappings * @@ -698,16 +839,13 @@ export const createIndex = ( client: ElasticsearchClient, indexName: string, mappings: IndexMapping, - aliases?: string[] + aliases: string[] = [] ): TaskEither.TaskEither => { const createIndexTask: TaskEither.TaskEither< RetryableEsClientError, AcknowledgeResponse > = () => { - const aliasesObject = (aliases ?? []).reduce((acc, alias) => { - acc[alias] = {}; - return acc; - }, {} as Record); + const aliasesObject = aliasArrayToRecord(aliases); return client.indices .create( @@ -792,6 +930,7 @@ export const createIndex = ( ); }; +/** @internal */ export interface UpdateAndPickupMappingsResponse { taskId: string; } @@ -842,6 +981,8 @@ export const updateAndPickupMappings = ( }) ); }; + +/** @internal */ export interface SearchResponse { outdatedDocuments: SavedObjectsRawDoc[]; } @@ -906,7 +1047,8 @@ export const searchForOutdatedDocuments = ( export const bulkOverwriteTransformedDocuments = ( client: ElasticsearchClient, index: string, - transformedDocs: SavedObjectsRawDoc[] + transformedDocs: SavedObjectsRawDoc[], + refresh: estypes.Refresh ): TaskEither.TaskEither => () => { return client .bulk({ @@ -919,15 +1061,7 @@ export const bulkOverwriteTransformedDocuments = ( // system indices puts in place a hard control. require_alias: false, wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - // Wait for a refresh to happen before returning. This ensures that when - // this Kibana instance searches for outdated documents, it won't find - // documents that were already transformed by itself or another Kibna - // instance. However, this causes each OUTDATED_DOCUMENTS_SEARCH -> - // OUTDATED_DOCUMENTS_TRANSFORM cycle to take 1s so when batches are - // small performance will become a lot worse. - // The alternative is to use a search_after with either a tie_breaker - // field or using a Point In Time as a cursor to go through all documents. - refresh: 'wait_for', + refresh, filter_path: ['items.*.error'], body: transformedDocs.flatMap((doc) => { return [ diff --git a/src/core/server/saved_objects/migrationsv2/index.ts b/src/core/server/saved_objects/migrationsv2/index.ts index 6e65a2e700fd305..25816c7fd14c609 100644 --- a/src/core/server/saved_objects/migrationsv2/index.ts +++ b/src/core/server/saved_objects/migrationsv2/index.ts @@ -9,9 +9,10 @@ import { ElasticsearchClient } from '../../elasticsearch'; import { IndexMapping } from '../mappings'; import { Logger } from '../../logging'; -import { SavedObjectsMigrationVersion } from '../types'; +import type { SavedObjectsMigrationVersion } from '../types'; +import type { TransformRawDocs } from './types'; import { MigrationResult } from '../migrations/core'; -import { next, TransformRawDocs } from './next'; +import { next } from './next'; import { createInitialState, model } from './model'; import { migrationStateActionMachine } from './migrations_state_action_machine'; import { SavedObjectsMigrationConfigType } from '../saved_objects_config'; @@ -55,5 +56,6 @@ export async function runResilientMigrator({ logger, next: next(client, transformRawDocs), model, + client, }); } diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/.gitignore b/src/core/server/saved_objects/migrationsv2/integration_tests/.gitignore index 57208badcc6805d..397b4a7624e35fa 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/.gitignore +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/.gitignore @@ -1 +1 @@ -migration_test_kibana.log +*.log diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts index 3905044f04e2fc1..b31f20950ae7761 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts @@ -14,9 +14,14 @@ import { SavedObjectsRawDoc } from '../../serialization'; import { bulkOverwriteTransformedDocuments, cloneIndex, + closePit, createIndex, fetchIndices, + openPit, + OpenPitResponse, reindex, + readWithPit, + ReadWithPit, searchForOutdatedDocuments, SearchResponse, setWriteBlock, @@ -30,6 +35,7 @@ import { UpdateAndPickupMappingsResponse, verifyReindex, removeWriteBlock, + transformDocs, waitForIndexStatusYellow, } from '../actions'; import * as Either from 'fp-ts/lib/Either'; @@ -70,14 +76,20 @@ describe('migration actions', () => { { _source: { title: 'saved object 4', type: 'another_unused_type' } }, { _source: { title: 'f-agent-event 5', type: 'f_agent_event' } }, ] as unknown) as SavedObjectsRawDoc[]; - await bulkOverwriteTransformedDocuments(client, 'existing_index_with_docs', sourceDocs)(); + await bulkOverwriteTransformedDocuments( + client, + 'existing_index_with_docs', + sourceDocs, + 'wait_for' + )(); await createIndex(client, 'existing_index_2', { properties: {} })(); await createIndex(client, 'existing_index_with_write_block', { properties: {} })(); await bulkOverwriteTransformedDocuments( client, 'existing_index_with_write_block', - sourceDocs + sourceDocs, + 'wait_for' )(); await setWriteBlock(client, 'existing_index_with_write_block')(); await updateAliases(client, [ @@ -155,7 +167,12 @@ describe('migration actions', () => { { _source: { title: 'doc 4' } }, ] as unknown) as SavedObjectsRawDoc[]; await expect( - bulkOverwriteTransformedDocuments(client, 'new_index_without_write_block', sourceDocs)() + bulkOverwriteTransformedDocuments( + client, + 'new_index_without_write_block', + sourceDocs, + 'wait_for' + )() ).rejects.toMatchObject(expect.anything()); }); it('resolves left index_not_found_exception when the index does not exist', async () => { @@ -265,14 +282,14 @@ describe('migration actions', () => { const task = cloneIndex(client, 'existing_index_with_write_block', 'clone_target_1'); expect.assertions(1); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": Object { - "acknowledged": true, - "shardsAcknowledged": true, - }, - } - `); + Object { + "_tag": "Right", + "right": Object { + "acknowledged": true, + "shardsAcknowledged": true, + }, + } + `); }); it('resolves right after waiting for index status to be yellow if clone target already existed', async () => { expect.assertions(2); @@ -331,14 +348,14 @@ describe('migration actions', () => { expect.assertions(1); const task = cloneIndex(client, 'no_such_index', 'clone_target_3'); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "index": "no_such_index", - "type": "index_not_found_exception", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "index": "no_such_index", + "type": "index_not_found_exception", + }, + } + `); }); it('resolves left with a retryable_es_client_error if clone target already exists but takes longer than the specified timeout before turning yellow', async () => { // Create a red index @@ -406,13 +423,13 @@ describe('migration actions', () => { targetIndex: 'reindex_target', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1", "doc 2", "doc 3", - "saved object 4", "f-agent-event 5", + "saved object 4", ] `); }); @@ -433,18 +450,18 @@ describe('migration actions', () => { )()) as Either.Right; const task = waitForReindexTask(client, res.right.taskId, '10s'); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "reindex_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "reindex_succeeded", + } + `); const results = ((await searchForOutdatedDocuments(client, { batchSize: 1000, targetIndex: 'reindex_target_excluded_docs', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1", "doc 2", @@ -474,13 +491,13 @@ describe('migration actions', () => { targetIndex: 'reindex_target_2', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1_updated", "doc 2_updated", "doc 3_updated", - "saved object 4_updated", "f-agent-event 5_updated", + "saved object 4_updated", ] `); }); @@ -526,13 +543,13 @@ describe('migration actions', () => { targetIndex: 'reindex_target_3', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1_updated", "doc 2_updated", "doc 3_updated", - "saved object 4_updated", "f-agent-event 5_updated", + "saved object 4_updated", ] `); }); @@ -551,7 +568,7 @@ describe('migration actions', () => { _id, _source, })); - await bulkOverwriteTransformedDocuments(client, 'reindex_target_4', sourceDocs)(); + await bulkOverwriteTransformedDocuments(client, 'reindex_target_4', sourceDocs, 'wait_for')(); // Now do a real reindex const res = (await reindex( @@ -576,13 +593,13 @@ describe('migration actions', () => { targetIndex: 'reindex_target_4', outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - expect(results.map((doc) => doc._source.title)).toMatchInlineSnapshot(` + expect(results.map((doc) => doc._source.title).sort()).toMatchInlineSnapshot(` Array [ "doc 1", "doc 2", "doc 3_updated", - "saved object 4_updated", "f-agent-event 5_updated", + "saved object 4_updated", ] `); }); @@ -790,9 +807,169 @@ describe('migration actions', () => { ); task = verifyReindex(client, 'existing_index_2', 'no_such_index'); - await expect(task()).rejects.toMatchInlineSnapshot( - `[ResponseError: index_not_found_exception]` + await expect(task()).rejects.toThrow('index_not_found_exception'); + }); + }); + + describe('openPit', () => { + it('opens PointInTime for an index', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + expect(pitResponse.right.pitId).toEqual(expect.any(String)); + + const searchResponse = await client.search({ + body: { + pit: { id: pitResponse.right.pitId }, + }, + }); + + await expect(searchResponse.body.hits.hits.length).toBeGreaterThan(0); + }); + it('rejects if index does not exist', async () => { + const openPitTask = openPit(client, 'no_such_index'); + await expect(openPitTask()).rejects.toThrow('index_not_found_exception'); + }); + }); + + describe('readWithPit', () => { + it('requests documents from an index using given PIT', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + const readWithPitTask = readWithPit( + client, + pitResponse.right.pitId, + Option.none, + 1000, + undefined + ); + const docsResponse = (await readWithPitTask()) as Either.Right; + + await expect(docsResponse.right.outdatedDocuments.length).toBe(5); + }); + + it('requests the batchSize of documents from an index', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + const readWithPitTask = readWithPit( + client, + pitResponse.right.pitId, + Option.none, + 3, + undefined ); + const docsResponse = (await readWithPitTask()) as Either.Right; + + await expect(docsResponse.right.outdatedDocuments.length).toBe(3); + }); + + it('it excludes documents not matching the provided "unusedTypesQuery"', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + const readWithPitTask = readWithPit( + client, + pitResponse.right.pitId, + Option.some({ + bool: { + must_not: [ + { + term: { + type: 'f_agent_event', + }, + }, + { + term: { + type: 'another_unused_type', + }, + }, + ], + }, + }), + 1000, + undefined + ); + + const docsResponse = (await readWithPitTask()) as Either.Right; + + expect(docsResponse.right.outdatedDocuments.map((doc) => doc._source.title).sort()) + .toMatchInlineSnapshot(` + Array [ + "doc 1", + "doc 2", + "doc 3", + ] + `); + }); + + it('rejects if PIT does not exist', async () => { + const readWithPitTask = readWithPit(client, 'no_such_pit', Option.none, 1000, undefined); + await expect(readWithPitTask()).rejects.toThrow('illegal_argument_exception'); + }); + }); + + describe('closePit', () => { + it('closes PointInTime', async () => { + const openPitTask = openPit(client, 'existing_index_with_docs'); + const pitResponse = (await openPitTask()) as Either.Right; + + const pitId = pitResponse.right.pitId; + await closePit(client, pitId)(); + + const searchTask = client.search({ + body: { + pit: { id: pitId }, + }, + }); + + await expect(searchTask).rejects.toThrow('search_phase_execution_exception'); + }); + + it('rejects if PIT does not exist', async () => { + const closePitTask = closePit(client, 'no_such_pit'); + await expect(closePitTask()).rejects.toThrow('illegal_argument_exception'); + }); + }); + + describe('transformDocs', () => { + it('applies "transformRawDocs" and writes result into an index', async () => { + const index = 'transform_docs_index'; + const originalDocs = [ + { _id: 'foo:1', _source: { type: 'dashboard', value: 1 } }, + { _id: 'foo:2', _source: { type: 'dashboard', value: 2 } }, + ]; + + const createIndexTask = createIndex(client, index, { + dynamic: true, + properties: {}, + }); + await createIndexTask(); + + async function tranformRawDocs(docs: SavedObjectsRawDoc[]): Promise { + for (const doc of docs) { + doc._source.value += 1; + } + return docs; + } + + const transformTask = transformDocs(client, tranformRawDocs, originalDocs, index, 'wait_for'); + + const result = (await transformTask()) as Either.Right<'bulk_index_succeeded'>; + + expect(result.right).toBe('bulk_index_succeeded'); + + const { body } = await client.search<{ value: number }>({ + index, + }); + const hits = body.hits.hits; + + const foo1 = hits.find((h) => h._id === 'foo:1'); + expect(foo1?._source?.value).toBe(2); + + const foo2 = hits.find((h) => h._id === 'foo:2'); + expect(foo2?._source?.value).toBe(3); }); }); @@ -919,7 +1096,8 @@ describe('migration actions', () => { await bulkOverwriteTransformedDocuments( client, 'existing_index_without_mappings', - sourceDocs + sourceDocs, + 'wait_for' )(); // Assert that we can't search over the unmapped fields of the document @@ -1147,7 +1325,13 @@ describe('migration actions', () => { { _source: { title: 'doc 6' } }, { _source: { title: 'doc 7' } }, ] as unknown) as SavedObjectsRawDoc[]; - const task = bulkOverwriteTransformedDocuments(client, 'existing_index_with_docs', newDocs); + const task = bulkOverwriteTransformedDocuments( + client, + 'existing_index_with_docs', + newDocs, + 'wait_for' + ); + await expect(task()).resolves.toMatchInlineSnapshot(` Object { "_tag": "Right", @@ -1162,10 +1346,12 @@ describe('migration actions', () => { outdatedDocumentsQuery: undefined, })()) as Either.Right).right.outdatedDocuments; - const task = bulkOverwriteTransformedDocuments(client, 'existing_index_with_docs', [ - ...existingDocs, - ({ _source: { title: 'doc 8' } } as unknown) as SavedObjectsRawDoc, - ]); + const task = bulkOverwriteTransformedDocuments( + client, + 'existing_index_with_docs', + [...existingDocs, ({ _source: { title: 'doc 8' } } as unknown) as SavedObjectsRawDoc], + 'wait_for' + ); await expect(task()).resolves.toMatchInlineSnapshot(` Object { "_tag": "Right", @@ -1180,7 +1366,12 @@ describe('migration actions', () => { { _source: { title: 'doc 7' } }, ] as unknown) as SavedObjectsRawDoc[]; await expect( - bulkOverwriteTransformedDocuments(client, 'existing_index_with_write_block', newDocs)() + bulkOverwriteTransformedDocuments( + client, + 'existing_index_with_write_block', + newDocs, + 'wait_for' + )() ).rejects.toMatchObject(expect.anything()); }); }); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_so_with_multiple_namespaces.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_so_with_multiple_namespaces.zip new file mode 100644 index 0000000000000000000000000000000000000000..a92211c16c559330c906aefae1768576c912580d GIT binary patch literal 56841 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 literal 0 HcmV?d00001 diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_with_corrupted_so.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_with_corrupted_so.zip new file mode 100644 index 0000000000000000000000000000000000000000..c6c89ac2879b2e674c1d613e6de0271632db9ebc GIT binary patch literal 49885 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 MDF}#0BJf}T2dHD44FCWD literal 0 HcmV?d00001 diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts new file mode 100644 index 000000000000000..48bb282da18f637 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/cleanup.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; +import Util from 'util'; +import JSON5 from 'json5'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; +import type { Root } from '../../../root'; + +const logFilePath = Path.join(__dirname, 'cleanup_test.log'); + +const asyncUnlink = Util.promisify(Fs.unlink); +const asyncReadFile = Util.promisify(Fs.readFile); +async function removeLogFile() { + // ignore errors if it doesn't exist + await asyncUnlink(logFilePath).catch(() => void 0); +} + +function createRoot() { + return kbnTestServer.createRootWithCorePlugins( + { + migrations: { + skip: false, + enableV2: true, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + ], + }, + }, + { + oss: true, + } + ); +} + +describe('migration v2', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let root: Root; + + beforeAll(async () => { + await removeLogFile(); + }); + + afterAll(async () => { + if (root) { + await root.shutdown(); + } + if (esServer) { + await esServer.stop(); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); + }); + + it('clean ups if migration fails', async () => { + const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'trial', + // original SO: + // { + // _index: '.kibana_7.13.0_001', + // _type: '_doc', + // _id: 'index-pattern:test_index*', + // _version: 1, + // result: 'created', + // _shards: { total: 2, successful: 1, failed: 0 }, + // _seq_no: 0, + // _primary_term: 1 + // } + dataArchive: Path.join(__dirname, 'archives', '7.13.0_with_corrupted_so.zip'), + }, + }, + }); + + root = createRoot(); + + esServer = await startES(); + await root.setup(); + + await expect(root.start()).rejects.toThrow( + /Unable to migrate the corrupt saved object document with _id: 'index-pattern:test_index\*'/ + ); + + const logFileContent = await asyncReadFile(logFilePath, 'utf-8'); + const records = logFileContent + .split('\n') + .filter(Boolean) + .map((str) => JSON5.parse(str)); + + const logRecordWithPit = records.find( + (rec) => rec.message === '[.kibana] REINDEX_SOURCE_TO_TEMP_OPEN_PIT RESPONSE' + ); + + expect(logRecordWithPit).toBeTruthy(); + + const pitId = logRecordWithPit.right.pitId; + expect(pitId).toBeTruthy(); + + const client = esServer.es.getClient(); + await expect( + client.search({ + body: { + pit: { id: pitId }, + }, + }) + // throws an exception that cannot search with closed PIT + ).rejects.toThrow(/search_phase_execution_exception/); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts index 1f8c3a535a9027b..37dfe9bc717d044 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts @@ -51,6 +51,8 @@ describe('migration v2', () => { migrations: { skip: false, enableV2: true, + // There are 53 docs in fixtures. Batch size configured to enforce 3 migration steps. + batchSize: 20, }, logging: { appenders: { diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts new file mode 100644 index 000000000000000..9f7e32c49ef1532 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; +import Util from 'util'; +import { kibanaPackageJson as pkg } from '@kbn/utils'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; +import type { ElasticsearchClient } from '../../../elasticsearch'; +import { Root } from '../../../root'; +import { deterministicallyRegenerateObjectId } from '../../migrations/core/document_migrator'; + +const logFilePath = Path.join(__dirname, 'migration_test_kibana.log'); + +const asyncUnlink = Util.promisify(Fs.unlink); +async function removeLogFile() { + // ignore errors if it doesn't exist + await asyncUnlink(logFilePath).catch(() => void 0); +} + +function sortByTypeAndId(a: { type: string; id: string }, b: { type: string; id: string }) { + return a.type.localeCompare(b.type) || a.id.localeCompare(b.id); +} + +async function fetchDocs(esClient: ElasticsearchClient, index: string) { + const { body } = await esClient.search({ + index, + body: { + query: { + bool: { + should: [ + { + term: { type: 'foo' }, + }, + { + term: { type: 'bar' }, + }, + { + term: { type: 'legacy-url-alias' }, + }, + ], + }, + }, + }, + }); + + return body.hits.hits + .map((h) => ({ + ...h._source, + id: h._id, + })) + .sort(sortByTypeAndId); +} + +function createRoot() { + return kbnTestServer.createRootWithCorePlugins( + { + migrations: { + skip: false, + enableV2: true, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + ], + }, + }, + { + oss: true, + } + ); +} + +describe('migration v2', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let root: Root; + + beforeAll(async () => { + await removeLogFile(); + }); + + afterAll(async () => { + if (root) { + await root.shutdown(); + } + if (esServer) { + await esServer.stop(); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); + }); + + it('rewrites id deterministically for SO with namespaceType: "multiple" and "multiple-isolated"', async () => { + const migratedIndex = `.kibana_${pkg.version}_001`; + const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'trial', + // original SO: + // [ + // { id: 'foo:1', type: 'foo', foo: { name: 'Foo 1 default' } }, + // { id: 'spacex:foo:1', type: 'foo', foo: { name: 'Foo 1 spacex' }, namespace: 'spacex' }, + // { + // id: 'bar:1', + // type: 'bar', + // bar: { nomnom: 1 }, + // references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }], + // }, + // { + // id: 'spacex:bar:1', + // type: 'bar', + // bar: { nomnom: 2 }, + // references: [{ type: 'foo', id: '1', name: 'Foo 1 spacex' }], + // namespace: 'spacex', + // }, + // ]; + dataArchive: Path.join(__dirname, 'archives', '7.13.0_so_with_multiple_namespaces.zip'), + }, + }, + }); + + root = createRoot(); + + esServer = await startES(); + const coreSetup = await root.setup(); + + coreSetup.savedObjects.registerType({ + name: 'foo', + hidden: false, + mappings: { properties: { name: { type: 'text' } } }, + namespaceType: 'multiple', + convertToMultiNamespaceTypeVersion: '8.0.0', + }); + + coreSetup.savedObjects.registerType({ + name: 'bar', + hidden: false, + mappings: { properties: { nomnom: { type: 'integer' } } }, + namespaceType: 'multiple-isolated', + convertToMultiNamespaceTypeVersion: '8.0.0', + }); + + const coreStart = await root.start(); + const esClient = coreStart.elasticsearch.client.asInternalUser; + + const migratedDocs = await fetchDocs(esClient, migratedIndex); + + // each newly converted multi-namespace object in a non-default space has its ID deterministically regenerated, and a legacy-url-alias + // object is created which links the old ID to the new ID + const newFooId = deterministicallyRegenerateObjectId('spacex', 'foo', '1'); + const newBarId = deterministicallyRegenerateObjectId('spacex', 'bar', '1'); + + expect(migratedDocs).toEqual( + [ + { + id: 'foo:1', + type: 'foo', + foo: { name: 'Foo 1 default' }, + references: [], + namespaces: ['default'], + migrationVersion: { foo: '8.0.0' }, + coreMigrationVersion: pkg.version, + }, + { + id: `foo:${newFooId}`, + type: 'foo', + foo: { name: 'Foo 1 spacex' }, + references: [], + namespaces: ['spacex'], + originId: '1', + migrationVersion: { foo: '8.0.0' }, + coreMigrationVersion: pkg.version, + }, + { + // new object for spacex:foo:1 + id: 'legacy-url-alias:spacex:foo:1', + type: 'legacy-url-alias', + 'legacy-url-alias': { + targetId: newFooId, + targetNamespace: 'spacex', + targetType: 'foo', + }, + migrationVersion: {}, + references: [], + coreMigrationVersion: pkg.version, + }, + { + id: 'bar:1', + type: 'bar', + bar: { nomnom: 1 }, + references: [{ type: 'foo', id: '1', name: 'Foo 1 default' }], + namespaces: ['default'], + migrationVersion: { bar: '8.0.0' }, + coreMigrationVersion: pkg.version, + }, + { + id: `bar:${newBarId}`, + type: 'bar', + bar: { nomnom: 2 }, + references: [{ type: 'foo', id: newFooId, name: 'Foo 1 spacex' }], + namespaces: ['spacex'], + originId: '1', + migrationVersion: { bar: '8.0.0' }, + coreMigrationVersion: pkg.version, + }, + { + // new object for spacex:bar:1 + id: 'legacy-url-alias:spacex:bar:1', + type: 'legacy-url-alias', + 'legacy-url-alias': { + targetId: newBarId, + targetNamespace: 'spacex', + targetType: 'bar', + }, + migrationVersion: {}, + references: [], + coreMigrationVersion: pkg.version, + }, + ].sort(sortByTypeAndId) + ); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts index a6617fc2fb7f48a..161d4a7219c8d97 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.test.ts @@ -5,9 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { cleanupMock } from './migrations_state_machine_cleanup.mocks'; import { migrationStateActionMachine } from './migrations_state_action_machine'; -import { loggingSystemMock } from '../../mocks'; +import { loggingSystemMock, elasticsearchServiceMock } from '../../mocks'; import * as Either from 'fp-ts/lib/Either'; import * as Option from 'fp-ts/lib/Option'; import { AllControlStates, State } from './types'; @@ -15,6 +15,7 @@ import { createInitialState } from './model'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { elasticsearchClientMock } from '../../elasticsearch/client/mocks'; +const esClient = elasticsearchServiceMock.createElasticsearchClient(); describe('migrationsStateActionMachine', () => { beforeAll(() => { jest @@ -74,6 +75,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), next, + client: esClient, }); const logs = loggingSystemMock.collect(mockLogger); const doneLog = logs.info.splice(8, 1)[0][0]; @@ -151,6 +153,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), next, + client: esClient, }) ).resolves.toEqual(expect.anything()); }); @@ -161,6 +164,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), next, + client: esClient, }) ).resolves.toEqual(expect.objectContaining({ status: 'migrated' })); }); @@ -171,6 +175,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'LEGACY_DELETE', 'DONE']), next, + client: esClient, }) ).resolves.toEqual(expect.objectContaining({ status: 'patched' })); }); @@ -181,6 +186,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'FATAL']), next, + client: esClient, }) ).rejects.toMatchInlineSnapshot( `[Error: Unable to complete saved object migrations for the [.my-so-index] index: the fatal reason]` @@ -196,6 +202,7 @@ describe('migrationsStateActionMachine', () => { logger: mockLogger.get(), model: transitionModel(['LEGACY_DELETE', 'FATAL']), next, + client: esClient, }).catch((err) => err); // Ignore the first 4 log entries that come from our model const executionLogLogs = loggingSystemMock.collect(mockLogger).info.slice(4); @@ -418,6 +425,7 @@ describe('migrationsStateActionMachine', () => { }) ); }, + client: esClient, }) ).rejects.toMatchInlineSnapshot( `[Error: Unable to complete saved object migrations for the [.my-so-index] index. Please check the health of your Elasticsearch cluster and try again. Error: [snapshot_in_progress_exception]: Cannot delete indices that are being snapshotted]` @@ -450,6 +458,7 @@ describe('migrationsStateActionMachine', () => { next: () => { throw new Error('this action throws'); }, + client: esClient, }) ).rejects.toMatchInlineSnapshot( `[Error: Unable to complete saved object migrations for the [.my-so-index] index. Error: this action throws]` @@ -483,6 +492,7 @@ describe('migrationsStateActionMachine', () => { if (state.controlState === 'LEGACY_DELETE') throw new Error('this action throws'); return () => Promise.resolve('hello'); }, + client: esClient, }); } catch (e) { /** ignore */ @@ -680,4 +690,37 @@ describe('migrationsStateActionMachine', () => { ] `); }); + describe('cleanup', () => { + beforeEach(() => { + cleanupMock.mockClear(); + }); + it('calls cleanup function when an action throws', async () => { + await expect( + migrationStateActionMachine({ + initialState: { ...initialState, reason: 'the fatal reason' } as State, + logger: mockLogger.get(), + model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'FATAL']), + next: () => { + throw new Error('this action throws'); + }, + client: esClient, + }) + ).rejects.toThrow(); + + expect(cleanupMock).toHaveBeenCalledTimes(1); + }); + it('calls cleanup function when reaching the FATAL state', async () => { + await expect( + migrationStateActionMachine({ + initialState: { ...initialState, reason: 'the fatal reason' } as State, + logger: mockLogger.get(), + model: transitionModel(['LEGACY_REINDEX', 'LEGACY_DELETE', 'FATAL']), + next, + client: esClient, + }) + ).rejects.toThrow(); + + expect(cleanupMock).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts index 20177dda63b3b36..dede52f9758e962 100644 --- a/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_action_machine.ts @@ -9,8 +9,10 @@ import { errors as EsErrors } from '@elastic/elasticsearch'; import * as Option from 'fp-ts/lib/Option'; import { Logger, LogMeta } from '../../logging'; +import type { ElasticsearchClient } from '../../elasticsearch'; import { CorruptSavedObjectError } from '../migrations/core/migrate_raw_docs'; import { Model, Next, stateActionMachine } from './state_action_machine'; +import { cleanup } from './migrations_state_machine_cleanup'; import { State } from './types'; interface StateLogMeta extends LogMeta { @@ -19,7 +21,8 @@ interface StateLogMeta extends LogMeta { }; } -type ExecutionLog = Array< +/** @internal */ +export type ExecutionLog = Array< | { type: 'transition'; prevControlState: State['controlState']; @@ -31,6 +34,11 @@ type ExecutionLog = Array< controlState: State['controlState']; res: unknown; } + | { + type: 'cleanup'; + state: State; + message: string; + } >; const logStateTransition = ( @@ -99,11 +107,13 @@ export async function migrationStateActionMachine({ logger, next, model, + client, }: { initialState: State; logger: Logger; next: Next; model: Model; + client: ElasticsearchClient; }) { const executionLog: ExecutionLog = []; const startTime = Date.now(); @@ -112,11 +122,13 @@ export async function migrationStateActionMachine({ // indicate which messages come from which index upgrade. const logMessagePrefix = `[${initialState.indexPrefix}] `; let prevTimestamp = startTime; + let lastState: State | undefined; try { const finalState = await stateActionMachine( initialState, (state) => next(state), (state, res) => { + lastState = state; executionLog.push({ type: 'response', res, @@ -169,6 +181,7 @@ export async function migrationStateActionMachine({ }; } } else if (finalState.controlState === 'FATAL') { + await cleanup(client, executionLog, finalState); dumpExecutionLog(logger, logMessagePrefix, executionLog); return Promise.reject( new Error( @@ -180,6 +193,7 @@ export async function migrationStateActionMachine({ throw new Error('Invalid terminating control state'); } } catch (e) { + await cleanup(client, executionLog, lastState); if (e instanceof EsErrors.ResponseError) { logger.error( logMessagePrefix + `[${e.body?.error?.type}]: ${e.body?.error?.reason ?? e.message}` @@ -202,9 +216,13 @@ export async function migrationStateActionMachine({ ); } - throw new Error( + const newError = new Error( `Unable to complete saved object migrations for the [${initialState.indexPrefix}] index. ${e}` ); + + // restore error stack to point to a source of the problem. + newError.stack = `[${e.stack}]`; + throw newError; } } } diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.mocks.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.mocks.ts new file mode 100644 index 000000000000000..29967a1f758205a --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.mocks.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const cleanupMock = jest.fn(); +jest.doMock('./migrations_state_machine_cleanup', () => ({ + cleanup: cleanupMock, +})); diff --git a/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.ts b/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.ts new file mode 100644 index 000000000000000..1881f9a712c293b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/migrations_state_machine_cleanup.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ElasticsearchClient } from '../../elasticsearch'; +import * as Actions from './actions'; +import type { State } from './types'; +import type { ExecutionLog } from './migrations_state_action_machine'; + +export async function cleanup( + client: ElasticsearchClient, + executionLog: ExecutionLog, + state?: State +) { + if (!state) return; + if ('sourceIndexPitId' in state) { + try { + await Actions.closePit(client, state.sourceIndexPitId)(); + } catch (e) { + executionLog.push({ + type: 'cleanup', + state, + message: e.message, + }); + } + } +} diff --git a/src/core/server/saved_objects/migrationsv2/model.test.ts b/src/core/server/saved_objects/migrationsv2/model.test.ts index 0267ae33dd157c0..57a7a7f2ea24a87 100644 --- a/src/core/server/saved_objects/migrationsv2/model.test.ts +++ b/src/core/server/saved_objects/migrationsv2/model.test.ts @@ -17,7 +17,10 @@ import type { LegacyReindexState, LegacyReindexWaitForTaskState, LegacyDeleteState, - ReindexSourceToTempState, + ReindexSourceToTempOpenPit, + ReindexSourceToTempRead, + ReindexSourceToTempClosePit, + ReindexSourceToTempIndex, UpdateTargetMappingsState, UpdateTargetMappingsWaitForTaskState, OutdatedDocumentsSearch, @@ -25,7 +28,6 @@ import type { MarkVersionIndexReady, BaseState, CreateReindexTempState, - ReindexSourceToTempWaitForTaskState, MarkVersionIndexReadyConflict, CreateNewTargetState, CloneTempToSource, @@ -299,14 +301,12 @@ describe('migrations v2 model', () => { settings: {}, }, }); - const newState = model(initState, res) as FatalState; + const newState = model(initState, res) as WaitForYellowSourceState; - expect(newState.controlState).toEqual('WAIT_FOR_YELLOW_SOURCE'); - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: '.kibana_7.invalid.0_001', - }); + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('.kibana_7.invalid.0_001'); }); + test('INIT -> WAIT_FOR_YELLOW_SOURCE when migrating from a v2 migrations index (>= 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_7.11.0_001': { @@ -330,15 +330,14 @@ describe('migrations v2 model', () => { }, }, res - ); + ) as WaitForYellowSourceState; - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: '.kibana_7.11.0_001', - }); + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('.kibana_7.11.0_001'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); + test('INIT -> WAIT_FOR_YELLOW_SOURCE when migrating from a v1 migrations index (>= 6.5 < 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_3': { @@ -349,12 +348,10 @@ describe('migrations v2 model', () => { settings: {}, }, }); - const newState = model(initState, res); + const newState = model(initState, res) as WaitForYellowSourceState; - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: '.kibana_3', - }); + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('.kibana_3'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -420,12 +417,10 @@ describe('migrations v2 model', () => { versionIndex: 'my-saved-objects_7.11.0_001', }, res - ); + ) as WaitForYellowSourceState; - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: 'my-saved-objects_3', - }); + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('my-saved-objects_3'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -449,12 +444,11 @@ describe('migrations v2 model', () => { versionIndex: 'my-saved-objects_7.12.0_001', }, res - ); + ) as WaitForYellowSourceState; + + expect(newState.controlState).toBe('WAIT_FOR_YELLOW_SOURCE'); + expect(newState.sourceIndex.value).toBe('my-saved-objects_7.11.0'); - expect(newState).toMatchObject({ - controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: 'my-saved-objects_7.11.0', - }); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -662,7 +656,7 @@ describe('migrations v2 model', () => { const waitForYellowSourceState: WaitForYellowSourceState = { ...baseState, controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: '.kibana_3', + sourceIndex: Option.some('.kibana_3') as Option.Some, sourceIndexMappings: mappingsWithUnknownType, }; @@ -734,7 +728,7 @@ describe('migrations v2 model', () => { }); }); describe('CREATE_REINDEX_TEMP', () => { - const createReindexTargetState: CreateReindexTempState = { + const state: CreateReindexTempState = { ...baseState, controlState: 'CREATE_REINDEX_TEMP', versionIndexReadyActions: Option.none, @@ -742,80 +736,134 @@ describe('migrations v2 model', () => { targetIndex: '.kibana_7.11.0_001', tempIndexMappings: { properties: {} }, }; - it('CREATE_REINDEX_TEMP -> REINDEX_SOURCE_TO_TEMP if action succeeds', () => { + it('CREATE_REINDEX_TEMP -> REINDEX_SOURCE_TO_TEMP_OPEN_PIT if action succeeds', () => { const res: ResponseType<'CREATE_REINDEX_TEMP'> = Either.right('create_index_succeeded'); - const newState = model(createReindexTargetState, res); - expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP'); + const newState = model(state, res); + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_OPEN_PIT'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); }); - describe('REINDEX_SOURCE_TO_TEMP', () => { - const reindexSourceToTargetState: ReindexSourceToTempState = { + + describe('REINDEX_SOURCE_TO_TEMP_OPEN_PIT', () => { + const state: ReindexSourceToTempOpenPit = { ...baseState, - controlState: 'REINDEX_SOURCE_TO_TEMP', + controlState: 'REINDEX_SOURCE_TO_TEMP_OPEN_PIT', versionIndexReadyActions: Option.none, sourceIndex: Option.some('.kibana') as Option.Some, targetIndex: '.kibana_7.11.0_001', + tempIndexMappings: { properties: {} }, }; - test('REINDEX_SOURCE_TO_TEMP -> REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP'> = Either.right({ - taskId: 'reindex-task-id', + it('REINDEX_SOURCE_TO_TEMP_OPEN_PIT -> REINDEX_SOURCE_TO_TEMP_READ if action succeeds', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_OPEN_PIT'> = Either.right({ + pitId: 'pit_id', }); - const newState = model(reindexSourceToTargetState, res); - expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'); - expect(newState.retryCount).toEqual(0); - expect(newState.retryDelay).toEqual(0); + const newState = model(state, res) as ReindexSourceToTempRead; + expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_READ'); + expect(newState.sourceIndexPitId).toBe('pit_id'); + expect(newState.lastHitSortValue).toBe(undefined); }); }); - describe('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK', () => { - const state: ReindexSourceToTempWaitForTaskState = { + + describe('REINDEX_SOURCE_TO_TEMP_READ', () => { + const state: ReindexSourceToTempRead = { ...baseState, - controlState: 'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK', + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', versionIndexReadyActions: Option.none, sourceIndex: Option.some('.kibana') as Option.Some, + sourceIndexPitId: 'pit_id', targetIndex: '.kibana_7.11.0_001', - reindexSourceToTargetTaskId: 'reindex-task-id', + tempIndexMappings: { properties: {} }, + lastHitSortValue: undefined, }; - test('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK -> SET_TEMP_WRITE_BLOCK when response is right', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'> = Either.right( - 'reindex_succeeded' + + it('REINDEX_SOURCE_TO_TEMP_READ -> REINDEX_SOURCE_TO_TEMP_INDEX if the index has outdated documents to reindex', () => { + const outdatedDocuments = [{ _id: '1', _source: { type: 'vis' } }]; + const lastHitSortValue = [123456]; + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_READ'> = Either.right({ + outdatedDocuments, + lastHitSortValue, + }); + const newState = model(state, res) as ReindexSourceToTempIndex; + expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_INDEX'); + expect(newState.outdatedDocuments).toBe(outdatedDocuments); + expect(newState.lastHitSortValue).toBe(lastHitSortValue); + }); + + it('REINDEX_SOURCE_TO_TEMP_READ -> REINDEX_SOURCE_TO_TEMP_CLOSE_PIT if no outdated documents to reindex', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_READ'> = Either.right({ + outdatedDocuments: [], + lastHitSortValue: undefined, + }); + const newState = model(state, res) as ReindexSourceToTempClosePit; + expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_CLOSE_PIT'); + expect(newState.sourceIndexPitId).toBe('pit_id'); + }); + }); + + describe('REINDEX_SOURCE_TO_TEMP_CLOSE_PIT', () => { + const state: ReindexSourceToTempClosePit = { + ...baseState, + controlState: 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT', + versionIndexReadyActions: Option.none, + sourceIndex: Option.some('.kibana') as Option.Some, + sourceIndexPitId: 'pit_id', + targetIndex: '.kibana_7.11.0_001', + tempIndexMappings: { properties: {} }, + }; + + it('REINDEX_SOURCE_TO_TEMP_CLOSE_PIT -> SET_TEMP_WRITE_BLOCK if action succeeded', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT'> = Either.right({}); + const newState = model(state, res) as ReindexSourceToTempIndex; + expect(newState.controlState).toBe('SET_TEMP_WRITE_BLOCK'); + expect(newState.sourceIndex).toEqual(state.sourceIndex); + }); + }); + + describe('REINDEX_SOURCE_TO_TEMP_INDEX', () => { + const state: ReindexSourceToTempIndex = { + ...baseState, + controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX', + outdatedDocuments: [], + versionIndexReadyActions: Option.none, + sourceIndex: Option.some('.kibana') as Option.Some, + sourceIndexPitId: 'pit_id', + targetIndex: '.kibana_7.11.0_001', + lastHitSortValue: undefined, + }; + + it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ if action succeeded', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.right( + 'bulk_index_succeeded' ); const newState = model(state, res); - expect(newState.controlState).toEqual('SET_TEMP_WRITE_BLOCK'); + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_READ'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK -> SET_TEMP_WRITE_BLOCK when response is left target_index_had_write_block', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'> = Either.left({ + + it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ when response is left target_index_had_write_block', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.left({ type: 'target_index_had_write_block', }); - const newState = model(state, res); - expect(newState.controlState).toEqual('SET_TEMP_WRITE_BLOCK'); + const newState = model(state, res) as ReindexSourceToTempRead; + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_READ'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK -> SET_TEMP_WRITE_BLOCK when response is left index_not_found_exception', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'> = Either.left({ + + it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ when response is left index_not_found_exception for temp index', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.left({ type: 'index_not_found_exception', - index: '.kibana_7.11.0_reindex_temp', + index: state.tempIndex, }); - const newState = model(state, res); - expect(newState.controlState).toEqual('SET_TEMP_WRITE_BLOCK'); + const newState = model(state, res) as ReindexSourceToTempRead; + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_READ'); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK -> REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK when response is left wait_for_task_completion_timeout', () => { - const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'> = Either.left({ - message: '[timeout_exception] Timeout waiting for ...', - type: 'wait_for_task_completion_timeout', - }); - const newState = model(state, res); - expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'); - expect(newState.retryCount).toEqual(1); - expect(newState.retryDelay).toEqual(2000); - }); }); + describe('SET_TEMP_WRITE_BLOCK', () => { const state: SetTempWriteBlock = { ...baseState, diff --git a/src/core/server/saved_objects/migrationsv2/model.ts b/src/core/server/saved_objects/migrationsv2/model.ts index acf0f620136a2c9..2097b1de88aaba2 100644 --- a/src/core/server/saved_objects/migrationsv2/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model.ts @@ -227,7 +227,7 @@ export const model = (currentState: State, resW: ResponseType): return { ...stateP, controlState: 'WAIT_FOR_YELLOW_SOURCE', - sourceIndex: source, + sourceIndex: Option.some(source) as Option.Some, sourceIndexMappings: indices[source].mappings, }; } else if (indices[stateP.legacyIndex] != null) { @@ -303,7 +303,7 @@ export const model = (currentState: State, resW: ResponseType): } } else if (stateP.controlState === 'LEGACY_SET_WRITE_BLOCK') { const res = resW as ExcludeRetryableEsError>; - // If the write block is sucessfully in place + // If the write block is successfully in place if (Either.isRight(res)) { return { ...stateP, controlState: 'LEGACY_CREATE_REINDEX_TARGET' }; } else if (Either.isLeft(res)) { @@ -431,14 +431,14 @@ export const model = (currentState: State, resW: ResponseType): return { ...stateP, controlState: 'SET_SOURCE_WRITE_BLOCK', - sourceIndex: Option.some(source) as Option.Some, + sourceIndex: source, targetIndex: target, targetIndexMappings: disableUnknownTypeMappingFields( stateP.targetIndexMappings, stateP.sourceIndexMappings ), versionIndexReadyActions: Option.some([ - { remove: { index: source, alias: stateP.currentAlias, must_exist: true } }, + { remove: { index: source.value, alias: stateP.currentAlias, must_exist: true } }, { add: { index: target, alias: stateP.currentAlias } }, { add: { index: target, alias: stateP.versionAlias } }, { remove_index: { index: stateP.tempIndex } }, @@ -466,32 +466,61 @@ export const model = (currentState: State, resW: ResponseType): } else if (stateP.controlState === 'CREATE_REINDEX_TEMP') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { - return { ...stateP, controlState: 'REINDEX_SOURCE_TO_TEMP' }; + return { ...stateP, controlState: 'REINDEX_SOURCE_TO_TEMP_OPEN_PIT' }; } else { // If the createIndex action receives an 'resource_already_exists_exception' // it will wait until the index status turns green so we don't have any // left responses to handle here. throwBadResponse(stateP, res); } - } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP') { + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_OPEN_PIT') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { return { ...stateP, - controlState: 'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK', - reindexSourceToTargetTaskId: res.right.taskId, + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', + sourceIndexPitId: res.right.pitId, + lastHitSortValue: undefined, }; } else { - // Since this is a background task, the request should always succeed, - // errors only show up in the returned task. throwBadResponse(stateP, res); } - } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK') { + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_READ') { const res = resW as ExcludeRetryableEsError>; if (Either.isRight(res)) { + if (res.right.outdatedDocuments.length > 0) { + return { + ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX', + outdatedDocuments: res.right.outdatedDocuments, + lastHitSortValue: res.right.lastHitSortValue, + }; + } return { ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT', + }; + } else { + throwBadResponse(stateP, res); + } + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT') { + const res = resW as ExcludeRetryableEsError>; + if (Either.isRight(res)) { + const { sourceIndexPitId, ...state } = stateP; + return { + ...state, controlState: 'SET_TEMP_WRITE_BLOCK', + sourceIndex: stateP.sourceIndex as Option.Some, + }; + } else { + throwBadResponse(stateP, res); + } + } else if (stateP.controlState === 'REINDEX_SOURCE_TO_TEMP_INDEX') { + const res = resW as ExcludeRetryableEsError>; + if (Either.isRight(res)) { + return { + ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', }; } else { const left = res.left; @@ -510,28 +539,11 @@ export const model = (currentState: State, resW: ResponseType): // we know another instance already completed these. return { ...stateP, - controlState: 'SET_TEMP_WRITE_BLOCK', + controlState: 'REINDEX_SOURCE_TO_TEMP_READ', }; - } else if (isLeftTypeof(left, 'wait_for_task_completion_timeout')) { - // After waiting for the specificed timeout, the task has not yet - // completed. Retry this step to see if the task has completed after an - // exponential delay. We will basically keep polling forever until the - // Elasticeasrch task succeeds or fails. - return delayRetryState(stateP, left.message, Number.MAX_SAFE_INTEGER); - } else if ( - isLeftTypeof(left, 'index_not_found_exception') || - isLeftTypeof(left, 'incompatible_mapping_exception') - ) { - // Don't handle the following errors as the migration algorithm should - // never cause them to occur: - // - incompatible_mapping_exception the temp index has `dynamic: false` - // mappings - // - index_not_found_exception for the source index, we will never - // delete the source index - throwBadResponse(stateP, left as never); - } else { - throwBadResponse(stateP, left); } + // should never happen + throwBadResponse(stateP, res as never); } } else if (stateP.controlState === 'SET_TEMP_WRITE_BLOCK') { const res = resW as ExcludeRetryableEsError>; @@ -609,7 +621,7 @@ export const model = (currentState: State, resW: ResponseType): controlState: 'OUTDATED_DOCUMENTS_SEARCH', }; } else { - throwBadResponse(stateP, res); + throwBadResponse(stateP, res as never); } } else if (stateP.controlState === 'UPDATE_TARGET_MAPPINGS') { const res = resW as ExcludeRetryableEsError>; @@ -647,10 +659,10 @@ export const model = (currentState: State, resW: ResponseType): } else { const left = res.left; if (isLeftTypeof(left, 'wait_for_task_completion_timeout')) { - // After waiting for the specificed timeout, the task has not yet + // After waiting for the specified timeout, the task has not yet // completed. Retry this step to see if the task has completed after an // exponential delay. We will basically keep polling forever until the - // Elasticeasrch task succeeds or fails. + // Elasticsearch task succeeds or fails. return delayRetryState(stateP, res.left.message, Number.MAX_SAFE_INTEGER); } else { throwBadResponse(stateP, left); diff --git a/src/core/server/saved_objects/migrationsv2/next.ts b/src/core/server/saved_objects/migrationsv2/next.ts index bb506cbca66fb14..6d61634a6948e70 100644 --- a/src/core/server/saved_objects/migrationsv2/next.ts +++ b/src/core/server/saved_objects/migrationsv2/next.ts @@ -6,13 +6,13 @@ * Side Public License, v 1. */ -import * as TaskEither from 'fp-ts/lib/TaskEither'; -import * as Option from 'fp-ts/lib/Option'; -import { UnwrapPromise } from '@kbn/utility-types'; -import { pipe } from 'fp-ts/lib/pipeable'; +import type { UnwrapPromise } from '@kbn/utility-types'; import type { AllActionStates, - ReindexSourceToTempState, + ReindexSourceToTempOpenPit, + ReindexSourceToTempRead, + ReindexSourceToTempClosePit, + ReindexSourceToTempIndex, MarkVersionIndexReady, InitState, LegacyCreateReindexTargetState, @@ -27,18 +27,16 @@ import type { UpdateTargetMappingsState, UpdateTargetMappingsWaitForTaskState, CreateReindexTempState, - ReindexSourceToTempWaitForTaskState, MarkVersionIndexReadyConflict, CreateNewTargetState, CloneTempToSource, SetTempWriteBlock, WaitForYellowSourceState, + TransformRawDocs, } from './types'; import * as Actions from './actions'; import { ElasticsearchClient } from '../../elasticsearch'; -import { SavedObjectsRawDoc } from '..'; -export type TransformRawDocs = (rawDocs: SavedObjectsRawDoc[]) => Promise; type ActionMap = ReturnType; /** @@ -56,26 +54,43 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra INIT: (state: InitState) => Actions.fetchIndices(client, [state.currentAlias, state.versionAlias]), WAIT_FOR_YELLOW_SOURCE: (state: WaitForYellowSourceState) => - Actions.waitForIndexStatusYellow(client, state.sourceIndex), + Actions.waitForIndexStatusYellow(client, state.sourceIndex.value), SET_SOURCE_WRITE_BLOCK: (state: SetSourceWriteBlockState) => Actions.setWriteBlock(client, state.sourceIndex.value), CREATE_NEW_TARGET: (state: CreateNewTargetState) => Actions.createIndex(client, state.targetIndex, state.targetIndexMappings), CREATE_REINDEX_TEMP: (state: CreateReindexTempState) => Actions.createIndex(client, state.tempIndex, state.tempIndexMappings), - REINDEX_SOURCE_TO_TEMP: (state: ReindexSourceToTempState) => - Actions.reindex( + REINDEX_SOURCE_TO_TEMP_OPEN_PIT: (state: ReindexSourceToTempOpenPit) => + Actions.openPit(client, state.sourceIndex.value), + REINDEX_SOURCE_TO_TEMP_READ: (state: ReindexSourceToTempRead) => + Actions.readWithPit( client, - state.sourceIndex.value, + state.sourceIndexPitId, + state.unusedTypesQuery, + state.batchSize, + state.lastHitSortValue + ), + REINDEX_SOURCE_TO_TEMP_CLOSE_PIT: (state: ReindexSourceToTempClosePit) => + Actions.closePit(client, state.sourceIndexPitId), + REINDEX_SOURCE_TO_TEMP_INDEX: (state: ReindexSourceToTempIndex) => + Actions.transformDocs( + client, + transformRawDocs, + state.outdatedDocuments, state.tempIndex, - Option.none, - false, - state.unusedTypesQuery + /** + * Since we don't run a search against the target index, we disable "refresh" to speed up + * the migration process. + * Although any further step must run "refresh" for the target index + * before we reach out to the OUTDATED_DOCUMENTS_SEARCH step. + * Right now, we rely on UPDATE_TARGET_MAPPINGS + UPDATE_TARGET_MAPPINGS_WAIT_FOR_TASK + * to perform refresh. + */ + false ), SET_TEMP_WRITE_BLOCK: (state: SetTempWriteBlock) => Actions.setWriteBlock(client, state.tempIndex), - REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK: (state: ReindexSourceToTempWaitForTaskState) => - Actions.waitForReindexTask(client, state.reindexSourceToTargetTaskId, '60s'), CLONE_TEMP_TO_TARGET: (state: CloneTempToSource) => Actions.cloneIndex(client, state.tempIndex, state.targetIndex), UPDATE_TARGET_MAPPINGS: (state: UpdateTargetMappingsState) => @@ -89,16 +104,20 @@ export const nextActionMap = (client: ElasticsearchClient, transformRawDocs: Tra outdatedDocumentsQuery: state.outdatedDocumentsQuery, }), OUTDATED_DOCUMENTS_TRANSFORM: (state: OutdatedDocumentsTransform) => - pipe( - TaskEither.tryCatch( - () => transformRawDocs(state.outdatedDocuments), - (e) => { - throw e; - } - ), - TaskEither.chain((docs) => - Actions.bulkOverwriteTransformedDocuments(client, state.targetIndex, docs) - ) + // Wait for a refresh to happen before returning. This ensures that when + // this Kibana instance searches for outdated documents, it won't find + // documents that were already transformed by itself or another Kibana + // instance. However, this causes each OUTDATED_DOCUMENTS_SEARCH -> + // OUTDATED_DOCUMENTS_TRANSFORM cycle to take 1s so when batches are + // small performance will become a lot worse. + // The alternative is to use a search_after with either a tie_breaker + // field or using a Point In Time as a cursor to go through all documents. + Actions.transformDocs( + client, + transformRawDocs, + state.outdatedDocuments, + state.targetIndex, + 'wait_for' ), MARK_VERSION_INDEX_READY: (state: MarkVersionIndexReady) => Actions.updateAliases(client, state.versionIndexReadyActions.value), diff --git a/src/core/server/saved_objects/migrationsv2/types.ts b/src/core/server/saved_objects/migrationsv2/types.ts index 5e84bc23b1d1616..b84d483cf620315 100644 --- a/src/core/server/saved_objects/migrationsv2/types.ts +++ b/src/core/server/saved_objects/migrationsv2/types.ts @@ -132,7 +132,7 @@ export type FatalState = BaseState & { export interface WaitForYellowSourceState extends BaseState { /** Wait for the source index to be yellow before requesting it. */ readonly controlState: 'WAIT_FOR_YELLOW_SOURCE'; - readonly sourceIndex: string; + readonly sourceIndex: Option.Some; readonly sourceIndexMappings: IndexMapping; } @@ -158,21 +158,29 @@ export type CreateReindexTempState = PostInitState & { readonly sourceIndex: Option.Some; }; -export type ReindexSourceToTempState = PostInitState & { - /** Reindex documents from the source index into the target index */ - readonly controlState: 'REINDEX_SOURCE_TO_TEMP'; +export interface ReindexSourceToTempOpenPit extends PostInitState { + /** Open PIT to the source index */ + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_OPEN_PIT'; readonly sourceIndex: Option.Some; -}; +} -export type ReindexSourceToTempWaitForTaskState = PostInitState & { - /** - * Wait until reindexing documents from the source index into the target - * index has completed - */ - readonly controlState: 'REINDEX_SOURCE_TO_TEMP_WAIT_FOR_TASK'; - readonly sourceIndex: Option.Some; - readonly reindexSourceToTargetTaskId: string; -}; +export interface ReindexSourceToTempRead extends PostInitState { + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_READ'; + readonly sourceIndexPitId: string; + readonly lastHitSortValue: number[] | undefined; +} + +export interface ReindexSourceToTempClosePit extends PostInitState { + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT'; + readonly sourceIndexPitId: string; +} + +export interface ReindexSourceToTempIndex extends PostInitState { + readonly controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX'; + readonly outdatedDocuments: SavedObjectsRawDoc[]; + readonly sourceIndexPitId: string; + readonly lastHitSortValue: number[] | undefined; +} export type SetTempWriteBlock = PostInitState & { /** @@ -302,8 +310,10 @@ export type State = | SetSourceWriteBlockState | CreateNewTargetState | CreateReindexTempState - | ReindexSourceToTempState - | ReindexSourceToTempWaitForTaskState + | ReindexSourceToTempOpenPit + | ReindexSourceToTempRead + | ReindexSourceToTempClosePit + | ReindexSourceToTempIndex | SetTempWriteBlock | CloneTempToSource | UpdateTargetMappingsState @@ -324,3 +334,5 @@ export type AllControlStates = State['controlState']; * 'FATAL' and 'DONE'). */ export type AllActionStates = Exclude; + +export type TransformRawDocs = (rawDocs: SavedObjectsRawDoc[]) => Promise; diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index c0e2cdc33336336..8faa476b77bfa05 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -1917,10 +1917,7 @@ export class SavedObjectsRepository { ...(preference ? { preference } : {}), }; - const { - body, - statusCode, - } = await this.client.openPointInTime( + const { body, statusCode } = await this.client.openPointInTime( // @ts-expect-error @elastic/elasticsearch OpenPointInTimeRequest.index expected to accept string[] esOptions, { diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index 950ab5f4392e158..dbf19f84825bed8 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Client } from 'elasticsearch'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; import { // @ts-expect-error https://github.com/elastic/kibana/issues/95679 @@ -140,7 +140,7 @@ export interface TestElasticsearchServer { start: (esArgs: string[], esEnvVars: Record) => Promise; stop: () => Promise; cleanup: () => Promise; - getClient: () => Client; + getClient: () => KibanaClient; getCallCluster: () => LegacyAPICaller; getUrl: () => string; } diff --git a/test/functional/apps/context/_discover_navigation.js b/test/functional/apps/context/_discover_navigation.js index dc5d56271c7fd76..1c3862e07e9d7be 100644 --- a/test/functional/apps/context/_discover_navigation.js +++ b/test/functional/apps/context/_discover_navigation.js @@ -35,7 +35,10 @@ export default function ({ getService, getPageObjects }) { describe('context link in discover', () => { before(async () => { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); - await kibanaServer.uiSettings.update({ 'doc_table:legacy': true }); + await kibanaServer.uiSettings.update({ + 'doc_table:legacy': true, + defaultIndex: 'logstash-*', + }); await PageObjects.common.navigateToApp('discover'); for (const columnName of TEST_COLUMN_NAMES) { diff --git a/test/functional/fixtures/es_archiver/visualize/data.json b/test/functional/fixtures/es_archiver/visualize/data.json index 66941e201e9bada..f337bffe80f2cd5 100644 --- a/test/functional/fixtures/es_archiver/visualize/data.json +++ b/test/functional/fixtures/es_archiver/visualize/data.json @@ -207,7 +207,7 @@ "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"message\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"message.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"message\"}}},{\"name\":\"user\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"user.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"user\"}}}]", "title": "test_index*" }, - "type": "test_index*" + "type": "index-pattern" } } } From b6f0ff29db83467ed1a1fc46c6577f6690e416f6 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Mon, 26 Apr 2021 15:31:00 +0100 Subject: [PATCH 25/37] [Task manager] avoid adding the health monitoring data into the service status (#98265) Removes the Task Manager Health Status from the `meta` field in Core Service Status. --- .../task_manager/server/routes/health.test.ts | 25 ------------------- .../task_manager/server/routes/health.ts | 16 +++++++++--- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/task_manager/server/routes/health.test.ts b/x-pack/plugins/task_manager/server/routes/health.test.ts index dd7ed69aaf27f96..0a9671d9ac37e99 100644 --- a/x-pack/plugins/task_manager/server/routes/health.test.ts +++ b/x-pack/plugins/task_manager/server/routes/health.test.ts @@ -155,31 +155,6 @@ describe('healthRoute', () => { expect(await serviceStatus).toMatchObject({ level: ServiceStatusLevels.unavailable, summary: 'Task Manager is unavailable', - meta: { - status: 'error', - ...summarizeMonitoringStats( - mockHealthStats({ - last_update: expect.any(String), - stats: { - configuration: { - timestamp: expect.any(String), - }, - workload: { - timestamp: expect.any(String), - }, - runtime: { - timestamp: expect.any(String), - value: { - polling: { - last_successful_poll: expect.any(String), - }, - }, - }, - }, - }), - getTaskManagerConfig({}) - ), - }, }); }); diff --git a/x-pack/plugins/task_manager/server/routes/health.ts b/x-pack/plugins/task_manager/server/routes/health.ts index 589443b62ea4277..cc2f6c6630e56d8 100644 --- a/x-pack/plugins/task_manager/server/routes/health.ts +++ b/x-pack/plugins/task_manager/server/routes/health.ts @@ -34,13 +34,22 @@ const LEVEL_SUMMARY = { [ServiceStatusLevels.unavailable.toString()]: 'Task Manager is unavailable', }; +/** + * We enforce a `meta` of `never` because this meta gets duplicated into *every dependant plugin*, and + * this will then get logged out when logging is set to Verbose. + * We used to pass in the the entire MonitoredHealth into this `meta` field, but this means that the + * whole MonitoredHealth JSON (which can be quite big) was duplicated dozens of times and when we + * try to view logs in Discover, it fails to render as this JSON was often dozens of levels deep. + */ +type TaskManagerServiceStatus = ServiceStatus; + export function healthRoute( router: IRouter, monitoringStats$: Observable, logger: Logger, taskManagerId: string, config: TaskManagerConfig -): Observable { +): Observable { // if "hot" health stats are any more stale than monitored_stats_required_freshness (pollInterval +1s buffer by default) // consider the system unhealthy const requiredHotStatsFreshness: number = config.monitored_stats_required_freshness; @@ -67,7 +76,7 @@ export function healthRoute( return { id: taskManagerId, timestamp, status: healthStatus, ...summarizedStats }; } - const serviceStatus$: Subject = new Subject(); + const serviceStatus$: Subject = new Subject(); /* keep track of last health summary, as we'll return that to the next call to _health */ let lastMonitoredStats: MonitoringStats | null = null; @@ -110,7 +119,7 @@ export function healthRoute( export function withServiceStatus( monitoredHealth: MonitoredHealth -): [MonitoredHealth, ServiceStatus] { +): [MonitoredHealth, TaskManagerServiceStatus] { const level = monitoredHealth.status === HealthStatus.OK ? ServiceStatusLevels.available @@ -122,7 +131,6 @@ export function withServiceStatus( { level, summary: LEVEL_SUMMARY[level.toString()], - meta: monitoredHealth, }, ]; } From a459dcfae78af122f9930c0785d272aefd8adda5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 26 Apr 2021 10:32:51 -0400 Subject: [PATCH 26/37] [APM] Small bug in service overview instance details - need to hide "cloud" section when there is no cloud metadata (#98194) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../instance_details.test.tsx | 98 +++++++++++++++++++ .../intance_details.tsx | 2 +- .../shared/key_value_filter_list/index.tsx | 5 +- 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_details.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_details.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_details.test.tsx new file mode 100644 index 000000000000000..10919cf4a32aa8a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_details.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { + expectTextsInDocument, + expectTextsNotInDocument, + renderWithTheme, +} from '../../../../utils/testHelpers'; +import { InstanceDetails } from './intance_details'; +import * as useInstanceDetailsFetcher from './use_instance_details_fetcher'; + +type ServiceInstanceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; + +describe('InstanceDetails', () => { + it('renders loading spinner when data is being fetched', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ data: undefined, status: FETCH_STATUS.LOADING }); + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId('loadingSpinner')).toBeInTheDocument(); + }); + + it('renders all sections', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ + data: { + service: { node: { name: 'foo' } }, + container: { id: 'baz' }, + cloud: { provider: 'bar' }, + } as ServiceInstanceDetails, + status: FETCH_STATUS.SUCCESS, + }); + const component = renderWithTheme( + + ); + expectTextsInDocument(component, ['Service', 'Container', 'Cloud']); + }); + + it('hides service section', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ + data: { + container: { id: 'baz' }, + cloud: { provider: 'bar' }, + } as ServiceInstanceDetails, + status: FETCH_STATUS.SUCCESS, + }); + const component = renderWithTheme( + + ); + expectTextsInDocument(component, ['Container', 'Cloud']); + expectTextsNotInDocument(component, ['Service']); + }); + + it('hides container section', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ + data: { + service: { node: { name: 'foo' } }, + cloud: { provider: 'bar' }, + } as ServiceInstanceDetails, + status: FETCH_STATUS.SUCCESS, + }); + const component = renderWithTheme( + + ); + expectTextsInDocument(component, ['Service', 'Cloud']); + expectTextsNotInDocument(component, ['Container']); + }); + + it('hides cloud section', () => { + jest + .spyOn(useInstanceDetailsFetcher, 'useInstanceDetailsFetcher') + .mockReturnValue({ + data: { + service: { node: { name: 'foo' } }, + container: { id: 'baz' }, + } as ServiceInstanceDetails, + status: FETCH_STATUS.SUCCESS, + }); + const component = renderWithTheme( + + ); + expectTextsInDocument(component, ['Service', 'Container']); + expectTextsNotInDocument(component, ['Cloud']); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx index f50d02bb1545427..ba1da7e6dd6eb9c 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx @@ -82,7 +82,7 @@ export function InstanceDetails({ serviceName, serviceNodeName }: Props) { ) { return (
- +
); } diff --git a/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx b/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx index c836919a8a6abcd..54d8790c32d33f5 100644 --- a/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx @@ -65,7 +65,8 @@ export function KeyValueFilterList({ icon?: string; onClickFilter: (filter: { key: string; value: any }) => void; }) { - if (!keyValueList.length) { + const nonEmptyKeyValueList = removeEmptyValues(keyValueList); + if (!nonEmptyKeyValueList.length) { return null; } @@ -77,7 +78,7 @@ export function KeyValueFilterList({ buttonClassName="buttonContentContainer" > - {removeEmptyValues(keyValueList).map(({ key, value }) => { + {nonEmptyKeyValueList.map(({ key, value }) => { return ( Date: Mon, 26 Apr 2021 17:13:00 +0200 Subject: [PATCH 27/37] [ML] Check for the sort field type in Anomalies table before updating the URL state. (#98270) * [ML] check sort field type * [ML] functional tests for anomalies table pagination --- .../anomalies_table/anomalies_table.js | 5 ++- .../ml/anomaly_detection/anomaly_explorer.ts | 11 +++++++ .../functional/services/ml/anomalies_table.ts | 31 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js b/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js index ce78ff0f4862575..7b8b3bdcceb4bc4 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js @@ -152,7 +152,10 @@ export class AnomaliesTableInternal extends Component { const result = { pageIndex: page && page.index !== undefined ? page.index : tableState.pageIndex, pageSize: page && page.size !== undefined ? page.size : tableState.pageSize, - sortField: sort && sort.field !== undefined ? sort.field : tableState.sortField, + sortField: + sort && sort.field !== undefined && typeof sort.field === 'string' + ? sort.field + : tableState.sortField, sortDirection: sort && sort.direction !== undefined ? sort.direction : tableState.sortDirection, }; diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts index 66d45c801b81ad4..c713343b3e38041 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts @@ -324,6 +324,17 @@ export default function ({ getService }: FtrProviderContext) { await ml.anomalyExplorer.assertAnomalyExplorerChartsCount(0); }); + it('allows to change the anomalies table pagination', async () => { + await ml.testExecution.logTestStep('displays the anomalies table with default config'); + await ml.anomaliesTable.assertTableExists(); + await ml.anomaliesTable.assertRowsNumberPerPage(25); + await ml.anomaliesTable.assertTableRowsCount(25); + + await ml.testExecution.logTestStep('updates table pagination'); + await ml.anomaliesTable.setRowsNumberPerPage(10); + await ml.anomaliesTable.assertTableRowsCount(10); + }); + it('adds swim lane embeddable to a dashboard', async () => { // should be the last step because it navigates away from the Anomaly Explorer page await ml.testExecution.logTestStep( diff --git a/x-pack/test/functional/services/ml/anomalies_table.ts b/x-pack/test/functional/services/ml/anomalies_table.ts index 30bb3e67bc862d5..52dfaa1a70855d8 100644 --- a/x-pack/test/functional/services/ml/anomalies_table.ts +++ b/x-pack/test/functional/services/ml/anomalies_table.ts @@ -22,6 +22,10 @@ export function MachineLearningAnomaliesTableProvider({ getService }: FtrProvide return await testSubjects.findAll('mlAnomaliesTable > ~mlAnomaliesListRow'); }, + /** + * Asserts the number of rows rendered in a table + * @param expectedCount + */ async assertTableRowsCount(expectedCount: number) { const actualCount = (await this.getTableRows()).length; expect(actualCount).to.eql( @@ -118,5 +122,32 @@ export function MachineLearningAnomaliesTableProvider({ getService }: FtrProvide }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); }, + + /** + * Asserts selected number of rows per page on the pagination control. + * @param rowsNumber + */ + async assertRowsNumberPerPage(rowsNumber: 10 | 25 | 100) { + const textContent = await testSubjects.getVisibleText( + 'mlAnomaliesTable > tablePaginationPopoverButton' + ); + expect(textContent).to.be(`Rows per page: ${rowsNumber}`); + }, + + async ensurePagePopupOpen() { + await retry.tryForTime(5000, async () => { + const isOpen = await testSubjects.exists('tablePagination-10-rows'); + if (!isOpen) { + await testSubjects.click('mlAnomaliesTable > tablePaginationPopoverButton'); + await testSubjects.existOrFail('tablePagination-10-rows'); + } + }); + }, + + async setRowsNumberPerPage(rowsNumber: 10 | 25 | 100) { + await this.ensurePagePopupOpen(); + await testSubjects.click(`tablePagination-${rowsNumber}-rows`); + await this.assertRowsNumberPerPage(rowsNumber); + }, }; } From a2813dd5a02568c5abf5d9f792b6637da7c82261 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Mon, 26 Apr 2021 16:29:10 +0100 Subject: [PATCH 28/37] [ML] Improve check for runtime fields in datafeed query (#98289) --- .../new_job/common/job_creator/util/filter_runtime_mappings.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts index 5995224ef325489..21d8413f1a70400 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts @@ -106,6 +106,8 @@ function findFieldsInQuery(obj: object) { if (isPopulatedObject(val)) { fields.push(key); fields.push(...findFieldsInQuery(val)); + } else if (typeof val === 'string') { + fields.push(val); } else { fields.push(key); } From 3d8efce2403907ee3076e8a5cb46e30a7791d073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 26 Apr 2021 11:35:33 -0400 Subject: [PATCH 29/37] [APM] Bug: Fix header controls (type, search, comparison, and date picker) responsive breakpoints (#98179) * header responsive design * fixing ci * addressing comments --- .../components/shared/KueryBar/index.tsx | 27 +++---- .../public/components/shared/search_bar.tsx | 70 +++++++++++-------- .../shared/transaction_type_select.tsx | 6 +- .../apm/public/hooks/use_break_points.ts | 32 +++++---- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 6 files changed, 68 insertions(+), 69 deletions(-) diff --git a/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx b/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx index ff34359d83c7607..1b503e9b0528688 100644 --- a/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx @@ -9,25 +9,20 @@ import { i18n } from '@kbn/i18n'; import { startsWith, uniqueId } from 'lodash'; import React, { useState } from 'react'; import { useHistory, useLocation, useParams } from 'react-router-dom'; -import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { esKuery, IIndexPattern, QuerySuggestion, } from '../../../../../../../src/plugins/data/public'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { useDynamicIndexPatternFetcher } from '../../../hooks/use_dynamic_index_pattern'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useDynamicIndexPatternFetcher } from '../../../hooks/use_dynamic_index_pattern'; import { fromQuery, toQuery } from '../Links/url_helpers'; import { getBoolFilter } from './get_bool_filter'; // @ts-expect-error import { Typeahead } from './Typeahead'; import { useProcessorEvent } from './use_processor_event'; -const Container = euiStyled.div` - margin-bottom: 10px; -`; - interface State { suggestions: QuerySuggestion[]; isLoadingSuggestions: boolean; @@ -145,16 +140,14 @@ export function KueryBar(props: { prepend?: React.ReactNode | string }) { } return ( - - - + ); } diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index ed9a196bbcd9dd3..f0fc18cf266b9e5 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -5,21 +5,25 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; +import { + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiSpacer, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiCallOut } from '@elastic/eui'; -import { EuiLink } from '@elastic/eui'; -import { enableInspectEsQueries } from '../../../../observability/public'; +import React from 'react'; import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; -import { px, unit } from '../../style/variables'; +import { enableInspectEsQueries } from '../../../../observability/public'; +import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; +import { useKibanaUrl } from '../../hooks/useKibanaUrl'; +import { useBreakPoints } from '../../hooks/use_break_points'; +import { px } from '../../style/variables'; import { DatePicker } from './DatePicker'; import { KueryBar } from './KueryBar'; import { TimeComparison } from './time_comparison'; -import { useBreakPoints } from '../../hooks/use_break_points'; -import { useKibanaUrl } from '../../hooks/useKibanaUrl'; -import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; import { TransactionTypeSelect } from './transaction_type_select'; const EuiFlexGroupSpaced = euiStyled(EuiFlexGroup)` @@ -28,15 +32,10 @@ const EuiFlexGroupSpaced = euiStyled(EuiFlexGroup)` `; interface Props { - prepend?: React.ReactNode | string; showTimeComparison?: boolean; showTransactionTypeSelector?: boolean; } -function getRowDirection(showColumn: boolean) { - return showColumn ? 'column' : 'row'; -} - function DebugQueryCallout() { const { uiSettings } = useApmPluginContext().core; const advancedSettingsUrl = useKibanaUrl('/app/management/kibana/settings', { @@ -84,42 +83,53 @@ function DebugQueryCallout() { } export function SearchBar({ - prepend, showTimeComparison = false, showTransactionTypeSelector = false, }: Props) { - const { isMedium, isLarge } = useBreakPoints(); - const itemsStyle = { marginBottom: isLarge ? px(unit) : 0 }; - + const { isSmall, isMedium, isLarge, isXl, isXXL } = useBreakPoints(); return ( <> - - {showTransactionTypeSelector && ( - - - - )} + - + + {showTransactionTypeSelector && ( + + + + )} + + + + - + {showTimeComparison && ( - + )} - + + ); } diff --git a/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx index 772b42ed13577f3..dc071fe93bbbdcd 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx +++ b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx @@ -6,7 +6,6 @@ */ import { EuiSelect } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React, { FormEvent, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import styled from 'styled-components'; @@ -18,7 +17,7 @@ import * as urlHelpers from './Links/url_helpers'; // min-width on here to the width when "request" is loaded so it doesn't start // out collapsed and change its width when the list of transaction types is loaded. const EuiSelectWithWidth = styled(EuiSelect)` - min-width: 157px; + min-width: 200px; `; export function TransactionTypeSelect() { @@ -45,9 +44,6 @@ export function TransactionTypeSelect() { diff --git a/x-pack/plugins/apm/public/hooks/use_break_points.ts b/x-pack/plugins/apm/public/hooks/use_break_points.ts index 53e46cfe898ac75..fb8dc8f6a55b83a 100644 --- a/x-pack/plugins/apm/public/hooks/use_break_points.ts +++ b/x-pack/plugins/apm/public/hooks/use_break_points.ts @@ -10,26 +10,28 @@ import useWindowSize from 'react-use/lib/useWindowSize'; import useDebounce from 'react-use/lib/useDebounce'; import { isWithinMaxBreakpoint } from '@elastic/eui'; -export function useBreakPoints() { - const [screenSizes, setScreenSizes] = useState({ - isSmall: false, - isMedium: false, - isLarge: false, - isXl: false, - }); +function isMinXXL(windowWidth: number) { + return windowWidth >= 1600; +} + +function getScreenSizes(windowWidth: number) { + const isXXL = isMinXXL(windowWidth); + return { + isSmall: isWithinMaxBreakpoint(windowWidth, 's'), + isMedium: isWithinMaxBreakpoint(windowWidth, 'm'), + isLarge: isWithinMaxBreakpoint(windowWidth, 'l'), + isXl: isWithinMaxBreakpoint(windowWidth, 'xl') && !isXXL, + isXXL, + }; +} +export function useBreakPoints() { const { width } = useWindowSize(); + const [screenSizes, setScreenSizes] = useState(getScreenSizes(width)); useDebounce( () => { - const windowWidth = window.innerWidth; - - setScreenSizes({ - isSmall: isWithinMaxBreakpoint(windowWidth, 's'), - isMedium: isWithinMaxBreakpoint(windowWidth, 'm'), - isLarge: isWithinMaxBreakpoint(windowWidth, 'l'), - isXl: isWithinMaxBreakpoint(windowWidth, 'xl'), - }); + setScreenSizes(getScreenSizes(width)); }, 50, [width] diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b66fa50ae168aa7..23235182c9978c2 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5653,7 +5653,6 @@ "xpack.apm.transactionsTable.nameColumnLabel": "名前", "xpack.apm.transactionsTable.notFoundLabel": "トランザクションが見つかりませんでした。", "xpack.apm.transactionsTable.throughputColumnLabel": "スループット", - "xpack.apm.transactionTypeSelectLabel": "型", "xpack.apm.tutorial.apmServer.title": "APM Server", "xpack.apm.tutorial.elasticCloud.textPre": "APM Server を有効にするには、[the Elastic Cloud console] (https://cloud.elastic.co/deployments?q={cloudId}) に移動し、展開設定で APM を有効にします。有効になったら、このページを更新してください。", "xpack.apm.tutorial.elasticCloudInstructions.title": "APM エージェント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5fc97e979208316..a32e206e8ef5c5e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5692,7 +5692,6 @@ "xpack.apm.transactionsTable.nameColumnLabel": "名称", "xpack.apm.transactionsTable.notFoundLabel": "未找到任何事务。", "xpack.apm.transactionsTable.throughputColumnLabel": "吞吐量", - "xpack.apm.transactionTypeSelectLabel": "类型", "xpack.apm.tutorial.apmServer.title": "APM Server", "xpack.apm.tutorial.elasticCloud.textPre": "要启用 APM Server,请前往 [Elastic Cloud 控制台](https://cloud.elastic.co/deployments?q={cloudId}) 并在部署设置中启用 APM。启用后,请刷新此页面。", "xpack.apm.tutorial.elasticCloudInstructions.title": "APM 代理", From 7a5b8a51ef3bf16c1494b1cc34bd01590477f3c3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Apr 2021 08:48:54 -0700 Subject: [PATCH 30/37] Update dependency @elastic/charts to v29.1.0 (#98242) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 6af5c256c57fa7c..ef9a82152f987f1 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "dependencies": { "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "29.0.0", + "@elastic/charts": "29.1.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath/npm_module", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.13.0", diff --git a/yarn.lock b/yarn.lock index 1c33d64afbec05f..3ed199a6a3c4f7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1359,10 +1359,10 @@ dependencies: object-hash "^1.3.0" -"@elastic/charts@29.0.0": - version "29.0.0" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-29.0.0.tgz#6f4ea5bba2caab9700e900fc0bb72685306d1184" - integrity sha512-df8fYiwOWzO7boIBXMsiWY9oHw5//WZJ2MogJ/38pZeDMRHwjIvQCzj1NL641ijFlFBfWwPSmPur9vbF5xTjbg== +"@elastic/charts@29.1.0": + version "29.1.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-29.1.0.tgz#2850aa30d5e00aa8a1ab4974ea36f3c960a8e457" + integrity sha512-/nHT8niLtvSwX3dyEeIQWXEEZrB3xgjLIdlnqZhQXEdHqDQnxlehOMsTqWWws7jS/5uRq/sg+8N2z1xEb+odDw== dependencies: "@popperjs/core" "^2.4.0" chroma-js "^2.1.0" From fec07b766fd21185847cc9033a383f258e7b988b Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Mon, 26 Apr 2021 10:49:29 -0500 Subject: [PATCH 31/37] [Workplace Search] PR#3362 to Kibana (#98207) * Add StatusItem component * Replace tooltip with new popover * Consistant spacing Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/shared/status_item/index.ts | 8 +++ .../shared/status_item/status_item.test.tsx | 43 ++++++++++++ .../shared/status_item/status_item.tsx | 67 +++++++++++++++++++ .../workplace_search/constants.ts | 11 +++ .../content_sources/components/overview.tsx | 12 +--- 5 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/index.ts new file mode 100644 index 000000000000000..e6caa5c3a764260 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { StatusItem } from './status_item'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.test.tsx new file mode 100644 index 000000000000000..c1c18b51f9fd3a3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiPopover, EuiCopy, EuiButton, EuiButtonIcon } from '@elastic/eui'; + +import { StatusItem } from './'; + +describe('SourceRow', () => { + const details = ['foo', 'bar']; + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiPopover)).toHaveLength(1); + }); + + it('renders the copy button', () => { + const copyMock = jest.fn(); + const wrapper = shallow(); + const copyEl = shallow(
{wrapper.find(EuiCopy).props().children(copyMock)}
); + + expect(copyEl.find(EuiButton).props().onClick).toEqual(copyMock); + }); + + it('handles popover visibility toggle click', () => { + const wrapper = shallow(); + const button = wrapper.find(EuiPopover).dive().find(EuiButtonIcon); + button.simulate('click'); + + expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(true); + + wrapper.find(EuiPopover).prop('closePopover')(); + + expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.tsx new file mode 100644 index 000000000000000..79455ccc1d90d3e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/status_item/status_item.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; + +import { + EuiCopy, + EuiButton, + EuiButtonIcon, + EuiToolTip, + EuiSpacer, + EuiCodeBlock, + EuiPopover, +} from '@elastic/eui'; + +import { COPY_TEXT, STATUS_POPOVER_TOOLTIP } from '../../../constants'; + +interface StatusItemProps { + details: string[]; +} + +export const StatusItem: React.FC = ({ details }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen); + const closePopover = () => setIsPopoverOpen(false); + const formattedDetails = details.join('\n'); + + const tooltipPopoverTrigger = ( + + + + ); + + const infoPopover = ( + + + {formattedDetails} + + + + {(copy) => ( + + {COPY_TEXT} + + )} + + + ); + + return infoPopover; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 9f758cacdfce355..dcebc35d45f7111 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -751,3 +751,14 @@ export const REMOVE_BUTTON = i18n.translate( defaultMessage: 'Remove', } ); + +export const COPY_TEXT = i18n.translate('xpack.enterpriseSearch.workplaceSearch.copyText', { + defaultMessage: 'Copy', +}); + +export const STATUS_POPOVER_TOOLTIP = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.statusPopoverTooltip', + { + defaultMessage: 'Click to view info', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index 86c911e7e0b00a2..153df1bc00496a8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -14,7 +14,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, - EuiIconTip, EuiLink, EuiPanel, EuiSpacer, @@ -37,6 +36,7 @@ import aclImage from '../../../assets/supports_acl.svg'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { CredentialItem } from '../../../components/shared/credential_item'; import { LicenseBadge } from '../../../components/shared/license_badge'; +import { StatusItem } from '../../../components/shared/status_item'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; import { RECENT_ACTIVITY_TITLE, @@ -199,15 +199,7 @@ export const Overview: React.FC = () => { {!custom && ( - {status}{' '} - {activityDetails && ( - ( -
{detail}
- ))} - /> - )} + {status} {activityDetails && }
)} From f0a3244f5455313841d52261d657b39ac7f84b2e Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Mon, 26 Apr 2021 10:52:27 -0500 Subject: [PATCH 32/37] [Workplace Search] Redirect to correct route for form created sources (#98215) * [Workplace Search] Redirect to correct route for form created sources * Remove unnecessary passing of query params This is no longer needed with the new route Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/add_source/add_source.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index 8186c43efef494e..ee4bcfb9afd3418 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -6,17 +6,17 @@ */ import React, { useEffect } from 'react'; -import { useLocation } from 'react-router-dom'; - -import { Location } from 'history'; import { useActions, useValues } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { setSuccessMessage } from '../../../../../shared/flash_messages'; import { KibanaLogic } from '../../../../../shared/kibana'; import { Loading } from '../../../../../shared/loading'; import { AppLogic } from '../../../../app_logic'; import { CUSTOM_SERVICE_TYPE } from '../../../../constants'; -import { SOURCE_ADDED_PATH, getSourcesPath } from '../../../../routes'; +import { SOURCES_PATH, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; import { staticSourceData } from '../../source_data'; @@ -34,7 +34,6 @@ import { SaveCustom } from './save_custom'; import './add_source.scss'; export const AddSource: React.FC = (props) => { - const { search } = useLocation() as Location; const { initializeAddSource, setAddSourceStep, @@ -78,6 +77,13 @@ export const AddSource: React.FC = (props) => { const goToSaveConfig = () => setAddSourceStep(AddSourceSteps.SaveConfigStep); const setConfigCompletedStep = () => setAddSourceStep(AddSourceSteps.ConfigCompletedStep); const goToConfigCompleted = () => saveSourceConfig(false, setConfigCompletedStep); + const FORM_SOURCE_ADDED_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.formSourceAddedSuccessMessage', + { + defaultMessage: '{name} connected', + values: { name }, + } + ); const goToConnectInstance = () => { setAddSourceStep(AddSourceSteps.ConnectInstanceStep); @@ -88,9 +94,8 @@ export const AddSource: React.FC = (props) => { const goToSaveCustom = () => createContentSource(CUSTOM_SERVICE_TYPE, saveCustomSuccess); const goToFormSourceCreated = () => { - KibanaLogic.values.navigateToUrl( - `${getSourcesPath(SOURCE_ADDED_PATH, isOrganization)}${search}` - ); + KibanaLogic.values.navigateToUrl(`${getSourcesPath(SOURCES_PATH, isOrganization)}`); + setSuccessMessage(FORM_SOURCE_ADDED_SUCCESS_MESSAGE); }; const header = ; From 48523e5066fae3bd769a064ea78a08a9e545331e Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Mon, 26 Apr 2021 17:59:14 +0200 Subject: [PATCH 33/37] Add support for /api/status before Kibana completes startup (#79012) Co-authored-by: Larry Gregory Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...omhttpresponseoptions.bypasserrorformat.md | 13 +++ ...n-core-server.customhttpresponseoptions.md | 1 + ...r.httpresponseoptions.bypasserrorformat.md | 13 +++ ...-plugin-core-server.httpresponseoptions.md | 1 + .../functional_tests/lib/run_kibana_server.js | 2 +- .../src/kbn_client/kbn_client_requester.ts | 9 ++ .../src/kbn_client/kbn_client_status.ts | 2 + src/core/server/http/http_server.test.ts | 34 ++++++ src/core/server/http/http_server.ts | 102 +++++++++++------- src/core/server/http/http_service.test.ts | 31 +++++- src/core/server/http/http_service.ts | 63 ++++++++--- .../http/integration_tests/router.test.ts | 56 ++++++++++ src/core/server/http/router/index.ts | 8 +- src/core/server/http/router/response.ts | 6 +- .../server/http/router/response_adapter.ts | 3 + src/core/server/http/router/router.ts | 3 +- src/core/server/http/types.ts | 7 ++ .../migrations/core/index_migrator.test.ts | 1 + .../migrations/core/index_migrator.ts | 2 + .../migrations/core/migration_context.ts | 6 +- .../core/migration_coordinator.test.ts | 6 ++ .../migrations/core/migration_coordinator.ts | 18 +++- .../migrations/kibana/kibana_migrator.ts | 4 +- src/core/server/saved_objects/status.ts | 11 +- src/core/server/server.api.md | 4 +- src/core/server/status/legacy_status.ts | 2 +- src/core/server/status/routes/status.ts | 5 +- src/core/server/status/status_service.ts | 23 +++- .../apis/saved_objects/migrations.ts | 1 + 29 files changed, 363 insertions(+), 74 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md diff --git a/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md new file mode 100644 index 000000000000000..bbd97ab517d2967 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CustomHttpResponseOptions](./kibana-plugin-core-server.customhttpresponseoptions.md) > [bypassErrorFormat](./kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md) + +## CustomHttpResponseOptions.bypassErrorFormat property + +Bypass the default error formatting + +Signature: + +```typescript +bypassErrorFormat?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md index 67242bbd4e2efbd..82089c831d718d4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md @@ -17,6 +17,7 @@ export interface CustomHttpResponseOptionsT | HTTP message to send to the client | +| [bypassErrorFormat](./kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md) | boolean | Bypass the default error formatting | | [headers](./kibana-plugin-core-server.customhttpresponseoptions.headers.md) | ResponseHeaders | HTTP Headers with additional information about response | | [statusCode](./kibana-plugin-core-server.customhttpresponseoptions.statuscode.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md new file mode 100644 index 000000000000000..98792c47d564f0e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResponseOptions](./kibana-plugin-core-server.httpresponseoptions.md) > [bypassErrorFormat](./kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md) + +## HttpResponseOptions.bypassErrorFormat property + +Bypass the default error formatting + +Signature: + +```typescript +bypassErrorFormat?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md index 9f31e86175f7988..497adc6a5ec5d1d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md @@ -17,5 +17,6 @@ export interface HttpResponseOptions | Property | Type | Description | | --- | --- | --- | | [body](./kibana-plugin-core-server.httpresponseoptions.body.md) | HttpResponsePayload | HTTP message to send to the client | +| [bypassErrorFormat](./kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md) | boolean | Bypass the default error formatting | | [headers](./kibana-plugin-core-server.httpresponseoptions.headers.md) | ResponseHeaders | HTTP Headers with additional information about response | diff --git a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js index a43d3a09c7d70b4..f92d01d6454d503 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js +++ b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js @@ -38,7 +38,7 @@ export async function runKibanaServer({ procs, config, options }) { ...extendNodeOptions(installDir), }, cwd: installDir || KIBANA_ROOT, - wait: /http server running/, + wait: /\[Kibana\]\[http\] http server running/, }); } diff --git a/packages/kbn-test/src/kbn_client/kbn_client_requester.ts b/packages/kbn-test/src/kbn_client/kbn_client_requester.ts index 31cd3a689956899..af75137d148e97c 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_requester.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_requester.ts @@ -19,6 +19,10 @@ const isConcliftOnGetError = (error: any) => { ); }; +const isIgnorableError = (error: any, ignorableErrors: number[] = []) => { + return isAxiosResponseError(error) && ignorableErrors.includes(error.response.status); +}; + export const uriencode = ( strings: TemplateStringsArray, ...values: Array @@ -53,6 +57,7 @@ export interface ReqOptions { body?: any; retries?: number; headers?: Record; + ignoreErrors?: number[]; responseType?: ResponseType; } @@ -125,6 +130,10 @@ export class KbnClientRequester { const requestedRetries = options.retries !== undefined; const failedToGetResponse = isAxiosRequestError(error); + if (isIgnorableError(error, options.ignoreErrors)) { + return error.response; + } + let errorMessage; if (conflictOnGet) { errorMessage = `Conflict on GET (path=${options.path}, attempt=${attempt}/${maxAttempts})`; diff --git a/packages/kbn-test/src/kbn_client/kbn_client_status.ts b/packages/kbn-test/src/kbn_client/kbn_client_status.ts index 7e14e58309fa2fe..26c46917ae8dd8b 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_status.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_status.ts @@ -44,6 +44,8 @@ export class KbnClientStatus { const { data } = await this.requester.request({ method: 'GET', path: 'api/status', + // Status endpoint returns 503 if any services are in an unavailable state + ignoreErrors: [503], }); return data; } diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 1a82907849cea08..7624a11a6f03fae 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -138,6 +138,40 @@ test('log listening address after started when configured with BasePath and rewr `); }); +test('does not allow router registration after server is listening', async () => { + expect(server.isListening()).toBe(false); + + const { registerRouter } = await server.setup(config); + + const router1 = new Router('/foo', logger, enhanceWithContext); + expect(() => registerRouter(router1)).not.toThrowError(); + + await server.start(); + + expect(server.isListening()).toBe(true); + + const router2 = new Router('/bar', logger, enhanceWithContext); + expect(() => registerRouter(router2)).toThrowErrorMatchingInlineSnapshot( + `"Routers can be registered only when HTTP server is stopped."` + ); +}); + +test('allows router registration after server is listening via `registerRouterAfterListening`', async () => { + expect(server.isListening()).toBe(false); + + const { registerRouterAfterListening } = await server.setup(config); + + const router1 = new Router('/foo', logger, enhanceWithContext); + expect(() => registerRouterAfterListening(router1)).not.toThrowError(); + + await server.start(); + + expect(server.isListening()).toBe(true); + + const router2 = new Router('/bar', logger, enhanceWithContext); + expect(() => registerRouterAfterListening(router2)).not.toThrowError(); +}); + test('valid params', async () => { const router = new Router('/foo', logger, enhanceWithContext); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index d845ac1b639b661..8b4c3b9416152f1 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -33,6 +33,7 @@ import { KibanaRouteOptions, KibanaRequestState, isSafeMethod, + RouterRoute, } from './router'; import { SessionStorageCookieOptions, @@ -52,6 +53,13 @@ export interface HttpServerSetup { * @param router {@link IRouter} - a router with registered route handlers. */ registerRouter: (router: IRouter) => void; + /** + * Add all the routes registered with `router` to HTTP server request listeners. + * Unlike `registerRouter`, this function allows routes to be registered even after the server + * has started listening for requests. + * @param router {@link IRouter} - a router with registered route handlers. + */ + registerRouterAfterListening: (router: IRouter) => void; registerStaticDir: (path: string, dirPath: string) => void; basePath: HttpServiceSetup['basePath']; csp: HttpServiceSetup['csp']; @@ -114,6 +122,17 @@ export class HttpServer { this.registeredRouters.add(router); } + private registerRouterAfterListening(router: IRouter) { + if (this.isListening()) { + for (const route of router.getRoutes()) { + this.configureRoute(route); + } + } else { + // Not listening yet, add to set of registeredRouters so that it can be added after listening has started. + this.registeredRouters.add(router); + } + } + public async setup(config: HttpConfig): Promise { const serverOptions = getServerOptions(config); const listenerOptions = getListenerOptions(config); @@ -130,6 +149,7 @@ export class HttpServer { return { registerRouter: this.registerRouter.bind(this), + registerRouterAfterListening: this.registerRouterAfterListening.bind(this), registerStaticDir: this.registerStaticDir.bind(this), registerOnPreRouting: this.registerOnPreRouting.bind(this), registerOnPreAuth: this.registerOnPreAuth.bind(this), @@ -170,45 +190,7 @@ export class HttpServer { for (const router of this.registeredRouters) { for (const route of router.getRoutes()) { - this.log.debug(`registering route handler for [${route.path}]`); - // Hapi does not allow payload validation to be specified for 'head' or 'get' requests - const validate = isSafeMethod(route.method) ? undefined : { payload: true }; - const { authRequired, tags, body = {}, timeout } = route.options; - const { accepts: allow, maxBytes, output, parse } = body; - - const kibanaRouteOptions: KibanaRouteOptions = { - xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), - }; - - this.server.route({ - handler: route.handler, - method: route.method, - path: route.path, - options: { - auth: this.getAuthOption(authRequired), - app: kibanaRouteOptions, - tags: tags ? Array.from(tags) : undefined, - // TODO: This 'validate' section can be removed once the legacy platform is completely removed. - // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default - // validation applied in ./http_tools#getServerOptions - // (All NP routes are already required to specify their own validation in order to access the payload) - validate, - // @ts-expect-error Types are outdated and doesn't allow `payload.multipart` to be `true` - payload: [allow, maxBytes, output, parse, timeout?.payload].some((x) => x !== undefined) - ? { - allow, - maxBytes, - output, - parse, - timeout: timeout?.payload, - multipart: true, - } - : undefined, - timeout: { - socket: timeout?.idleSocket ?? this.config!.socketTimeout, - }, - }, - }); + this.configureRoute(route); } } @@ -486,4 +468,46 @@ export class HttpServer { options: { auth: false }, }); } + + private configureRoute(route: RouterRoute) { + this.log.debug(`registering route handler for [${route.path}]`); + // Hapi does not allow payload validation to be specified for 'head' or 'get' requests + const validate = isSafeMethod(route.method) ? undefined : { payload: true }; + const { authRequired, tags, body = {}, timeout } = route.options; + const { accepts: allow, maxBytes, output, parse } = body; + + const kibanaRouteOptions: KibanaRouteOptions = { + xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), + }; + + this.server!.route({ + handler: route.handler, + method: route.method, + path: route.path, + options: { + auth: this.getAuthOption(authRequired), + app: kibanaRouteOptions, + tags: tags ? Array.from(tags) : undefined, + // TODO: This 'validate' section can be removed once the legacy platform is completely removed. + // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default + // validation applied in ./http_tools#getServerOptions + // (All NP routes are already required to specify their own validation in order to access the payload) + validate, + // @ts-expect-error Types are outdated and doesn't allow `payload.multipart` to be `true` + payload: [allow, maxBytes, output, parse, timeout?.payload].some((x) => x !== undefined) + ? { + allow, + maxBytes, + output, + parse, + timeout: timeout?.payload, + multipart: true, + } + : undefined, + timeout: { + socket: timeout?.idleSocket ?? this.config!.socketTimeout, + }, + }, + }); + } } diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 83279e99bc47613..ebb9ad971b84843 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -68,20 +68,32 @@ test('creates and sets up http server', async () => { start: jest.fn(), stop: jest.fn(), }; - mockHttpServer.mockImplementation(() => httpServer); + const notReadyHttpServer = { + isListening: () => false, + setup: jest.fn().mockReturnValue({ server: fakeHapiServer }), + start: jest.fn(), + stop: jest.fn(), + }; + mockHttpServer.mockImplementationOnce(() => httpServer); + mockHttpServer.mockImplementationOnce(() => notReadyHttpServer); const service = new HttpService({ coreId, configService, env, logger }); expect(mockHttpServer.mock.instances.length).toBe(1); expect(httpServer.setup).not.toHaveBeenCalled(); + expect(notReadyHttpServer.setup).not.toHaveBeenCalled(); await service.setup(setupDeps); expect(httpServer.setup).toHaveBeenCalled(); expect(httpServer.start).not.toHaveBeenCalled(); + expect(notReadyHttpServer.setup).toHaveBeenCalled(); + expect(notReadyHttpServer.start).toHaveBeenCalled(); + await service.start(); expect(httpServer.start).toHaveBeenCalled(); + expect(notReadyHttpServer.stop).toHaveBeenCalled(); }); test('spins up notReady server until started if configured with `autoListen:true`', async () => { @@ -102,6 +114,8 @@ test('spins up notReady server until started if configured with `autoListen:true .mockImplementationOnce(() => httpServer) .mockImplementationOnce(() => ({ setup: () => ({ server: notReadyHapiServer }), + start: jest.fn(), + stop: jest.fn().mockImplementation(() => notReadyHapiServer.stop()), })); const service = new HttpService({ @@ -163,7 +177,14 @@ test('stops http server', async () => { start: noop, stop: jest.fn(), }; - mockHttpServer.mockImplementation(() => httpServer); + const notReadyHttpServer = { + isListening: () => false, + setup: jest.fn().mockReturnValue({ server: fakeHapiServer }), + start: noop, + stop: jest.fn(), + }; + mockHttpServer.mockImplementationOnce(() => httpServer); + mockHttpServer.mockImplementationOnce(() => notReadyHttpServer); const service = new HttpService({ coreId, configService, env, logger }); @@ -171,6 +192,7 @@ test('stops http server', async () => { await service.start(); expect(httpServer.stop).toHaveBeenCalledTimes(0); + expect(notReadyHttpServer.stop).toHaveBeenCalledTimes(1); await service.stop(); @@ -188,7 +210,7 @@ test('stops not ready server if it is running', async () => { isListening: () => false, setup: jest.fn().mockReturnValue({ server: mockHapiServer }), start: noop, - stop: jest.fn(), + stop: jest.fn().mockImplementation(() => mockHapiServer.stop()), }; mockHttpServer.mockImplementation(() => httpServer); @@ -198,7 +220,7 @@ test('stops not ready server if it is running', async () => { await service.stop(); - expect(mockHapiServer.stop).toHaveBeenCalledTimes(1); + expect(mockHapiServer.stop).toHaveBeenCalledTimes(2); }); test('register route handler', async () => { @@ -231,6 +253,7 @@ test('returns http server contract on setup', async () => { mockHttpServer.mockImplementation(() => ({ isListening: () => false, setup: jest.fn().mockReturnValue(httpServer), + start: noop, stop: noop, })); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index fdf9b738a983352..0d28506607682ea 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -8,7 +8,6 @@ import { Observable, Subscription, combineLatest, of } from 'rxjs'; import { first, map } from 'rxjs/operators'; -import { Server } from '@hapi/hapi'; import { pick } from '@kbn/std'; import type { RequestHandlerContext } from 'src/core/server'; @@ -20,7 +19,7 @@ import { CoreContext } from '../core_context'; import { PluginOpaqueId } from '../plugins'; import { CspConfigType, config as cspConfig } from '../csp'; -import { Router } from './router'; +import { IRouter, Router } from './router'; import { HttpConfig, HttpConfigType, config as httpConfig } from './http_config'; import { HttpServer } from './http_server'; import { HttpsRedirectServer } from './https_redirect_server'; @@ -30,6 +29,7 @@ import { RequestHandlerContextProvider, InternalHttpServiceSetup, InternalHttpServiceStart, + InternalNotReadyHttpServiceSetup, } from './types'; import { registerCoreHandlers } from './lifecycle_handlers'; @@ -54,7 +54,7 @@ export class HttpService private readonly logger: LoggerFactory; private readonly log: Logger; private readonly env: Env; - private notReadyServer?: Server; + private notReadyServer?: HttpServer; private internalSetup?: InternalHttpServiceSetup; private requestHandlerContext?: RequestHandlerContextContainer; @@ -88,9 +88,7 @@ export class HttpService const config = await this.config$.pipe(first()).toPromise(); - if (this.shouldListen(config)) { - await this.runNotReadyServer(config); - } + const notReadyServer = await this.setupNotReadyService({ config, context: deps.context }); const { registerRouter, ...serverContract } = await this.httpServer.setup(config); @@ -99,6 +97,8 @@ export class HttpService this.internalSetup = { ...serverContract, + notReadyServer, + externalUrl: new ExternalUrlConfig(config.externalUrl), createRouter: ( @@ -178,14 +178,51 @@ export class HttpService await this.httpsRedirectServer.stop(); } + private async setupNotReadyService({ + config, + context, + }: { + config: HttpConfig; + context: ContextSetup; + }): Promise { + if (!this.shouldListen(config)) { + return; + } + + const notReadySetup = await this.runNotReadyServer(config); + + // We cannot use the real context container since the core services may not yet be ready + const fakeContext: RequestHandlerContextContainer = new Proxy( + context.createContextContainer(), + { + get: (target, property, receiver) => { + if (property === 'createHandler') { + return Reflect.get(target, property, receiver); + } + throw new Error(`Unexpected access from fake context: ${String(property)}`); + }, + } + ); + + return { + registerRoutes: (path: string, registerCallback: (router: IRouter) => void) => { + const router = new Router( + path, + this.log, + fakeContext.createHandler.bind(null, this.coreContext.coreId) + ); + + registerCallback(router); + notReadySetup.registerRouterAfterListening(router); + }, + }; + } + private async runNotReadyServer(config: HttpConfig) { this.log.debug('starting NotReady server'); - const httpServer = new HttpServer(this.logger, 'NotReady', of(config.shutdownTimeout)); - const { server } = await httpServer.setup(config); - this.notReadyServer = server; - // use hapi server while KibanaResponseFactory doesn't allow specifying custom headers - // https://github.com/elastic/kibana/issues/33779 - this.notReadyServer.route({ + this.notReadyServer = new HttpServer(this.logger, 'NotReady', of(config.shutdownTimeout)); + const notReadySetup = await this.notReadyServer.setup(config); + notReadySetup.server.route({ path: '/{p*}', method: '*', handler: (req, responseToolkit) => { @@ -201,5 +238,7 @@ export class HttpService }, }); await this.notReadyServer.start(); + + return notReadySetup; } } diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index 5b297ab44f8bbe2..354ab1c65d5651c 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -15,6 +15,8 @@ import { contextServiceMock } from '../../context/context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; import { HttpService } from '../http_service'; +import { Router } from '../router'; +import { loggerMock } from '@kbn/logging/target/mocks'; let server: HttpService; let logger: ReturnType; @@ -1836,3 +1838,57 @@ describe('ETag', () => { .expect(304, ''); }); }); + +describe('registerRouterAfterListening', () => { + it('allows a router to be registered before server has started listening', async () => { + const { server: innerServer, createRouter, registerRouterAfterListening } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello' }); + }); + + const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); + + const otherRouter = new Router('/test', loggerMock.create(), enhanceWithContext); + otherRouter.get({ path: '/afterListening', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello from other router' }); + }); + + registerRouterAfterListening(otherRouter); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + await supertest(innerServer.listener).get('/test/afterListening').expect(200); + }); + + it('allows a router to be registered after server has started listening', async () => { + const { server: innerServer, createRouter, registerRouterAfterListening } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello' }); + }); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + await supertest(innerServer.listener).get('/test/afterListening').expect(404); + + const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); + + const otherRouter = new Router('/test', loggerMock.create(), enhanceWithContext); + otherRouter.get({ path: '/afterListening', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello from other router' }); + }); + + registerRouterAfterListening(otherRouter); + + await supertest(innerServer.listener).get('/test/afterListening').expect(200); + }); +}); diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts index a958d330bf24d38..5ba8143936563f8 100644 --- a/src/core/server/http/router/index.ts +++ b/src/core/server/http/router/index.ts @@ -9,7 +9,13 @@ export { filterHeaders } from './headers'; export type { Headers, ResponseHeaders, KnownHeaders } from './headers'; export { Router } from './router'; -export type { RequestHandler, RequestHandlerWrapper, IRouter, RouteRegistrar } from './router'; +export type { + RequestHandler, + RequestHandlerWrapper, + IRouter, + RouteRegistrar, + RouterRoute, +} from './router'; export { isKibanaRequest, isRealRequest, ensureRawRequest, KibanaRequest } from './request'; export type { KibanaRequestEvents, diff --git a/src/core/server/http/router/response.ts b/src/core/server/http/router/response.ts index e2babf719f67e08..6cea7fcf4c94972 100644 --- a/src/core/server/http/router/response.ts +++ b/src/core/server/http/router/response.ts @@ -62,6 +62,8 @@ export interface HttpResponseOptions { body?: HttpResponsePayload; /** HTTP Headers with additional information about response */ headers?: ResponseHeaders; + /** Bypass the default error formatting */ + bypassErrorFormat?: boolean; } /** @@ -79,6 +81,8 @@ export interface CustomHttpResponseOptions; diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index f007a77a2a21a27..bbd296d6b1831a7 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -277,6 +277,11 @@ export interface HttpServiceSetup { getServerInfo: () => HttpServerInfo; } +/** @internal */ +export interface InternalNotReadyHttpServiceSetup { + registerRoutes(path: string, callback: (router: IRouter) => void): void; +} + /** @internal */ export interface InternalHttpServiceSetup extends Omit { @@ -287,6 +292,7 @@ export interface InternalHttpServiceSetup path: string, plugin?: PluginOpaqueId ) => IRouter; + registerRouterAfterListening: (router: IRouter) => void; registerStaticDir: (path: string, dirPath: string) => void; getAuthHeaders: GetAuthHeaders; registerRouteHandlerContext: < @@ -297,6 +303,7 @@ export interface InternalHttpServiceSetup contextName: ContextName, provider: RequestHandlerContextProvider ) => RequestHandlerContextContainer; + notReadyServer?: InternalNotReadyHttpServiceSetup; } /** @public */ diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index dd295efacf6b862..fcc03f363139b03 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -27,6 +27,7 @@ describe('IndexMigrator', () => { index: '.kibana', kibanaVersion: '7.10.0', log: loggingSystemMock.create().get(), + setStatus: jest.fn(), mappingProperties: {}, pollInterval: 1, scrollDuration: '1m', diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index 472fb4f8d1a397b..14dba1db9b624af 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -41,6 +41,8 @@ export class IndexMigrator { pollInterval: context.pollInterval, + setStatus: context.setStatus, + async isMigrated() { return !(await requiresMigration(context)); }, diff --git a/src/core/server/saved_objects/migrations/core/migration_context.ts b/src/core/server/saved_objects/migrations/core/migration_context.ts index 441c7efed049f28..d7f7aff45a47057 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.ts @@ -25,6 +25,7 @@ import { buildActiveMappings } from './build_active_mappings'; import { VersionedTransformer } from './document_migrator'; import * as Index from './elastic_index'; import { SavedObjectsMigrationLogger, MigrationLogger } from './migration_logger'; +import { KibanaMigratorStatus } from '../kibana'; export interface MigrationOpts { batchSize: number; @@ -34,6 +35,7 @@ export interface MigrationOpts { index: string; kibanaVersion: string; log: Logger; + setStatus: (status: KibanaMigratorStatus) => void; mappingProperties: SavedObjectsTypeMappingDefinitions; documentMigrator: VersionedTransformer; serializer: SavedObjectsSerializer; @@ -57,6 +59,7 @@ export interface Context { documentMigrator: VersionedTransformer; kibanaVersion: string; log: SavedObjectsMigrationLogger; + setStatus: (status: KibanaMigratorStatus) => void; batchSize: number; pollInterval: number; scrollDuration: string; @@ -70,7 +73,7 @@ export interface Context { * and various info needed to migrate the source index. */ export async function migrationContext(opts: MigrationOpts): Promise { - const { log, client } = opts; + const { log, client, setStatus } = opts; const alias = opts.index; const source = createSourceContext(await Index.fetchInfo(client, alias), alias); const dest = createDestContext(source, alias, opts.mappingProperties); @@ -82,6 +85,7 @@ export async function migrationContext(opts: MigrationOpts): Promise { dest, kibanaVersion: opts.kibanaVersion, log: new MigrationLogger(log), + setStatus, batchSize: opts.batchSize, documentMigrator: opts.documentMigrator, pollInterval: opts.pollInterval, diff --git a/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts b/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts index 9a045d0fbf7f983..63476a15d77cdee 100644 --- a/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts +++ b/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts @@ -19,6 +19,7 @@ describe('coordinateMigration', () => { throw { body: { error: { index: '.foo', type: 'resource_already_exists_exception' } } }; }); const isMigrated = jest.fn(); + const setStatus = jest.fn(); isMigrated.mockResolvedValueOnce(false).mockResolvedValueOnce(true); @@ -27,6 +28,7 @@ describe('coordinateMigration', () => { runMigration, pollInterval, isMigrated, + setStatus, }); expect(runMigration).toHaveBeenCalledTimes(1); @@ -39,12 +41,14 @@ describe('coordinateMigration', () => { const pollInterval = 1; const runMigration = jest.fn(() => Promise.resolve()); const isMigrated = jest.fn(() => Promise.resolve(true)); + const setStatus = jest.fn(); await coordinateMigration({ log, runMigration, pollInterval, isMigrated, + setStatus, }); expect(isMigrated).not.toHaveBeenCalled(); }); @@ -55,6 +59,7 @@ describe('coordinateMigration', () => { throw new Error('Doh'); }); const isMigrated = jest.fn(() => Promise.resolve(true)); + const setStatus = jest.fn(); await expect( coordinateMigration({ @@ -62,6 +67,7 @@ describe('coordinateMigration', () => { runMigration, pollInterval, isMigrated, + setStatus, }) ).rejects.toThrow(/Doh/); expect(isMigrated).not.toHaveBeenCalled(); diff --git a/src/core/server/saved_objects/migrations/core/migration_coordinator.ts b/src/core/server/saved_objects/migrations/core/migration_coordinator.ts index 3e66d37ce6964cb..5b99f050b0eceac 100644 --- a/src/core/server/saved_objects/migrations/core/migration_coordinator.ts +++ b/src/core/server/saved_objects/migrations/core/migration_coordinator.ts @@ -24,11 +24,16 @@ */ import _ from 'lodash'; +import { KibanaMigratorStatus } from '../kibana'; import { SavedObjectsMigrationLogger } from './migration_logger'; const DEFAULT_POLL_INTERVAL = 15000; -export type MigrationStatus = 'waiting' | 'running' | 'completed'; +export type MigrationStatus = + | 'waiting_to_start' + | 'waiting_for_other_nodes' + | 'running' + | 'completed'; export type MigrationResult = | { status: 'skipped' } @@ -43,6 +48,7 @@ export type MigrationResult = interface Opts { runMigration: () => Promise; isMigrated: () => Promise; + setStatus: (status: KibanaMigratorStatus) => void; log: SavedObjectsMigrationLogger; pollInterval?: number; } @@ -64,7 +70,9 @@ export async function coordinateMigration(opts: Opts): Promise try { return await opts.runMigration(); } catch (error) { - if (handleIndexExists(error, opts.log)) { + const waitingIndex = handleIndexExists(error, opts.log); + if (waitingIndex) { + opts.setStatus({ status: 'waiting_for_other_nodes', waitingIndex }); await waitForMigration(opts.isMigrated, opts.pollInterval); return { status: 'skipped' }; } @@ -77,11 +85,11 @@ export async function coordinateMigration(opts: Opts): Promise * and is the cue for us to fall into a polling loop, waiting for some * other Kibana instance to complete the migration. */ -function handleIndexExists(error: any, log: SavedObjectsMigrationLogger) { +function handleIndexExists(error: any, log: SavedObjectsMigrationLogger): string | undefined { const isIndexExistsError = _.get(error, 'body.error.type') === 'resource_already_exists_exception'; if (!isIndexExistsError) { - return false; + return undefined; } const index = _.get(error, 'body.error.index'); @@ -93,7 +101,7 @@ function handleIndexExists(error: any, log: SavedObjectsMigrationLogger) { `restarting Kibana.` ); - return true; + return index; } /** diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index 58dcae7309eeafa..e09284b49c86eef 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -52,6 +52,7 @@ export type IKibanaMigrator = Pick; export interface KibanaMigratorStatus { status: MigrationStatus; result?: MigrationResult[]; + waitingIndex?: string; } /** @@ -67,7 +68,7 @@ export class KibanaMigrator { private readonly serializer: SavedObjectsSerializer; private migrationResult?: Promise; private readonly status$ = new BehaviorSubject({ - status: 'waiting', + status: 'waiting_to_start', }); private readonly activeMappings: IndexMapping; private migrationsRetryDelay?: number; @@ -200,6 +201,7 @@ export class KibanaMigrator { kibanaVersion: this.kibanaVersion, log: this.log, mappingProperties: indexMap[index].typeMappings, + setStatus: (status) => this.status$.next(status), pollInterval: this.soMigrationsConfig.pollInterval, scrollDuration: this.soMigrationsConfig.scrollDuration, serializer: this.serializer, diff --git a/src/core/server/saved_objects/status.ts b/src/core/server/saved_objects/status.ts index 24e87d292454314..95bf6ddd9ff5254 100644 --- a/src/core/server/saved_objects/status.ts +++ b/src/core/server/saved_objects/status.ts @@ -18,11 +18,20 @@ export const calculateStatus$ = ( ): Observable> => { const migratorStatus$: Observable> = rawMigratorStatus$.pipe( map((migrationStatus) => { - if (migrationStatus.status === 'waiting') { + if (migrationStatus.status === 'waiting_to_start') { return { level: ServiceStatusLevels.unavailable, summary: `SavedObjects service is waiting to start migrations`, }; + } else if (migrationStatus.status === 'waiting_for_other_nodes') { + return { + level: ServiceStatusLevels.unavailable, + summary: `SavedObjects service is waiting for other nodes to complete the migration`, + detail: + `If no other Kibana instance is attempting ` + + `migrations, you can get past this message by deleting index ${migrationStatus.waitingIndex} and ` + + `restarting Kibana.`, + }; } else if (migrationStatus.status === 'running') { return { level: ServiceStatusLevels.unavailable, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index b4c6ee323cbac92..327aee1a9dfc610 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -788,6 +788,7 @@ export class CspConfig implements ICspConfig { // @public export interface CustomHttpResponseOptions { body?: T; + bypassErrorFormat?: boolean; headers?: ResponseHeaders; // (undocumented) statusCode: number; @@ -1078,6 +1079,7 @@ export interface HttpResourcesServiceToolkit { // @public export interface HttpResponseOptions { body?: HttpResponsePayload; + bypassErrorFormat?: boolean; headers?: ResponseHeaders; } @@ -3261,7 +3263,7 @@ export const validBodyOutput: readonly ["data", "stream"]; // Warnings were encountered during analysis: // // src/core/server/elasticsearch/client/types.ts:94:7 - (ae-forgotten-export) The symbol "Explanation" needs to be exported by the entry point index.d.ts -// src/core/server/http/router/response.ts:297:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts +// src/core/server/http/router/response.ts:301:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:326:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:326:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:329:3 - (ae-forgotten-export) The symbol "SavedObjectsConfigType" needs to be exported by the entry point index.d.ts diff --git a/src/core/server/status/legacy_status.ts b/src/core/server/status/legacy_status.ts index b7d0965e31f684b..1b3d139b1345ecd 100644 --- a/src/core/server/status/legacy_status.ts +++ b/src/core/server/status/legacy_status.ts @@ -95,7 +95,7 @@ const serviceStatusToHttpComponent = ( since: string ): StatusComponentHttp => ({ id: serviceName, - message: status.summary, + message: [status.summary, status.detail].filter(Boolean).join(' '), since, ...serviceStatusAttrs(status), }); diff --git a/src/core/server/status/routes/status.ts b/src/core/server/status/routes/status.ts index c1782570ecfa0df..72f639231996fd0 100644 --- a/src/core/server/status/routes/status.ts +++ b/src/core/server/status/routes/status.ts @@ -12,7 +12,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { MetricsServiceSetup } from '../../metrics'; -import { ServiceStatus, CoreStatus } from '../types'; +import { ServiceStatus, CoreStatus, ServiceStatusLevels } from '../types'; import { PluginName } from '../../plugins'; import { calculateLegacyStatus, LegacyStatusInfo } from '../legacy_status'; import { PackageInfo } from '../../config'; @@ -160,7 +160,8 @@ export const registerStatusRoute = ({ router, config, metrics, status }: Deps) = }, }; - return res.ok({ body }); + const statusCode = overall.level >= ServiceStatusLevels.unavailable ? 503 : 200; + return res.custom({ body, statusCode, bypassErrorFormat: true }); } ); }; diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index 7724e7a5e44b460..cfd4d92d91d3f8b 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -88,9 +88,7 @@ export class StatusService implements CoreService { // Create an unused subscription to ensure all underlying lazy observables are started. this.overallSubscription = overall$.subscribe(); - const router = http.createRouter(''); - registerStatusRoute({ - router, + const commonRouteDeps = { config: { allowAnonymous: statusConfig.allowAnonymous, packageInfo: this.coreContext.env.packageInfo, @@ -103,8 +101,27 @@ export class StatusService implements CoreService { plugins$: this.pluginsStatus.getAll$(), core$, }, + }; + + const router = http.createRouter(''); + registerStatusRoute({ + router, + ...commonRouteDeps, }); + if (http.notReadyServer && commonRouteDeps.config.allowAnonymous) { + http.notReadyServer.registerRoutes('', (notReadyRouter) => { + registerStatusRoute({ + router: notReadyRouter, + ...commonRouteDeps, + config: { + ...commonRouteDeps.config, + allowAnonymous: true, + }, + }); + }); + } + return { core$, overall$, diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index 87997ab4231a263..dcd34c604dc31e0 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -735,6 +735,7 @@ async function migrateIndex({ mappingProperties, batchSize: 10, log: getLogMock(), + setStatus: () => {}, pollInterval: 50, scrollDuration: '5m', serializer: new SavedObjectsSerializer(typeRegistry), From 92da713f26e005f1448351317cea502319883c0b Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 26 Apr 2021 18:05:13 +0200 Subject: [PATCH 34/37] [Exploratory View] Fix/Improve field values search in exploratory view (#97836) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../columns/filter_expanded.test.tsx | 3 +- .../series_editor/columns/filter_expanded.tsx | 90 ++++++++------ .../series_editor/columns/series_filter.tsx | 2 +- .../public/hooks/use_es_search.ts | 49 ++++++++ .../public/hooks/use_values_list.ts | 111 +++++++++++++----- 5 files changed, 184 insertions(+), 71 deletions(-) create mode 100644 x-pack/plugins/observability/public/hooks/use_es_search.ts diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx index 530b8dee3a4d20e..8d3060792857e35 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx @@ -8,12 +8,13 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { FilterExpanded } from './filter_expanded'; -import { mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('FilterExpanded', function () { it('should render properly', async function () { mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + mockAppIndexPattern(); render( field === fd); - const displayValues = (values || []).filter((opt) => - opt.toLowerCase().includes(value.toLowerCase()) - ); + const displayValues = values.filter((opt) => opt.toLowerCase().includes(value.toLowerCase())); return ( @@ -60,50 +56,70 @@ export function FilterExpanded({ seriesId, field, label, goBack, nestedField, is { setValue(evt.target.value); }} + placeholder={i18n.translate('xpack.observability.filters.expanded.search', { + defaultMessage: 'Search for {label}', + values: { label }, + })} /> - {loading && ( -
- -
- )} - {displayValues.map((opt) => ( - - - {isNegated !== false && ( + + {displayValues.map((opt) => ( + + + {isNegated !== false && ( + + )} - )} - - - - - ))} + + + + ))} +
); } +const ListWrapper = euiStyled.div` + height: 400px; + overflow-y: auto; + &::-webkit-scrollbar { + height: ${({ theme }) => theme.eui.euiScrollBar}; + width: ${({ theme }) => theme.eui.euiScrollBar}; + } + &::-webkit-scrollbar-thumb { + background-clip: content-box; + background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)}; + border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent; + } + &::-webkit-scrollbar-corner, + &::-webkit-scrollbar-track { + background-color: transparent; + } +`; + const Wrapper = styled.div` - max-width: 400px; + width: 400px; `; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx index 88cb53826341938..2d82aca658ec3a4 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx @@ -119,7 +119,7 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P button={button} isOpen={isPopoverVisible} closePopover={closePopover} - anchorPosition="leftCenter" + anchorPosition={isNew ? 'leftCenter' : 'rightCenter'} > {!selectedField ? mainPanel : childPanel} diff --git a/x-pack/plugins/observability/public/hooks/use_es_search.ts b/x-pack/plugins/observability/public/hooks/use_es_search.ts new file mode 100644 index 000000000000000..b6ee4a63823b1d0 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_es_search.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { estypes } from '@elastic/elasticsearch'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { ESSearchResponse } from '../../../../../typings/elasticsearch'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import { isCompleteResponse } from '../../../../../src/plugins/data/common'; +import { useFetcher } from './use_fetcher'; + +export const useEsSearch = ( + params: TParams, + fnDeps: any[] +) => { + const { + services: { data }, + } = useKibana<{ data: DataPublicPluginStart }>(); + + const { data: response = {}, loading } = useFetcher(() => { + return new Promise((resolve) => { + const search$ = data.search + .search({ + params, + }) + .subscribe({ + next: (result) => { + if (isCompleteResponse(result)) { + // Final result + resolve(result); + search$.unsubscribe(); + } + }, + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...fnDeps]); + + const { rawResponse } = response as any; + + return { data: rawResponse as ESSearchResponse, loading }; +}; + +export function createEsParams(params: T): T { + return params; +} diff --git a/x-pack/plugins/observability/public/hooks/use_values_list.ts b/x-pack/plugins/observability/public/hooks/use_values_list.ts index e17f515ed6cb9eb..147a66f3d505ec7 100644 --- a/x-pack/plugins/observability/public/hooks/use_values_list.ts +++ b/x-pack/plugins/observability/public/hooks/use_values_list.ts @@ -5,11 +5,12 @@ * 2.0. */ +import { capitalize, merge } from 'lodash'; +import { useEffect, useState } from 'react'; +import { useDebounce } from 'react-use'; import { IndexPattern } from '../../../../../src/plugins/data/common'; -import { useKibana } from '../../../../../src/plugins/kibana_react/public'; -import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; -import { useFetcher } from './use_fetcher'; import { ESFilter } from '../../../../../typings/elasticsearch'; +import { createEsParams, useEsSearch } from './use_es_search'; export interface Props { sourceField: string; @@ -17,6 +18,7 @@ export interface Props { indexPattern: IndexPattern; filters?: ESFilter[]; time?: { from: string; to: string }; + keepHistory?: boolean; } export const useValuesList = ({ @@ -25,38 +27,83 @@ export const useValuesList = ({ query = '', filters, time, + keepHistory, }: Props): { values: string[]; loading?: boolean } => { - const { - services: { data }, - } = useKibana<{ data: DataPublicPluginStart }>(); + const [debouncedQuery, setDebounceQuery] = useState(query); + const [values, setValues] = useState([]); const { from, to } = time ?? {}; - const { data: values, loading } = useFetcher(() => { - if (!sourceField || !indexPattern) { - return []; + let includeClause = ''; + + if (query) { + if (query[0].toLowerCase() === query[0]) { + // if first letter is lowercase we also add the capitalize option + includeClause = `(${query}|${capitalize(query)}).*`; + } else { + // otherwise we add lowercase option prefix + includeClause = `(${query}|${query.toLowerCase()}).*`; } - return data.autocomplete.getValueSuggestions({ - indexPattern, - query: query || '', - useTimeRange: !(from && to), - field: indexPattern.getFieldByName(sourceField)!, - boolFilter: - from && to - ? [ - ...(filters || []), - { - range: { - '@timestamp': { - gte: from, - lte: to, - }, - }, - }, - ] - : filters || [], - }); - }, [query, sourceField, data.autocomplete, indexPattern, from, to, filters]); - - return { values: values as string[], loading }; + } + + useDebounce( + () => { + setDebounceQuery(query); + }, + 350, + [query] + ); + + const { data, loading } = useEsSearch( + createEsParams({ + index: indexPattern.title, + body: { + query: { + bool: { + filter: [ + ...(filters ?? []), + ...(from && to + ? [ + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ] + : []), + ], + }, + }, + size: 0, + aggs: { + values: { + terms: { + field: sourceField, + size: 100, + ...(query ? { include: includeClause } : {}), + }, + }, + }, + }, + }), + [debouncedQuery, from, to] + ); + + useEffect(() => { + const newValues = + data?.aggregations?.values.buckets.map(({ key: value }) => value as string) ?? []; + + if (keepHistory) { + setValues((prevState) => { + return merge(newValues, prevState); + }); + } else { + setValues(newValues); + } + }, [data, keepHistory, loading]); + + return { values, loading }; }; From ca17d931deaff7217dc7f75ae1ad6ff3be680543 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 26 Apr 2021 18:34:27 +0200 Subject: [PATCH 35/37] [Uptime] fix uptime filters (#98001) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/components/overview/query_bar/query_bar.tsx | 2 +- .../components/overview/query_bar/use_index_pattern.ts | 7 +++---- .../public/components/overview/query_bar/use_query_bar.ts | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/overview/query_bar/query_bar.tsx b/x-pack/plugins/uptime/public/components/overview/query_bar/query_bar.tsx index 54e2789dc666f7a..0543e5868bb9ec5 100644 --- a/x-pack/plugins/uptime/public/components/overview/query_bar/query_bar.tsx +++ b/x-pack/plugins/uptime/public/components/overview/query_bar/query_bar.tsx @@ -36,7 +36,7 @@ export const QueryBar = () => { const { query, setQuery } = useQueryBar(); - const { index_pattern: indexPattern } = useIndexPattern(query.language ?? SyntaxType.text); + const { index_pattern: indexPattern } = useIndexPattern(); const [inputVal, setInputVal] = useState(query.query); diff --git a/x-pack/plugins/uptime/public/components/overview/query_bar/use_index_pattern.ts b/x-pack/plugins/uptime/public/components/overview/query_bar/use_index_pattern.ts index ab10afb5b231e52..b0e567c40ed73ce 100644 --- a/x-pack/plugins/uptime/public/components/overview/query_bar/use_index_pattern.ts +++ b/x-pack/plugins/uptime/public/components/overview/query_bar/use_index_pattern.ts @@ -9,18 +9,17 @@ import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { getIndexPattern } from '../../../state/actions'; import { selectIndexPattern } from '../../../state/selectors'; -import { SyntaxType } from './use_query_bar'; -export const useIndexPattern = (queryLanguage?: string) => { +export const useIndexPattern = () => { const dispatch = useDispatch(); const indexPattern = useSelector(selectIndexPattern); useEffect(() => { // we only use index pattern for kql queries - if (!indexPattern.index_pattern && (!queryLanguage || queryLanguage === SyntaxType.kuery)) { + if (!indexPattern.index_pattern) { dispatch(getIndexPattern()); } - }, [indexPattern.index_pattern, dispatch, queryLanguage]); + }, [indexPattern.index_pattern, dispatch]); return indexPattern; }; diff --git a/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.ts b/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.ts index 9e3691497eab6c7..0d8a2ee17994af4 100644 --- a/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.ts +++ b/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.ts @@ -44,7 +44,7 @@ export const useQueryBar = () => { } ); - const { index_pattern: indexPattern } = useIndexPattern(query.language); + const { index_pattern: indexPattern } = useIndexPattern(); const updateUrlParams = useUrlParams()[1]; From 1351510ce8c1301951885177d95973e0be30257c Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Mon, 26 Apr 2021 12:53:23 -0400 Subject: [PATCH 36/37] Creating a stub page for Search UI (#98069) --- .../components/engine/engine_nav.tsx | 3 +- .../components/engine/engine_router.test.tsx | 8 ++++++ .../components/engine/engine_router.tsx | 10 +++++-- .../app_search/components/search_ui/index.ts | 1 + .../components/search_ui/search_ui.test.tsx | 21 ++++++++++++++ .../components/search_ui/search_ui.tsx | 28 +++++++++++++++++++ 6 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx index dfca497807718f1..87fbf58dae0234c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx @@ -236,8 +236,7 @@ export const EngineNav: React.FC = () => { )} {canManageEngineSearchUi && ( {SEARCH_UI_TITLE} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index d01958942e0a179..3e001d33b990712 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -22,6 +22,7 @@ import { CurationsRouter } from '../curations'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; import { ResultSettings } from '../result_settings'; +import { SearchUI } from '../search_ui'; import { Synonyms } from '../synonyms'; import { EngineRouter } from './engine_router'; @@ -135,4 +136,11 @@ describe('EngineRouter', () => { expect(wrapper.find(ApiLogs)).toHaveLength(1); }); + + it('renders a search ui view', () => { + setMockValues({ ...values, myRole: { canManageEngineSearchUi: true } }); + const wrapper = shallow(); + + expect(wrapper.find(SearchUI)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index c246af361156373..fef67880f23a845 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -30,7 +30,7 @@ import { ENGINE_SYNONYMS_PATH, ENGINE_CURATIONS_PATH, ENGINE_RESULT_SETTINGS_PATH, - // ENGINE_SEARCH_UI_PATH, + ENGINE_SEARCH_UI_PATH, ENGINE_API_LOGS_PATH, } from '../../routes'; import { AnalyticsRouter } from '../analytics'; @@ -40,6 +40,7 @@ import { DocumentDetail, Documents } from '../documents'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; import { ResultSettings } from '../result_settings'; +import { SearchUI } from '../search_ui'; import { Synonyms } from '../synonyms'; import { EngineLogic, getEngineBreadcrumbs } from './'; @@ -56,7 +57,7 @@ export const EngineRouter: React.FC = () => { canManageEngineSynonyms, canManageEngineCurations, canManageEngineResultSettings, - // canManageEngineSearchUi, + canManageEngineSearchUi, canViewEngineApiLogs, }, } = useValues(AppLogic); @@ -122,6 +123,11 @@ export const EngineRouter: React.FC = () => { )} + {canManageEngineSearchUi && ( + + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts index 054e3cf14a77702..f161f891eb4a3fc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts @@ -6,3 +6,4 @@ */ export { SEARCH_UI_TITLE } from './constants'; +export { SearchUI } from './search_ui'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx new file mode 100644 index 000000000000000..352ef257dc8a2f6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../__mocks__/engine_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { SearchUI } from './'; + +describe('SearchUI', () => { + it('renders', () => { + shallow(); + // TODO: Check for form + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx new file mode 100644 index 000000000000000..086769f1556e91f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiPageHeader, EuiPageContentBody } from '@elastic/eui'; + +import { FlashMessages } from '../../../shared/flash_messages'; +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; + +import { getEngineBreadcrumbs } from '../engine'; + +import { SEARCH_UI_TITLE } from './constants'; + +export const SearchUI: React.FC = () => { + return ( + <> + + + + TODO + + ); +}; From f7ed9870ac9d0c4f97b013572489fb315b388610 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 26 Apr 2021 18:54:35 +0200 Subject: [PATCH 37/37] [ML] Fixes for Anomaly swim lane embeddable (#98258) --- .../explorer/actions/load_explorer_data.ts | 3 +- .../reducers/explorer_reducer/state.ts | 4 +- .../services/anomaly_timeline_service.ts | 28 ++++----- .../use_anomaly_charts_input_resolver.ts | 15 +---- .../swimlane_input_resolver.test.ts | 5 ++ .../swimlane_input_resolver.ts | 60 ++++++++++++------- .../embeddables/common/get_jobs_observable.ts | 15 ++++- 7 files changed, 76 insertions(+), 54 deletions(-) diff --git a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 6d70566af1a6462..935f44a657f7189 100644 --- a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -39,6 +39,7 @@ import { CombinedJob } from '../../../../common/types/anomaly_detection_jobs'; import { InfluencersFilterQuery } from '../../../../common/types/es_client'; import { ExplorerChartsData } from '../explorer_charts/explorer_charts_container_service'; import { mlJobService } from '../../services/job_service'; +import { TimeBucketsInterval } from '../../util/time_buckets'; // Memoize the data fetching methods. // wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument @@ -75,7 +76,7 @@ export interface LoadExplorerDataConfig { noInfluencersConfigured: boolean; selectedCells: AppStateSelectedCells | undefined; selectedJobs: ExplorerJob[]; - swimlaneBucketInterval: any; + swimlaneBucketInterval: TimeBucketsInterval; swimlaneLimit: number; tableInterval: string; tableSeverity: number; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index faab658740a7068..2365e4e46890265 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { Duration } from 'moment'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; import { Dictionary } from '../../../../../common/types/common'; @@ -25,6 +24,7 @@ import { import { AnnotationsTable } from '../../../../../common/types/annotations'; import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants'; import { InfluencersFilterQuery } from '../../../../../common/types/es_client'; +import { TimeBucketsInterval } from '../../../util/time_buckets'; export interface ExplorerState { overallAnnotations: AnnotationsTable; @@ -46,7 +46,7 @@ export interface ExplorerState { queryString: string; selectedCells: AppStateSelectedCells | undefined; selectedJobs: ExplorerJob[] | null; - swimlaneBucketInterval: Duration | undefined; + swimlaneBucketInterval: TimeBucketsInterval | undefined; swimlaneContainerWidth: number; tableData: AnomaliesTableData; tableQueryString: string; diff --git a/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts index 1521f62ac588d96..54d9626edf26c0e 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts @@ -11,7 +11,12 @@ import { TimeRange, UI_SETTINGS, } from '../../../../../../src/plugins/data/public'; -import { getBoundsRoundedToInterval, TimeBuckets, TimeRangeBounds } from '../util/time_buckets'; +import { + getBoundsRoundedToInterval, + TimeBuckets, + TimeBucketsInterval, + TimeRangeBounds, +} from '../util/time_buckets'; import { ExplorerJob, OverallSwimlaneData, @@ -92,9 +97,10 @@ export class AnomalyTimelineService { */ public async loadOverallData( selectedJobs: ExplorerJob[], - chartWidth: number + chartWidth?: number, + bucketInterval?: TimeBucketsInterval ): Promise { - const interval = this.getSwimlaneBucketInterval(selectedJobs, chartWidth); + const interval = bucketInterval ?? this.getSwimlaneBucketInterval(selectedJobs, chartWidth!); if (!selectedJobs || !selectedJobs.length) { throw new Error('Explorer jobs collection is required'); @@ -129,9 +135,6 @@ export class AnomalyTimelineService { interval.asSeconds() ); - // eslint-disable-next-line no-console - console.log('Explorer overall swim lane data set:', overallSwimlaneData); - return overallSwimlaneData; } @@ -156,8 +159,9 @@ export class AnomalyTimelineService { swimlaneLimit: number, perPage: number, fromPage: number, - swimlaneContainerWidth: number, - influencersFilterQuery?: any + swimlaneContainerWidth?: number, + influencersFilterQuery?: any, + bucketInterval?: TimeBucketsInterval ): Promise { const timefilterBounds = this.getTimeBounds(); @@ -165,10 +169,8 @@ export class AnomalyTimelineService { throw new Error('timeRangeSelectorEnabled has to be enabled'); } - const swimlaneBucketInterval = this.getSwimlaneBucketInterval( - selectedJobs, - swimlaneContainerWidth - ); + const swimlaneBucketInterval = + bucketInterval ?? this.getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth!); const searchBounds = getBoundsRoundedToInterval( timefilterBounds, @@ -222,8 +224,6 @@ export class AnomalyTimelineService { viewBySwimlaneFieldName, swimlaneBucketInterval.asSeconds() ); - // eslint-disable-next-line no-console - console.log('Explorer view by swim lane data set:', viewBySwimlaneData); return viewBySwimlaneData; } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts index 703851f3fe9b610..b5f149af205e3dd 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts @@ -14,13 +14,11 @@ import { MlStartDependencies } from '../../plugin'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; import { AppStateSelectedCells, - ExplorerJob, getSelectionInfluencers, getSelectionJobIds, getSelectionTimeRange, } from '../../application/explorer/explorer_utils'; import { OVERALL_LABEL, SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; -import { parseInterval } from '../../../common/util/parse_interval'; import { AnomalyChartsEmbeddableInput, AnomalyChartsEmbeddableOutput, @@ -76,8 +74,8 @@ export function useAnomalyChartsInputResolver( .pipe( tap(setIsLoading.bind(null, true)), debounceTime(FETCH_RESULTS_DEBOUNCE_MS), - switchMap(([jobs, input, embeddableContainerWidth, severityValue]) => { - if (!jobs) { + switchMap(([explorerJobs, input, embeddableContainerWidth, severityValue]) => { + if (!explorerJobs) { // couldn't load the list of jobs return of(undefined); } @@ -88,15 +86,6 @@ export function useAnomalyChartsInputResolver( anomalyExplorerService.setTimeRange(timeRangeInput); - const explorerJobs: ExplorerJob[] = jobs.map((job) => { - const bucketSpan = parseInterval(job.analysis_config.bucket_span); - return { - id: job.job_id, - selected: true, - bucketSpanSeconds: bucketSpan!.asSeconds(), - }; - }); - let influencersFilterQuery: InfluencersFilterQuery; try { influencersFilterQuery = processFilters(filters, query); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts index 4d2e2406376e270..01b1e3acf7f958c 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts @@ -55,6 +55,11 @@ describe('useSwimlaneInputResolver', () => { points: [], }) ), + getSwimlaneBucketInterval: jest.fn(() => { + return { + asSeconds: jest.fn(() => 900), + }; + }), }, anomalyDetectorService: { getJobs$: jest.fn((jobId: string[]) => { diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts index 4574c7e859c08c2..8b0c89bbd16b725 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts @@ -12,6 +12,8 @@ import { debounceTime, distinctUntilChanged, map, + pluck, + shareReplay, skipWhile, startWith, switchMap, @@ -27,8 +29,7 @@ import { SwimlaneType, } from '../../application/explorer/explorer_constants'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; -import { ExplorerJob, OverallSwimlaneData } from '../../application/explorer/explorer_utils'; -import { parseInterval } from '../../../common/util/parse_interval'; +import { OverallSwimlaneData } from '../../application/explorer/explorer_utils'; import { isViewBySwimLaneData } from '../../application/explorer/swimlane_container'; import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; import { @@ -43,7 +44,7 @@ import { getJobsObservable } from '../common/get_jobs_observable'; const FETCH_RESULTS_DEBOUNCE_MS = 500; export function useSwimlaneInputResolver( - embeddableInput: Observable, + embeddableInput$: Observable, onInputChange: (output: Partial) => void, refresh: Observable, services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices], @@ -67,6 +68,30 @@ export function useSwimlaneInputResolver( const [isLoading, setIsLoading] = useState(false); const chartWidth$ = useMemo(() => new Subject(), []); + + const selectedJobs$ = useMemo(() => { + return getJobsObservable(embeddableInput$, anomalyDetectorService, setError).pipe( + shareReplay(1) + ); + }, []); + + const bucketInterval$ = useMemo(() => { + return combineLatest([ + selectedJobs$, + chartWidth$, + embeddableInput$.pipe(pluck('timeRange')), + ]).pipe( + skipWhile(([jobs, width]) => !Array.isArray(jobs) || !width), + tap(([, , timeRange]) => { + anomalyTimelineService.setTimeRange(timeRange); + }), + map(([jobs, width]) => anomalyTimelineService.getSwimlaneBucketInterval(jobs!, width)), + distinctUntilChanged((prev, curr) => { + return prev.asSeconds() === curr.asSeconds(); + }) + ); + }, []); + const fromPage$ = useMemo(() => new Subject(), []); const perPage$ = useMemo(() => new Subject(), []); @@ -81,9 +106,9 @@ export function useSwimlaneInputResolver( useEffect(() => { const subscription = combineLatest([ - getJobsObservable(embeddableInput, anomalyDetectorService, setError), - embeddableInput, - chartWidth$.pipe(skipWhile((v) => !v)), + selectedJobs$, + embeddableInput$, + bucketInterval$, fromPage$, perPage$.pipe( startWith(undefined), @@ -97,8 +122,8 @@ export function useSwimlaneInputResolver( .pipe( tap(setIsLoading.bind(null, true)), debounceTime(FETCH_RESULTS_DEBOUNCE_MS), - switchMap(([jobs, input, swimlaneContainerWidth, fromPageInput, perPageFromState]) => { - if (!jobs) { + switchMap(([explorerJobs, input, bucketInterval, fromPageInput, perPageFromState]) => { + if (!explorerJobs) { // couldn't load the list of jobs return of(undefined); } @@ -107,27 +132,15 @@ export function useSwimlaneInputResolver( viewBy, swimlaneType: swimlaneTypeInput, perPage: perPageInput, - timeRange, filters, query, viewMode, } = input; - anomalyTimelineService.setTimeRange(timeRange); - if (!swimlaneType) { setSwimlaneType(swimlaneTypeInput); } - const explorerJobs: ExplorerJob[] = jobs.map((job) => { - const bucketSpan = parseInterval(job.analysis_config.bucket_span); - return { - id: job.job_id, - selected: true, - bucketSpanSeconds: bucketSpan!.asSeconds(), - }; - }); - let appliedFilters: any; try { appliedFilters = processFilters(filters, query, CONTROLLED_BY_SWIM_LANE_FILTER); @@ -138,7 +151,7 @@ export function useSwimlaneInputResolver( } return from( - anomalyTimelineService.loadOverallData(explorerJobs, swimlaneContainerWidth) + anomalyTimelineService.loadOverallData(explorerJobs, undefined, bucketInterval) ).pipe( switchMap((overallSwimlaneData) => { const { earliest, latest } = overallSwimlaneData; @@ -165,8 +178,9 @@ export function useSwimlaneInputResolver( : ANOMALY_SWIM_LANE_HARD_LIMIT, perPageFromState ?? perPageInput ?? SWIM_LANE_DEFAULT_PAGE_SIZE, fromPageInput, - swimlaneContainerWidth, - appliedFilters + undefined, + appliedFilters, + bucketInterval ) ).pipe( map((viewBySwimlaneData) => { diff --git a/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts b/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts index 6bdec30340b764e..451eb95b4f801f4 100644 --- a/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts +++ b/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts @@ -6,10 +6,12 @@ */ import { Observable, of } from 'rxjs'; -import { catchError, distinctUntilChanged, pluck, switchMap } from 'rxjs/operators'; +import { catchError, distinctUntilChanged, map, pluck, switchMap } from 'rxjs/operators'; import { isEqual } from 'lodash'; import { AnomalyChartsEmbeddableInput, AnomalySwimlaneEmbeddableInput } from '../types'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; +import { ExplorerJob } from '../../application/explorer/explorer_utils'; +import { parseInterval } from '../../../common/util/parse_interval'; export function getJobsObservable( embeddableInput: Observable, @@ -20,6 +22,17 @@ export function getJobsObservable( pluck('jobIds'), distinctUntilChanged(isEqual), switchMap((jobsIds) => anomalyDetectorService.getJobs$(jobsIds)), + map((jobs) => { + const explorerJobs: ExplorerJob[] = jobs.map((job) => { + const bucketSpan = parseInterval(job.analysis_config.bucket_span); + return { + id: job.job_id, + selected: true, + bucketSpanSeconds: bucketSpan!.asSeconds(), + }; + }); + return explorerJobs; + }), catchError((e) => { setErrorHandler(e.body ?? e); return of(undefined);