diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 86f9f7562434e49..217bb03549343d4 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -62,7 +62,9 @@ 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 - @kbn/config-schema - @kbn/std 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/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/package.json b/package.json index 1625b0305554930..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", @@ -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", @@ -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..7f5182e9071078b 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -4,7 +4,9 @@ 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", "//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/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-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-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/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/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/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', 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/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.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 5bf5ae26f6a0ad1..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)); }, @@ -189,8 +191,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/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.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..e09284b49c86eef 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; @@ -53,6 +52,7 @@ export type IKibanaMigrator = Pick; export interface KibanaMigratorStatus { status: MigrationStatus; result?: MigrationResult[]; + waitingIndex?: string; } /** @@ -68,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; @@ -185,12 +185,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, @@ -206,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/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 000000000000000..a92211c16c55933 Binary files /dev/null and b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_so_with_multiple_namespaces.zip differ 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 000000000000000..c6c89ac2879b2e6 Binary files /dev/null and b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_with_corrupted_so.zip differ 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/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/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/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..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 @@ -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) { - - - - - + + + ); @@ -331,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 ( @@ -408,14 +411,13 @@ export const TimeseriesConfig = injectI18n(function (props) { - - - - - + + + {palettesRegistry && ( @@ -430,7 +432,7 @@ export const TimeseriesConfig = injectI18n(function (props) { > @@ -443,14 +445,13 @@ export const TimeseriesConfig = injectI18n(function (props) { - - - - - + + + - - + - - - + {}, pollInterval: 50, scrollDuration: '5m', serializer: new SavedObjectsSerializer(typeRegistry), 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" } } } 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/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', + })} + + + + +
+
+
)} ); 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/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/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 ( - - {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/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; 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/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 + + ); +}; 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; +} 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/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 = ; 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 && }
)} 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 ( - - - - - - ); -} 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 ); 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)} ); } 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 && } + 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/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/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); } 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/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', + })} + + + + } > 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); 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)} + + ); } 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 }; }; 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 */} 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/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, }, ]; } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bde4e24ecd96442..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 エージェント", @@ -20806,9 +20805,6 @@ "xpack.snapshotRestore.repositoryForm.typeAzure.basePathDescription": "レポジトリデータへのコンテナーパスです。", "xpack.snapshotRestore.repositoryForm.typeAzure.basePathLabel": "ベースパス", "xpack.snapshotRestore.repositoryForm.typeAzure.basePathTitle": "ベースパス", - "xpack.snapshotRestore.repositoryForm.typeAzure.chunkSizeDescription": "スナップショットの作成時にファイルを小さなユニットに分けます。", - "xpack.snapshotRestore.repositoryForm.typeAzure.chunkSizeLabel": "チャンクサイズ", - "xpack.snapshotRestore.repositoryForm.typeAzure.chunkSizeTitle": "チャンクサイズ", "xpack.snapshotRestore.repositoryForm.typeAzure.clientDescription": "Azure クライアントの名前です。", "xpack.snapshotRestore.repositoryForm.typeAzure.clientLabel": "クライアント", "xpack.snapshotRestore.repositoryForm.typeAzure.clientTitle": "クライアント", @@ -20821,29 +20817,14 @@ "xpack.snapshotRestore.repositoryForm.typeAzure.locationModeDescription": "プライマリまたはセカンダリの場所です。セカンダリの場合、読み込み専用が true です。", "xpack.snapshotRestore.repositoryForm.typeAzure.locationModeLabel": "位置情報モード", "xpack.snapshotRestore.repositoryForm.typeAzure.locationModeTitle": "位置情報モード", - "xpack.snapshotRestore.repositoryForm.typeAzure.maxRestoreBytesDescription": "各ノードのスナップショットの復元レートです。", - "xpack.snapshotRestore.repositoryForm.typeAzure.maxRestoreBytesLabel": "1 秒間の最高復元バイト数", - "xpack.snapshotRestore.repositoryForm.typeAzure.maxRestoreBytesTitle": "1 秒間の最高復元バイト数", - "xpack.snapshotRestore.repositoryForm.typeAzure.maxSnapshotBytesDescription": "各ノードのスナップショットの作成レートです。", - "xpack.snapshotRestore.repositoryForm.typeAzure.maxSnapshotBytesLabel": "1 秒間の最高スナップショットバイト数", - "xpack.snapshotRestore.repositoryForm.typeAzure.maxSnapshotBytesTitle": "1 秒間の最高スナップショットバイト数", "xpack.snapshotRestore.repositoryForm.typeAzure.readonlyDescription": "このレポジトリへの書き込みアクセスがあるクラスターは 1 つだけでなければなりません。他のクラスターはすべて読み込み専用にする必要があります。", "xpack.snapshotRestore.repositoryForm.typeAzure.readonlyLabel": "読み込み専用レポジトリ", "xpack.snapshotRestore.repositoryForm.typeAzure.readonlyTitle": "読み込み専用", - "xpack.snapshotRestore.repositoryForm.typeFS.chunkSizeDescription": "スナップショットの作成時にファイルを小さなユニットに分けます。", - "xpack.snapshotRestore.repositoryForm.typeFS.chunkSizeLabel": "チャンクサイズ", - "xpack.snapshotRestore.repositoryForm.typeFS.chunkSizeTitle": "チャンクサイズ", "xpack.snapshotRestore.repositoryForm.typeFS.compressDescription": "スナップショット用にインデックスのマッピングと設定ファイルを圧縮します。データファイルは圧縮されません。", "xpack.snapshotRestore.repositoryForm.typeFS.compressLabel": "スナップショットを圧縮", "xpack.snapshotRestore.repositoryForm.typeFS.compressTitle": "スナップショットの圧縮", "xpack.snapshotRestore.repositoryForm.typeFS.locationLabel": "場所 (必須) ", "xpack.snapshotRestore.repositoryForm.typeFS.locationTitle": "ファイルシステムの場所", - "xpack.snapshotRestore.repositoryForm.typeFS.maxRestoreBytesDescription": "各ノードのスナップショットの復元レートです。", - "xpack.snapshotRestore.repositoryForm.typeFS.maxRestoreBytesLabel": "1 秒間の最高復元バイト数", - "xpack.snapshotRestore.repositoryForm.typeFS.maxRestoreBytesTitle": "1 秒間の最高復元バイト数", - "xpack.snapshotRestore.repositoryForm.typeFS.maxSnapshotBytesDescription": "各ノードのスナップショットの作成レートです。", - "xpack.snapshotRestore.repositoryForm.typeFS.maxSnapshotBytesLabel": "1 秒間の最高スナップショットバイト数", - "xpack.snapshotRestore.repositoryForm.typeFS.maxSnapshotBytesTitle": "1 秒間の最高スナップショットバイト数", "xpack.snapshotRestore.repositoryForm.typeFS.readonlyDescription": "このレポジトリへの書き込みアクセスがあるクラスターは 1 つだけでなければなりません。他のクラスターはすべて読み込み専用にする必要があります。", "xpack.snapshotRestore.repositoryForm.typeFS.readonlyLabel": "読み込み専用レポジトリ", "xpack.snapshotRestore.repositoryForm.typeFS.readonlyTitle": "読み込み専用", @@ -20853,27 +20834,15 @@ "xpack.snapshotRestore.repositoryForm.typeGCS.bucketDescription": "スナップショットに使用する Google Cloud Storage バケットの名前です。", "xpack.snapshotRestore.repositoryForm.typeGCS.bucketLabel": "バケット (必須) ", "xpack.snapshotRestore.repositoryForm.typeGCS.bucketTitle": "バケット", - "xpack.snapshotRestore.repositoryForm.typeGCS.chunkSizeDescription": "スナップショットの作成時にファイルを小さなユニットに分けます。", - "xpack.snapshotRestore.repositoryForm.typeGCS.chunkSizeLabel": "チャンクサイズ", - "xpack.snapshotRestore.repositoryForm.typeGCS.chunkSizeTitle": "チャンクサイズ", "xpack.snapshotRestore.repositoryForm.typeGCS.clientDescription": "Google Cloud Storage クライアントの名前です。", "xpack.snapshotRestore.repositoryForm.typeGCS.clientLabel": "クライアント", "xpack.snapshotRestore.repositoryForm.typeGCS.clientTitle": "クライアント", "xpack.snapshotRestore.repositoryForm.typeGCS.compressDescription": "スナップショット用にインデックスのマッピングと設定ファイルを圧縮します。データファイルは圧縮されません。", "xpack.snapshotRestore.repositoryForm.typeGCS.compressLabel": "スナップショットを圧縮", "xpack.snapshotRestore.repositoryForm.typeGCS.compressTitle": "スナップショットを圧縮", - "xpack.snapshotRestore.repositoryForm.typeGCS.maxRestoreBytesDescription": "各ノードのスナップショットの復元レートです。", - "xpack.snapshotRestore.repositoryForm.typeGCS.maxRestoreBytesLabel": "1 秒間の最高復元バイト数", - "xpack.snapshotRestore.repositoryForm.typeGCS.maxRestoreBytesTitle": "1 秒間の最高復元バイト数", - "xpack.snapshotRestore.repositoryForm.typeGCS.maxSnapshotBytesDescription": "各ノードのスナップショットの作成レートです。", - "xpack.snapshotRestore.repositoryForm.typeGCS.maxSnapshotBytesLabel": "1 秒間の最高スナップショットバイト数", - "xpack.snapshotRestore.repositoryForm.typeGCS.maxSnapshotBytesTitle": "1 秒間の最高スナップショットバイト数", "xpack.snapshotRestore.repositoryForm.typeGCS.readonlyDescription": "このレポジトリへの書き込みアクセスがあるクラスターは 1 つだけでなければなりません。他のクラスターはすべて読み込み専用にする必要があります。", "xpack.snapshotRestore.repositoryForm.typeGCS.readonlyLabel": "読み込み専用レポジトリ", "xpack.snapshotRestore.repositoryForm.typeGCS.readonlyTitle": "読み込み専用", - "xpack.snapshotRestore.repositoryForm.typeHDFS.chunkSizeDescription": "スナップショットの作成時にファイルを小さなユニットに分けます。", - "xpack.snapshotRestore.repositoryForm.typeHDFS.chunkSizeLabel": "チャンクサイズ", - "xpack.snapshotRestore.repositoryForm.typeHDFS.chunkSizeTitle": "チャンクサイズ", "xpack.snapshotRestore.repositoryForm.typeHDFS.compressDescription": "スナップショット用にインデックスのマッピングと設定ファイルを圧縮します。データファイルは圧縮されません。", "xpack.snapshotRestore.repositoryForm.typeHDFS.compressLabel": "スナップショットを圧縮", "xpack.snapshotRestore.repositoryForm.typeHDFS.compressTitle": "スナップショットの圧縮", @@ -20886,12 +20855,6 @@ "xpack.snapshotRestore.repositoryForm.typeHDFS.loadDefaultsDescription": "デフォルトの Hadoop 構成を読み込みます。", "xpack.snapshotRestore.repositoryForm.typeHDFS.loadDefaultsLabel": "デフォルトを読み込む", "xpack.snapshotRestore.repositoryForm.typeHDFS.loadDefaultsTitle": "デフォルトを読み込む", - "xpack.snapshotRestore.repositoryForm.typeHDFS.maxRestoreBytesDescription": "各ノードのスナップショットの復元レートです。", - "xpack.snapshotRestore.repositoryForm.typeHDFS.maxRestoreBytesLabel": "1 秒間の最高復元バイト数", - "xpack.snapshotRestore.repositoryForm.typeHDFS.maxRestoreBytesTitle": "1 秒間の最高復元バイト数", - "xpack.snapshotRestore.repositoryForm.typeHDFS.maxSnapshotBytesDescription": "各ノードのスナップショットの作成レートです。", - "xpack.snapshotRestore.repositoryForm.typeHDFS.maxSnapshotBytesLabel": "1 秒間の最高スナップショットバイト数", - "xpack.snapshotRestore.repositoryForm.typeHDFS.maxSnapshotBytesTitle": "1 秒間の最高スナップショットバイト数", "xpack.snapshotRestore.repositoryForm.typeHDFS.pathDescription": "データが保管されているファイルへのパスです。", "xpack.snapshotRestore.repositoryForm.typeHDFS.pathLabel": "パス (必須) ", "xpack.snapshotRestore.repositoryForm.typeHDFS.pathTitle": "パス", @@ -20922,21 +20885,12 @@ "xpack.snapshotRestore.repositoryForm.typeS3.cannedAclDescription": "新しい S3 バケットとオブジェクトに追加する canned ACL です。", "xpack.snapshotRestore.repositoryForm.typeS3.cannedAclLabel": "Canned ACL", "xpack.snapshotRestore.repositoryForm.typeS3.cannedAclTitle": "Canned ACL", - "xpack.snapshotRestore.repositoryForm.typeS3.chunkSizeDescription": "スナップショットの作成時にファイルを小さなユニットに分けます。", - "xpack.snapshotRestore.repositoryForm.typeS3.chunkSizeLabel": "チャンクサイズ", - "xpack.snapshotRestore.repositoryForm.typeS3.chunkSizeTitle": "チャンクサイズ", "xpack.snapshotRestore.repositoryForm.typeS3.clientDescription": "AWS S3 クライアントの名前です。", "xpack.snapshotRestore.repositoryForm.typeS3.clientLabel": "クライアント", "xpack.snapshotRestore.repositoryForm.typeS3.clientTitle": "クライアント", "xpack.snapshotRestore.repositoryForm.typeS3.compressDescription": "スナップショット用にインデックスのマッピングと設定ファイルを圧縮します。データファイルは圧縮されません。", "xpack.snapshotRestore.repositoryForm.typeS3.compressLabel": "スナップショットを圧縮", "xpack.snapshotRestore.repositoryForm.typeS3.compressTitle": "スナップショットの圧縮", - "xpack.snapshotRestore.repositoryForm.typeS3.maxRestoreBytesDescription": "各ノードのスナップショットの復元レートです。", - "xpack.snapshotRestore.repositoryForm.typeS3.maxRestoreBytesLabel": "1 秒間の最高復元バイト数", - "xpack.snapshotRestore.repositoryForm.typeS3.maxRestoreBytesTitle": "1 秒間の最高復元バイト数", - "xpack.snapshotRestore.repositoryForm.typeS3.maxSnapshotBytesDescription": "各ノードのスナップショットの作成レートです。", - "xpack.snapshotRestore.repositoryForm.typeS3.maxSnapshotBytesLabel": "1 秒間の最高スナップショットバイト数", - "xpack.snapshotRestore.repositoryForm.typeS3.maxSnapshotBytesTitle": "1 秒間の最高スナップショットバイト数", "xpack.snapshotRestore.repositoryForm.typeS3.readonlyDescription": "このレポジトリへの書き込みアクセスがあるクラスターは 1 つだけでなければなりません。他のクラスターはすべて読み込み専用にする必要があります。", "xpack.snapshotRestore.repositoryForm.typeS3.readonlyLabel": "読み込み専用レポジトリ", "xpack.snapshotRestore.repositoryForm.typeS3.readonlyTitle": "読み込み専用", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 43d537ad560cde7..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 代理", @@ -21143,9 +21142,6 @@ "xpack.snapshotRestore.repositoryForm.typeAzure.basePathDescription": "存储库数据的容器路径。", "xpack.snapshotRestore.repositoryForm.typeAzure.basePathLabel": "基路径", "xpack.snapshotRestore.repositoryForm.typeAzure.basePathTitle": "基路径", - "xpack.snapshotRestore.repositoryForm.typeAzure.chunkSizeDescription": "拍取快照时,将文件拆分成更小的单位。", - "xpack.snapshotRestore.repositoryForm.typeAzure.chunkSizeLabel": "块大小", - "xpack.snapshotRestore.repositoryForm.typeAzure.chunkSizeTitle": "块大小", "xpack.snapshotRestore.repositoryForm.typeAzure.clientDescription": "Azure 客户端的名称。", "xpack.snapshotRestore.repositoryForm.typeAzure.clientLabel": "客户端", "xpack.snapshotRestore.repositoryForm.typeAzure.clientTitle": "客户端", @@ -21158,29 +21154,14 @@ "xpack.snapshotRestore.repositoryForm.typeAzure.locationModeDescription": "主要位置或辅助位置。如果为辅助位置,则只读为 true。", "xpack.snapshotRestore.repositoryForm.typeAzure.locationModeLabel": "位置模式", "xpack.snapshotRestore.repositoryForm.typeAzure.locationModeTitle": "位置模式", - "xpack.snapshotRestore.repositoryForm.typeAzure.maxRestoreBytesDescription": "每个节点的快照还原速率。", - "xpack.snapshotRestore.repositoryForm.typeAzure.maxRestoreBytesLabel": "每秒最大还原字节数", - "xpack.snapshotRestore.repositoryForm.typeAzure.maxRestoreBytesTitle": "每秒最大还原字节数", - "xpack.snapshotRestore.repositoryForm.typeAzure.maxSnapshotBytesDescription": "为每个节点创建快照的速率。", - "xpack.snapshotRestore.repositoryForm.typeAzure.maxSnapshotBytesLabel": "每秒最大快照字节数", - "xpack.snapshotRestore.repositoryForm.typeAzure.maxSnapshotBytesTitle": "每秒最大快照字节数", "xpack.snapshotRestore.repositoryForm.typeAzure.readonlyDescription": "仅一个集群应对此存储库有写权限。所有其他集群应为只读。", "xpack.snapshotRestore.repositoryForm.typeAzure.readonlyLabel": "只读存储库", "xpack.snapshotRestore.repositoryForm.typeAzure.readonlyTitle": "只读", - "xpack.snapshotRestore.repositoryForm.typeFS.chunkSizeDescription": "拍取快照时,将文件拆分成更小的单位。", - "xpack.snapshotRestore.repositoryForm.typeFS.chunkSizeLabel": "块大小", - "xpack.snapshotRestore.repositoryForm.typeFS.chunkSizeTitle": "块大小", "xpack.snapshotRestore.repositoryForm.typeFS.compressDescription": "压缩快照的索引映射和设置文件。数据文件未压缩。", "xpack.snapshotRestore.repositoryForm.typeFS.compressLabel": "压缩快照", "xpack.snapshotRestore.repositoryForm.typeFS.compressTitle": "快照压缩", "xpack.snapshotRestore.repositoryForm.typeFS.locationLabel": "位置 (必填) ", "xpack.snapshotRestore.repositoryForm.typeFS.locationTitle": "文件系统位置", - "xpack.snapshotRestore.repositoryForm.typeFS.maxRestoreBytesDescription": "每个节点的快照还原速率。", - "xpack.snapshotRestore.repositoryForm.typeFS.maxRestoreBytesLabel": "每秒最大还原字节数", - "xpack.snapshotRestore.repositoryForm.typeFS.maxRestoreBytesTitle": "每秒最大还原字节数", - "xpack.snapshotRestore.repositoryForm.typeFS.maxSnapshotBytesDescription": "为每个节点创建快照的速率。", - "xpack.snapshotRestore.repositoryForm.typeFS.maxSnapshotBytesLabel": "每秒最大快照字节数", - "xpack.snapshotRestore.repositoryForm.typeFS.maxSnapshotBytesTitle": "每秒最大快照字节数", "xpack.snapshotRestore.repositoryForm.typeFS.readonlyDescription": "仅一个集群应对此存储库有写权限。所有其他集群应为只读。", "xpack.snapshotRestore.repositoryForm.typeFS.readonlyLabel": "只读存储库", "xpack.snapshotRestore.repositoryForm.typeFS.readonlyTitle": "只读", @@ -21190,27 +21171,15 @@ "xpack.snapshotRestore.repositoryForm.typeGCS.bucketDescription": "要用于快照的 Google Cloud Storage 存储桶的名称。", "xpack.snapshotRestore.repositoryForm.typeGCS.bucketLabel": "存储桶 (必填) ", "xpack.snapshotRestore.repositoryForm.typeGCS.bucketTitle": "存储桶", - "xpack.snapshotRestore.repositoryForm.typeGCS.chunkSizeDescription": "拍取快照时,将文件拆分成更小的单位。", - "xpack.snapshotRestore.repositoryForm.typeGCS.chunkSizeLabel": "块大小", - "xpack.snapshotRestore.repositoryForm.typeGCS.chunkSizeTitle": "块大小", "xpack.snapshotRestore.repositoryForm.typeGCS.clientDescription": "Google Cloud Storage 客户端的名称。", "xpack.snapshotRestore.repositoryForm.typeGCS.clientLabel": "客户端", "xpack.snapshotRestore.repositoryForm.typeGCS.clientTitle": "客户端", "xpack.snapshotRestore.repositoryForm.typeGCS.compressDescription": "压缩快照的索引映射和设置文件。数据文件未压缩。", "xpack.snapshotRestore.repositoryForm.typeGCS.compressLabel": "压缩快照", "xpack.snapshotRestore.repositoryForm.typeGCS.compressTitle": "压缩快照", - "xpack.snapshotRestore.repositoryForm.typeGCS.maxRestoreBytesDescription": "每个节点的快照还原速率。", - "xpack.snapshotRestore.repositoryForm.typeGCS.maxRestoreBytesLabel": "每秒最大还原字节数", - "xpack.snapshotRestore.repositoryForm.typeGCS.maxRestoreBytesTitle": "每秒最大还原字节数", - "xpack.snapshotRestore.repositoryForm.typeGCS.maxSnapshotBytesDescription": "为每个节点创建快照的速率。", - "xpack.snapshotRestore.repositoryForm.typeGCS.maxSnapshotBytesLabel": "每秒最大快照字节数", - "xpack.snapshotRestore.repositoryForm.typeGCS.maxSnapshotBytesTitle": "每秒最大快照字节数", "xpack.snapshotRestore.repositoryForm.typeGCS.readonlyDescription": "仅一个集群应对此存储库有写权限。所有其他集群应为只读。", "xpack.snapshotRestore.repositoryForm.typeGCS.readonlyLabel": "只读存储库", "xpack.snapshotRestore.repositoryForm.typeGCS.readonlyTitle": "只读", - "xpack.snapshotRestore.repositoryForm.typeHDFS.chunkSizeDescription": "拍取快照时,将文件拆分成更小的单位。", - "xpack.snapshotRestore.repositoryForm.typeHDFS.chunkSizeLabel": "块大小", - "xpack.snapshotRestore.repositoryForm.typeHDFS.chunkSizeTitle": "块大小", "xpack.snapshotRestore.repositoryForm.typeHDFS.compressDescription": "压缩快照的索引映射和设置文件。数据文件未压缩。", "xpack.snapshotRestore.repositoryForm.typeHDFS.compressLabel": "压缩快照", "xpack.snapshotRestore.repositoryForm.typeHDFS.compressTitle": "快照压缩", @@ -21223,12 +21192,6 @@ "xpack.snapshotRestore.repositoryForm.typeHDFS.loadDefaultsDescription": "加载默认的 Hadoop 配置。", "xpack.snapshotRestore.repositoryForm.typeHDFS.loadDefaultsLabel": "加载默认值", "xpack.snapshotRestore.repositoryForm.typeHDFS.loadDefaultsTitle": "加载默认值", - "xpack.snapshotRestore.repositoryForm.typeHDFS.maxRestoreBytesDescription": "每个节点的快照还原速率。", - "xpack.snapshotRestore.repositoryForm.typeHDFS.maxRestoreBytesLabel": "每秒最大还原字节数", - "xpack.snapshotRestore.repositoryForm.typeHDFS.maxRestoreBytesTitle": "每秒最大还原字节数", - "xpack.snapshotRestore.repositoryForm.typeHDFS.maxSnapshotBytesDescription": "为每个节点创建快照的速率。", - "xpack.snapshotRestore.repositoryForm.typeHDFS.maxSnapshotBytesLabel": "每秒最大快照字节数", - "xpack.snapshotRestore.repositoryForm.typeHDFS.maxSnapshotBytesTitle": "每秒最大快照字节数", "xpack.snapshotRestore.repositoryForm.typeHDFS.pathDescription": "存储数据的文件路径。", "xpack.snapshotRestore.repositoryForm.typeHDFS.pathLabel": "路径 (必填) ", "xpack.snapshotRestore.repositoryForm.typeHDFS.pathTitle": "路径", @@ -21259,21 +21222,12 @@ "xpack.snapshotRestore.repositoryForm.typeS3.cannedAclDescription": "要添加到新 S3 存储库和对象的标准 ACL。", "xpack.snapshotRestore.repositoryForm.typeS3.cannedAclLabel": "标准 ACL", "xpack.snapshotRestore.repositoryForm.typeS3.cannedAclTitle": "标准 ACL", - "xpack.snapshotRestore.repositoryForm.typeS3.chunkSizeDescription": "拍取快照时,将文件拆分成更小的单位。", - "xpack.snapshotRestore.repositoryForm.typeS3.chunkSizeLabel": "块大小", - "xpack.snapshotRestore.repositoryForm.typeS3.chunkSizeTitle": "块大小", "xpack.snapshotRestore.repositoryForm.typeS3.clientDescription": "AWS S3 客户端的名称。", "xpack.snapshotRestore.repositoryForm.typeS3.clientLabel": "客户端", "xpack.snapshotRestore.repositoryForm.typeS3.clientTitle": "客户端", "xpack.snapshotRestore.repositoryForm.typeS3.compressDescription": "压缩快照的索引映射和设置文件。数据文件未压缩。", "xpack.snapshotRestore.repositoryForm.typeS3.compressLabel": "压缩快照", "xpack.snapshotRestore.repositoryForm.typeS3.compressTitle": "快照压缩", - "xpack.snapshotRestore.repositoryForm.typeS3.maxRestoreBytesDescription": "每个节点的快照还原速率。", - "xpack.snapshotRestore.repositoryForm.typeS3.maxRestoreBytesLabel": "每秒最大还原字节数", - "xpack.snapshotRestore.repositoryForm.typeS3.maxRestoreBytesTitle": "每秒最大还原字节数", - "xpack.snapshotRestore.repositoryForm.typeS3.maxSnapshotBytesDescription": "为每个节点创建快照的速率。", - "xpack.snapshotRestore.repositoryForm.typeS3.maxSnapshotBytesLabel": "每秒最大快照字节数", - "xpack.snapshotRestore.repositoryForm.typeS3.maxSnapshotBytesTitle": "每秒最大快照字节数", "xpack.snapshotRestore.repositoryForm.typeS3.readonlyDescription": "仅一个集群应对此存储库有写权限。所有其他集群应为只读。", "xpack.snapshotRestore.repositoryForm.typeS3.readonlyLabel": "只读存储库", "xpack.snapshotRestore.repositoryForm.typeS3.readonlyTitle": "只读", 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/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 }], 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]; 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 () => [ { 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')); }); } 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/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/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')); }); } 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 () => { 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 ], 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); + }, }; } 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/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); }); }); 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); diff --git a/yarn.lock b/yarn.lock index ed20146e4fa5f14..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" @@ -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 "" @@ -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 ""