diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 58ac36ca59abf6..4d8890e3fc99db 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -100,6 +100,7 @@ yarn kbn watch-bazel - @kbn/server-http-tools - @kbn/server-route-repository - @kbn/std +- @kbn/storybook - @kbn/telemetry-utils - @kbn/tinymath - @kbn/ui-framework diff --git a/docs/user/alerting/alerting-getting-started.asciidoc b/docs/user/alerting/alerting-getting-started.asciidoc index 8c17f8ec93b965..b699c56ebd9445 100644 --- a/docs/user/alerting/alerting-getting-started.asciidoc +++ b/docs/user/alerting/alerting-getting-started.asciidoc @@ -136,9 +136,4 @@ Functionally, {kib} alerting differs in that: At a higher level, {kib} alerting allows rich integrations across use cases like <>, <>, <>, and <>. Pre-packaged *rule types* simplify setup and hide the details of complex, domain-specific detections, while providing a consistent interface across {kib}. -[float] -[[alerting-setup-prerequisites]] -== Prerequisites -<> - -- \ No newline at end of file diff --git a/docs/user/alerting/alerting-setup.asciidoc b/docs/user/alerting/alerting-setup.asciidoc index 39f1af0030e0aa..2ae5160069f0aa 100644 --- a/docs/user/alerting/alerting-setup.asciidoc +++ b/docs/user/alerting/alerting-setup.asciidoc @@ -1,8 +1,8 @@ [role="xpack"] [[alerting-setup]] -== Alerting Setup +== Alerting Set up ++++ -Setup +Set up ++++ The Alerting feature is automatically enabled in {kib}, but might require some additional configuration. diff --git a/docs/user/alerting/defining-rules.asciidoc b/docs/user/alerting/defining-rules.asciidoc deleted file mode 100644 index 686a7bbc8a37b9..00000000000000 --- a/docs/user/alerting/defining-rules.asciidoc +++ /dev/null @@ -1,11 +0,0 @@ -[role="xpack"] -[[defining-alerts]] -== Defining rules - -This content has been moved to <>. - -[float] -[[defining-alerts-general-details]] -==== General rule details - -This content has been moved to <>. \ No newline at end of file diff --git a/docs/user/alerting/index.asciidoc b/docs/user/alerting/index.asciidoc index 9ab6a2dc46ebf2..957d99a54ebaa4 100644 --- a/docs/user/alerting/index.asciidoc +++ b/docs/user/alerting/index.asciidoc @@ -1,7 +1,5 @@ include::alerting-getting-started.asciidoc[] include::alerting-setup.asciidoc[] include::create-and-manage-rules.asciidoc[] -include::defining-rules.asciidoc[] -include::rule-management.asciidoc[] include::rule-types.asciidoc[] include::alerting-troubleshooting.asciidoc[] diff --git a/docs/user/alerting/rule-management.asciidoc b/docs/user/alerting/rule-management.asciidoc deleted file mode 100644 index d6349a60e08eb7..00000000000000 --- a/docs/user/alerting/rule-management.asciidoc +++ /dev/null @@ -1,5 +0,0 @@ -[role="xpack"] -[[alert-management]] -== Managing rules - -This content has been moved to <>. \ No newline at end of file diff --git a/docs/user/alerting/rule-types.asciidoc b/docs/user/alerting/rule-types.asciidoc index bb840014fe80fb..f7f57d2f845a09 100644 --- a/docs/user/alerting/rule-types.asciidoc +++ b/docs/user/alerting/rule-types.asciidoc @@ -15,7 +15,7 @@ see {subscriptions}[the subscription page]. [[stack-rules]] === Stack rules -<> are built into {kib}. To access the *Stack Rules* feature and create and edit rules, users require the `all` privilege. See <> for more information. +<> are built into {kib}. To access the *Stack Rules* feature and create and edit rules, users require the `all` privilege. See <> for more information. [cols="2*<"] |=== diff --git a/docs/user/dashboard/vega-reference.asciidoc b/docs/user/dashboard/vega-reference.asciidoc index cc384ec041a9da..6829e129cd3b65 100644 --- a/docs/user/dashboard/vega-reference.asciidoc +++ b/docs/user/dashboard/vega-reference.asciidoc @@ -50,6 +50,11 @@ To learn more, read about https://vega.github.io/vega/docs/specification/#autosize[autosize] in the Vega documentation. +WARNING: Autosize in Vega-Lite has https://vega.github.io/vega-lite/docs/size.html#limitations[several limitations] +that can result in a warning like `Autosize "fit" only works for single views and layered views.` +The recommended fix for this warning is to convert your spec to Vega using the <> +`VEGA_DEBUG.vega_spec` output. + [float] [[vega-theme]] ====== Default theme to match {kib} diff --git a/package.json b/package.json index 41edf31feaa2a7..784052a12fcdd1 100644 --- a/package.json +++ b/package.json @@ -97,8 +97,8 @@ "yarn": "^1.21.1" }, "dependencies": { - "@elastic/apm-rum": "^5.6.1", - "@elastic/apm-rum-react": "^1.2.5", + "@elastic/apm-rum": "^5.8.0", + "@elastic/apm-rum-react": "^1.2.11", "@elastic/charts": "30.1.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.13", @@ -224,7 +224,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", - "elastic-apm-node": "^3.14.0", + "elastic-apm-node": "^3.16.0", "elasticsearch": "^16.7.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", @@ -468,7 +468,7 @@ "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", "@kbn/pm": "link:packages/kbn-pm", - "@kbn/storybook": "link:packages/kbn-storybook", + "@kbn/storybook": "link:bazel-bin/packages/kbn-storybook", "@kbn/telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", "@kbn/test-subj-selector": "link:packages/kbn-test-subj-selector", @@ -841,4 +841,4 @@ "yargs": "^15.4.1", "zlib": "^1.0.5" } -} \ No newline at end of file +} diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 3b95d9dc05d846..bce279545b49c3 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -44,6 +44,7 @@ filegroup( "//packages/kbn-server-http-tools:build", "//packages/kbn-server-route-repository:build", "//packages/kbn-std:build", + "//packages/kbn-storybook:build", "//packages/kbn-telemetry-tools:build", "//packages/kbn-tinymath:build", "//packages/kbn-ui-framework:build", diff --git a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts index a483da152ac895..d208624b69fc5e 100644 --- a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts @@ -95,6 +95,10 @@ export const filterExceptionItems = ( } }, []); + if (entries.length === 0) { + return acc; + } + const item = { ...exception, entries }; if (exceptionListItemSchema.is(item)) { diff --git a/packages/kbn-storybook/BUILD.bazel b/packages/kbn-storybook/BUILD.bazel new file mode 100644 index 00000000000000..e18256aeb8da46 --- /dev/null +++ b/packages/kbn-storybook/BUILD.bazel @@ -0,0 +1,98 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-storybook" +PKG_REQUIRE_NAME = "@kbn/storybook" + +SOURCE_FILES = glob( + [ + "lib/**/*.ts", + "lib/**/*.tsx", + "*.ts", + ], + exclude = ["**/*.test.*"], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "preset/package.json", + "templates/index.ejs", + "package.json", + "README.md", + "preset.js", +] + +SRC_DEPS = [ + "//packages/kbn-dev-utils", + "//packages/kbn-ui-shared-deps", + "@npm//@storybook/addons", + "@npm//@storybook/api", + "@npm//@storybook/components", + "@npm//@storybook/core", + "@npm//@storybook/node-logger", + "@npm//@storybook/react", + "@npm//@storybook/theming", + "@npm//loader-utils", + "@npm//react", + "@npm//webpack", + "@npm//webpack-merge", +] + +TYPES_DEPS = [ + "@npm//@types/loader-utils", + "@npm//@types/node", + "@npm//@types/webpack", + "@npm//@types/webpack-merge", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = [":tsc"] + 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-storybook/package.json b/packages/kbn-storybook/package.json index f2e4c9b3418b1e..f3c12f19a07934 100644 --- a/packages/kbn-storybook/package.json +++ b/packages/kbn-storybook/package.json @@ -7,10 +7,5 @@ "types": "./target/index.d.ts", "kibana": { "devOnly": true - }, - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" } } \ No newline at end of file diff --git a/packages/kbn-storybook/preset.js b/packages/kbn-storybook/preset.js index c1b7195c141b46..be0012a3818b17 100644 --- a/packages/kbn-storybook/preset.js +++ b/packages/kbn-storybook/preset.js @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +// eslint-disable-next-line const webpackConfig = require('./target/webpack.config').default; module.exports = { diff --git a/packages/kbn-storybook/preset/package.json b/packages/kbn-storybook/preset/package.json new file mode 100644 index 00000000000000..7cd7517d64dde0 --- /dev/null +++ b/packages/kbn-storybook/preset/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "main": "../preset.js" +} \ No newline at end of file diff --git a/packages/kbn-storybook/lib/templates/index.ejs b/packages/kbn-storybook/templates/index.ejs similarity index 100% rename from packages/kbn-storybook/lib/templates/index.ejs rename to packages/kbn-storybook/templates/index.ejs diff --git a/packages/kbn-storybook/tsconfig.json b/packages/kbn-storybook/tsconfig.json index 586f5ea32c0560..1f6886c45c505f 100644 --- a/packages/kbn-storybook/tsconfig.json +++ b/packages/kbn-storybook/tsconfig.json @@ -1,14 +1,15 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "target", "skipLibCheck": true, "declaration": true, "declarationMap": true, "sourceMap": true, "sourceRoot": "../../../../packages/kbn-storybook", + "target": "es2015", "types": ["node"] }, - "include": ["*.ts", "lib/**/*.ts", "lib/**/*.tsx", "../../typings/index.d.ts"] + "include": ["*.ts", "lib/**/*.ts", "lib/**/*.tsx"] } diff --git a/packages/kbn-storybook/typings.ts b/packages/kbn-storybook/typings.ts new file mode 100644 index 00000000000000..6c5d8f4da57097 --- /dev/null +++ b/packages/kbn-storybook/typings.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 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. + */ + +// Storybook react doesn't declare this in its typings, but it's there. +declare module '@storybook/react/standalone'; + +// Storybook references this module. It's @ts-ignored in the codebase but when +// built into its dist it strips that out. Add it here to avoid a type checking +// error. +// +// See https://github.com/storybookjs/storybook/issues/11684 +declare module 'react-syntax-highlighter/dist/cjs/create-element'; +declare module 'react-syntax-highlighter/dist/cjs/prism-light'; diff --git a/packages/kbn-storybook/webpack.config.ts b/packages/kbn-storybook/webpack.config.ts index 972caf8d481fe9..41d3ee1f7ee5c3 100644 --- a/packages/kbn-storybook/webpack.config.ts +++ b/packages/kbn-storybook/webpack.config.ts @@ -94,7 +94,7 @@ export default function ({ config: storybookConfig }: { config: Configuration }) return plugin.options && typeof plugin.options.template === 'string'; }); if (htmlWebpackPlugin) { - htmlWebpackPlugin.options.template = require.resolve('../lib/templates/index.ejs'); + htmlWebpackPlugin.options.template = require.resolve('../templates/index.ejs'); } return webpackMerge(storybookConfig, config); diff --git a/packages/kbn-test/src/functional_tests/lib/auth.ts b/packages/kbn-test/src/functional_tests/lib/auth.ts deleted file mode 100644 index abd1e0f9e7d5e9..00000000000000 --- a/packages/kbn-test/src/functional_tests/lib/auth.ts +++ /dev/null @@ -1,188 +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 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 fs from 'fs'; -import util from 'util'; -import { format as formatUrl } from 'url'; -import request from 'request'; -import type { ToolingLog } from '@kbn/dev-utils'; - -export const DEFAULT_SUPERUSER_PASS = 'changeme'; -const readFile = util.promisify(fs.readFile); - -function delay(delayMs: number) { - return new Promise((res) => setTimeout(res, delayMs)); -} - -interface UpdateCredentialsOptions { - port: number; - auth: string; - username: string; - password: string; - retries?: number; - protocol: string; - caCert?: Buffer | string; -} -async function updateCredentials({ - port, - auth, - username, - password, - retries = 10, - protocol, - caCert, -}: UpdateCredentialsOptions): Promise { - const result = await new Promise<{ body: any; httpResponse: request.Response }>( - (resolve, reject) => - request( - { - method: 'PUT', - uri: formatUrl({ - protocol: `${protocol}:`, - auth, - hostname: 'localhost', - port, - pathname: `/_security/user/${username}/_password`, - }), - json: true, - body: { password }, - ca: caCert, - }, - (err, httpResponse, body) => { - if (err) return reject(err); - resolve({ httpResponse, body }); - } - ) - ); - - const { body, httpResponse } = result; - const { statusCode } = httpResponse; - - if (statusCode === 200) { - return; - } - - if (retries > 0) { - await delay(2500); - return await updateCredentials({ - port, - auth, - username, - password, - retries: retries - 1, - protocol, - caCert, - }); - } - - throw new Error(`${statusCode} response, expected 200 -- ${JSON.stringify(body)}`); -} - -interface SetupUsersOptions { - log: ToolingLog; - esPort: number; - updates: Array<{ username: string; password: string; roles?: string[] }>; - protocol?: string; - caPath?: string; -} - -export async function setupUsers({ - log, - esPort, - updates, - protocol = 'http', - caPath, -}: SetupUsersOptions): Promise { - // track the current credentials for the `elastic` user as - // they will likely change as we apply updates - let auth = `elastic:${DEFAULT_SUPERUSER_PASS}`; - const caCert = caPath ? await readFile(caPath) : undefined; - - for (const { username, password, roles } of updates) { - // If working with a built-in user, just change the password - if (['logstash_system', 'elastic', 'kibana'].includes(username)) { - await updateCredentials({ port: esPort, auth, username, password, protocol, caCert }); - log.info('setting %j user password to %j', username, password); - - // If not a builtin user, add them - } else { - await insertUser({ port: esPort, auth, username, password, roles, protocol, caCert }); - log.info('Added %j user with password to %j', username, password); - } - - if (username === 'elastic') { - auth = `elastic:${password}`; - } - } -} - -interface InserUserOptions { - port: number; - auth: string; - username: string; - password: string; - roles?: string[]; - retries?: number; - protocol: string; - caCert?: Buffer | string; -} -async function insertUser({ - port, - auth, - username, - password, - roles = [], - retries = 10, - protocol, - caCert, -}: InserUserOptions): Promise { - const result = await new Promise<{ body: any; httpResponse: request.Response }>( - (resolve, reject) => - request( - { - method: 'POST', - uri: formatUrl({ - protocol: `${protocol}:`, - auth, - hostname: 'localhost', - port, - pathname: `/_security/user/${username}`, - }), - json: true, - body: { password, roles }, - ca: caCert, - }, - (err, httpResponse, body) => { - if (err) return reject(err); - resolve({ httpResponse, body }); - } - ) - ); - - const { body, httpResponse } = result; - const { statusCode } = httpResponse; - if (statusCode === 200) { - return; - } - - if (retries > 0) { - await delay(2500); - return await insertUser({ - port, - auth, - username, - password, - roles, - retries: retries - 1, - protocol, - caCert, - }); - } - - throw new Error(`${statusCode} response, expected 200 -- ${JSON.stringify(body)}`); -} diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts index 7ba9a3c1c4733e..da83d8285a6b5f 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts @@ -12,8 +12,6 @@ import { KIBANA_ROOT } from './paths'; import type { Config } from '../../functional_test_runner/'; import { createTestEsCluster } from '../../es'; -import { setupUsers, DEFAULT_SUPERUSER_PASS } from './auth'; - interface RunElasticsearchOptions { log: ToolingLog; esFrom: string; @@ -34,9 +32,7 @@ export async function runElasticsearch({ const cluster = createTestEsCluster({ port: config.get('servers.elasticsearch.port'), - password: isSecurityEnabled - ? DEFAULT_SUPERUSER_PASS - : config.get('servers.elasticsearch.password'), + password: isSecurityEnabled ? 'changeme' : config.get('servers.elasticsearch.password'), license, log, basePath: resolve(KIBANA_ROOT, '.es'), @@ -49,22 +45,5 @@ export async function runElasticsearch({ await cluster.start(); - if (isSecurityEnabled) { - await setupUsers({ - log, - esPort: config.get('servers.elasticsearch.port'), - updates: [config.get('servers.elasticsearch'), config.get('servers.kibana')], - protocol: config.get('servers.elasticsearch').protocol, - caPath: getRelativeCertificateAuthorityPath(config.get('kbnTestServer.serverArgs')), - }); - } - return cluster; } - -function getRelativeCertificateAuthorityPath(esConfig: string[] = []) { - const caConfig = esConfig.find( - (config) => config.indexOf('--elasticsearch.ssl.certificateAuthorities') === 0 - ); - return caConfig ? caConfig.split('=')[1] : undefined; -} diff --git a/packages/kbn-test/src/index.ts b/packages/kbn-test/src/index.ts index dd5343b0118b3f..af100a33ea3a78 100644 --- a/packages/kbn-test/src/index.ts +++ b/packages/kbn-test/src/index.ts @@ -29,8 +29,6 @@ export { esTestConfig, createTestEsCluster } from './es'; export { kbnTestConfig, kibanaServerTestUser, kibanaTestUser, adminTestUser } from './kbn'; -export { setupUsers, DEFAULT_SUPERUSER_PASS } from './functional_tests/lib/auth'; - export { readConfigFile } from './functional_test_runner/lib/config/read_config_file'; export { runFtrCli } from './functional_test_runner/cli'; diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js index 438b1e0b2e77bd..9d18c8033ff676 100644 --- a/packages/kbn-ui-shared-deps/webpack.config.js +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -7,7 +7,6 @@ */ const Path = require('path'); -const Os = require('os'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); @@ -31,7 +30,8 @@ module.exports = { 'kbn-ui-shared-deps.v8.light': ['@elastic/eui/dist/eui_theme_amsterdam_light.css'], }, context: __dirname, - devtool: 'cheap-source-map', + // cheap-source-map should be used if needed + devtool: false, output: { path: UiSharedDeps.distDir, filename: '[name].js', @@ -39,7 +39,6 @@ module.exports = { devtoolModuleFilenameTemplate: (info) => `kbn-ui-shared-deps/${Path.relative(REPO_ROOT, info.absoluteResourcePath)}`, library: '__kbnSharedDeps__', - futureEmitAssets: true, }, module: { @@ -111,7 +110,7 @@ module.exports = { optimization: { minimizer: [ new CssMinimizerPlugin({ - parallel: Math.min(Os.cpus().length, 2), + parallel: false, minimizerOptions: { preset: [ 'default', @@ -125,7 +124,7 @@ module.exports = { cache: false, sourceMap: false, extractComments: false, - parallel: Math.min(Os.cpus().length, 2), + parallel: false, terserOptions: { compress: true, mangle: true, diff --git a/src/core/public/apm_system.ts b/src/core/public/apm_system.ts index 32fc3303759912..f5af7011e632e8 100644 --- a/src/core/public/apm_system.ts +++ b/src/core/public/apm_system.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import type { ApmBase } from '@elastic/apm-rum'; +import type { ApmBase, AgentConfigOptions } from '@elastic/apm-rum'; import { modifyUrl } from '@kbn/std'; import type { InternalApplicationStart } from './application'; @@ -18,9 +18,8 @@ const HTTP_REQUEST_TRANSACTION_NAME_REGEX = /^(GET|POST|PUT|HEAD|PATCH|DELETE|OP * that lives in the Kibana Platform. */ -interface ApmConfig { - // AgentConfigOptions is not exported from @elastic/apm-rum - active?: boolean; +interface ApmConfig extends AgentConfigOptions { + // Kibana-specific config settings: globalLabels?: Record; } diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 7624a11a6f03fa..ffbd91c645382c 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -7,9 +7,10 @@ */ import { Server } from 'http'; -import { readFileSync } from 'fs'; +import { rmdir, mkdtemp, readFile, writeFile } from 'fs/promises'; import supertest from 'supertest'; import { omit } from 'lodash'; +import { join } from 'path'; import { ByteSizeValue, schema } from '@kbn/config-schema'; import { HttpConfig } from './http_config'; @@ -47,9 +48,9 @@ const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); let certificate: string; let key: string; -beforeAll(() => { - certificate = readFileSync(KBN_CERT_PATH, 'utf8'); - key = readFileSync(KBN_KEY_PATH, 'utf8'); +beforeAll(async () => { + certificate = await readFile(KBN_CERT_PATH, 'utf8'); + key = await readFile(KBN_KEY_PATH, 'utf8'); }); beforeEach(() => { @@ -1409,6 +1410,19 @@ describe('setup contract', () => { }); describe('#registerStaticDir', () => { + const assetFolder = join(__dirname, 'integration_tests', 'fixtures', 'static'); + let tempDir: string; + + beforeAll(async () => { + tempDir = await mkdtemp('cache-test'); + }); + + afterAll(async () => { + if (tempDir) { + await rmdir(tempDir, { recursive: true }); + } + }); + test('does not throw if called after stop', async () => { const { registerStaticDir } = await server.setup(config); await server.stop(); @@ -1416,6 +1430,111 @@ describe('setup contract', () => { registerStaticDir('/path1/{path*}', '/path/to/resource'); }).not.toThrow(); }); + + test('returns correct headers for static assets', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/some_json.json') + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).not.toBeUndefined(); + }); + + test('returns compressed version if present', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/compression_available.json') + .set('accept-encoding', 'gzip') + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).not.toBeUndefined(); + expect(response.get('content-encoding')).toEqual('gzip'); + }); + + test('returns uncompressed version if compressed asset is not available', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/some_json.json') + .set('accept-encoding', 'gzip') + .expect(200); + + expect(response.get('cache-control')).toEqual('must-revalidate'); + expect(response.get('etag')).not.toBeUndefined(); + expect(response.get('content-encoding')).toBeUndefined(); + }); + + test('returns a 304 if etag value matches', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + const response = await supertest(innerServer.listener) + .get('/static/some_json.json') + .expect(200); + + const etag = response.get('etag'); + expect(etag).not.toBeUndefined(); + + await supertest(innerServer.listener) + .get('/static/some_json.json') + .set('If-None-Match', etag) + .expect(304); + }); + + test('serves content if etag values does not match', async () => { + const { registerStaticDir, server: innerServer } = await server.setup(config); + + registerStaticDir('/static/{path*}', assetFolder); + + await server.start(); + + await supertest(innerServer.listener) + .get('/static/some_json.json') + .set('If-None-Match', `"definitely not a valid etag"`) + .expect(200); + }); + + test('dynamically updates depending on the content of the file', async () => { + const tempFile = join(tempDir, 'some_file.json'); + + const { registerStaticDir, server: innerServer } = await server.setup(config); + registerStaticDir('/static/{path*}', tempDir); + + await server.start(); + + await supertest(innerServer.listener).get('/static/some_file.json').expect(404); + + await writeFile(tempFile, `{ "over": 9000 }`); + + let response = await supertest(innerServer.listener) + .get('/static/some_file.json') + .expect(200); + + const etag1 = response.get('etag'); + + await writeFile(tempFile, `{ "over": 42 }`); + + response = await supertest(innerServer.listener).get('/static/some_file.json').expect(200); + + const etag2 = response.get('etag'); + + expect(etag1).not.toEqual(etag2); + }); }); describe('#registerOnPreRouting', () => { diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 8b4c3b9416152f..d43d86d587d060 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -465,7 +465,13 @@ export class HttpServer { lookupCompressed: true, }, }, - options: { auth: false }, + options: { + auth: false, + cache: { + privacy: 'public', + otherwise: 'must-revalidate', + }, + }, }); } diff --git a/src/core/server/http/integration_tests/fixtures/static/compression_available.json b/src/core/server/http/integration_tests/fixtures/static/compression_available.json new file mode 100644 index 00000000000000..1f878fb465cff8 --- /dev/null +++ b/src/core/server/http/integration_tests/fixtures/static/compression_available.json @@ -0,0 +1,3 @@ +{ + "hello": "dolly" +} diff --git a/src/core/server/http/integration_tests/fixtures/static/compression_available.json.gz b/src/core/server/http/integration_tests/fixtures/static/compression_available.json.gz new file mode 100644 index 00000000000000..e77819d2e8e59a Binary files /dev/null and b/src/core/server/http/integration_tests/fixtures/static/compression_available.json.gz differ diff --git a/src/core/server/http/integration_tests/fixtures/static/some_json.json b/src/core/server/http/integration_tests/fixtures/static/some_json.json new file mode 100644 index 00000000000000..c8c4105eb57cda --- /dev/null +++ b/src/core/server/http/integration_tests/fixtures/static/some_json.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index ba22ecb3b63768..2995ffd08e5c07 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -7,15 +7,7 @@ */ import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; -import { - createTestEsCluster, - DEFAULT_SUPERUSER_PASS, - esTestConfig, - kbnTestConfig, - kibanaServerTestUser, - kibanaTestUser, - setupUsers, -} from '@kbn/test'; +import { createTestEsCluster, esTestConfig, kibanaServerTestUser, kibanaTestUser } from '@kbn/test'; import { defaultsDeep } from 'lodash'; import { resolve } from 'path'; import { BehaviorSubject } from 'rxjs'; @@ -208,7 +200,6 @@ export function createTestServers({ defaultsDeep({}, settings.es ?? {}, { log, license, - password: license === 'trial' ? DEFAULT_SUPERUSER_PASS : undefined, }) ); @@ -224,19 +215,7 @@ export function createTestServers({ await es.start(); if (['gold', 'trial'].includes(license)) { - await setupUsers({ - log, - esPort: esTestConfig.getUrlParts().port, - updates: [ - ...usersToBeAdded, - // user elastic - esTestConfig.getUrlParts() as { username: string; password: string }, - // user kibana - kbnTestConfig.getUrlParts() as { username: string; password: string }, - ], - }); - - // Override provided configs, we know what the elastic user is now + // Override provided configs kbnSettings.elasticsearch = { hosts: [esTestConfig.getUrl()], username: kibanaServerTestUser.username, diff --git a/src/dev/run_licenses_csv_report.js b/src/dev/run_licenses_csv_report.js index 8a612c9e3d8784..1923eddff33e92 100644 --- a/src/dev/run_licenses_csv_report.js +++ b/src/dev/run_licenses_csv_report.js @@ -71,7 +71,8 @@ run( licenses: [ 'Custom;https://www.redhat.com/licenses/EULA_Red_Hat_Universal_Base_Image_English_20190422.pdf', ], - sourceURL: 'https://oss-dependencies.elastic.co/redhat/ubi/ubi-minimal-8-source.tar.gz', + sourceURL: + 'https://oss-dependencies.elastic.co/red-hat-universal-base-image-minimal/8/ubi-minimal-8-source.tar.gz', } ); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx index e60dabd1d8d8c7..26a3c482e9d3cb 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx @@ -8,7 +8,7 @@ import './discover_field.scss'; -import React, { useState } from 'react'; +import React, { useState, useCallback, memo } from 'react'; import { EuiPopover, EuiPopoverTitle, @@ -29,6 +29,172 @@ import { IndexPatternField, IndexPattern } from '../../../../../../../data/publi import { getFieldTypeName } from './lib/get_field_type_name'; import { DiscoverFieldDetailsFooter } from './discover_field_details_footer'; +function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : ''; +} + +const FieldInfoIcon: React.FC = memo(() => ( + + + +)); + +const DiscoverFieldTypeIcon: React.FC<{ field: IndexPatternField }> = memo(({ field }) => ( + +)); + +const FieldName: React.FC<{ field: IndexPatternField }> = memo(({ field }) => { + const title = + field.displayName !== field.name + ? i18n.translate('discover.field.title', { + defaultMessage: '{fieldName} ({fieldDisplayName})', + values: { + fieldName: field.name, + fieldDisplayName: field.displayName, + }, + }) + : field.displayName; + + return ( + + {wrapOnDot(field.displayName)} + + ); +}); + +interface ActionButtonProps { + field: IndexPatternField; + isSelected?: boolean; + alwaysShow: boolean; + toggleDisplay: (field: IndexPatternField) => void; +} + +const ActionButton: React.FC = memo( + ({ field, isSelected, alwaysShow, toggleDisplay }) => { + const actionBtnClassName = classNames('dscSidebarItem__action', { + ['dscSidebarItem__mobile']: alwaysShow, + }); + if (field.name === '_source') { + return null; + } + if (!isSelected) { + return ( + + ) => { + if (ev.type === 'click') { + ev.currentTarget.focus(); + } + ev.preventDefault(); + ev.stopPropagation(); + toggleDisplay(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', { + defaultMessage: 'Add {field} to table', + values: { field: field.name }, + })} + /> + + ); + } else { + return ( + + ) => { + if (ev.type === 'click') { + ev.currentTarget.focus(); + } + ev.preventDefault(); + ev.stopPropagation(); + toggleDisplay(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={i18n.translate( + 'discover.fieldChooser.discoverField.removeButtonAriaLabel', + { + defaultMessage: 'Remove {field} from table', + values: { field: field.name }, + } + )} + /> + + ); + } + } +); + +interface MultiFieldsProps { + multiFields: NonNullable; + toggleDisplay: (field: IndexPatternField) => void; + alwaysShowActionButton: boolean; +} + +const MultiFields: React.FC = memo( + ({ multiFields, toggleDisplay, alwaysShowActionButton }) => ( + + +
+ {i18n.translate('discover.fieldChooser.discoverField.multiFields', { + defaultMessage: 'Multi fields', + })} +
+
+ {multiFields.map((entry) => ( + } + fieldAction={ + + } + fieldName={} + key={entry.field.name} + /> + ))} +
+ ) +); + export interface DiscoverFieldProps { /** * Determines whether add/remove button is displayed not only when focused @@ -85,7 +251,7 @@ export interface DiscoverFieldProps { onDeleteField?: (fieldName: string) => void; } -export function DiscoverField({ +function DiscoverFieldComponent({ alwaysShowActionButton = false, field, indexPattern, @@ -99,133 +265,22 @@ export function DiscoverField({ onEditField, onDeleteField, }: DiscoverFieldProps) { - const addLabelAria = i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', { - defaultMessage: 'Add {field} to table', - values: { field: field.name }, - }); - const removeLabelAria = i18n.translate( - 'discover.fieldChooser.discoverField.removeButtonAriaLabel', - { - defaultMessage: 'Remove {field} from table', - values: { field: field.name }, - } - ); - const [infoIsOpen, setOpen] = useState(false); - const toggleDisplay = (f: IndexPatternField, isSelected: boolean) => { - if (isSelected) { - onRemoveField(f.name); - } else { - onAddField(f.name); - } - }; + const toggleDisplay = useCallback( + (f: IndexPatternField) => { + if (selected) { + onRemoveField(f.name); + } else { + onAddField(f.name); + } + }, + [onAddField, onRemoveField, selected] + ); - function togglePopover() { + const togglePopover = useCallback(() => { setOpen(!infoIsOpen); - } - - function wrapOnDot(str?: string) { - // u200B is a non-width white-space character, which allows - // the browser to efficiently word-wrap right after the dot - // without us having to draw a lot of extra DOM elements, etc - return str ? str.replace(/\./g, '.\u200B') : ''; - } - - const getDscFieldIcon = (indexPatternField: IndexPatternField) => { - return ( - - ); - }; - - const dscFieldIcon = getDscFieldIcon(field); - - const getTitle = (indexPatternField: IndexPatternField) => { - return indexPatternField.displayName !== indexPatternField.name - ? i18n.translate('discover.field.title', { - defaultMessage: '{fieldName} ({fieldDisplayName})', - values: { - fieldName: indexPatternField.name, - fieldDisplayName: indexPatternField.displayName, - }, - }) - : indexPatternField.displayName; - }; - - const getFieldName = (indexPatternField: IndexPatternField) => { - return ( - - {wrapOnDot(indexPatternField.displayName)} - - ); - }; - const fieldName = getFieldName(field); - - const actionBtnClassName = classNames('dscSidebarItem__action', { - ['dscSidebarItem__mobile']: alwaysShowActionButton, - }); - const getActionButton = (f: IndexPatternField, isSelected?: boolean) => { - if (f.name !== '_source' && !isSelected) { - return ( - - ) => { - if (ev.type === 'click') { - ev.currentTarget.focus(); - } - ev.preventDefault(); - ev.stopPropagation(); - toggleDisplay(f, false); - }} - data-test-subj={`fieldToggle-${f.name}`} - aria-label={addLabelAria} - /> - - ); - } else if (f.name !== '_source' && isSelected) { - return ( - - ) => { - if (ev.type === 'click') { - ev.currentTarget.focus(); - } - ev.preventDefault(); - ev.stopPropagation(); - toggleDisplay(f, isSelected); - }} - data-test-subj={`fieldToggle-${f.name}`} - aria-label={removeLabelAria} - /> - - ); - } - }; - - const actionButton = getActionButton(field, selected); + }, [infoIsOpen]); if (field.type === '_source') { return ( @@ -233,71 +288,20 @@ export function DiscoverField({ size="s" className="dscSidebarItem" dataTestSubj={`field-${field.name}-showDetails`} - fieldIcon={dscFieldIcon} - fieldAction={actionButton} - fieldName={fieldName} + fieldIcon={} + fieldAction={ + + } + fieldName={} /> ); } - const getFieldInfoIcon = () => { - if (field.type !== 'conflict') { - return null; - } - return ( - - - - ); - }; - - const fieldInfoIcon = getFieldInfoIcon(); - - const shouldRenderMultiFields = !!multiFields; - const renderMultiFields = () => { - if (!multiFields) { - return null; - } - return ( - - -
- {i18n.translate('discover.fieldChooser.discoverField.multiFields', { - defaultMessage: 'Multi fields', - })} -
-
- {multiFields.map((entry) => ( - {}} - dataTestSubj={`field-${entry.field.name}-showDetails`} - fieldIcon={getDscFieldIcon(entry.field)} - fieldAction={getActionButton(entry.field, entry.isSelected)} - fieldName={getFieldName(entry.field)} - key={entry.field.name} - /> - ))} -
- ); - }; - const isRuntimeField = Boolean(indexPattern.getFieldByName(field.name)?.runtimeField); const isUnknownField = field.type === 'unknown' || field.type === 'unknown_selected'; const canEditField = onEditField && (!isUnknownField || isRuntimeField); @@ -334,9 +338,7 @@ export function DiscoverField({ > { - if (onDeleteField) { - onDeleteField(field.name); - } + onDeleteField?.(field.name); }} iconType="trash" data-test-subj={`discoverFieldListPanelDelete-${field.name}`} @@ -352,6 +354,8 @@ export function DiscoverField({ ); + const details = getDetails(field); + return ( { - togglePopover(); - }} + onClick={togglePopover} dataTestSubj={`field-${field.name}-showDetails`} - fieldIcon={dscFieldIcon} - fieldAction={actionButton} - fieldName={fieldName} - fieldInfoIcon={fieldInfoIcon} + fieldIcon={} + fieldAction={ + + } + fieldName={} + fieldInfoIcon={field.type === 'conflict' && } /> } isOpen={infoIsOpen} @@ -384,26 +393,33 @@ export function DiscoverField({ {infoIsOpen && ( - - )} - {shouldRenderMultiFields ? ( <> - {renderMultiFields()} - + {multiFields && ( + + )} + {!details.error && ( + + )} - ) : null} + )} ); } + +export const DiscoverField = memo(DiscoverFieldComponent); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx index d7008ba3e310f3..ffa7b30de5280d 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx @@ -20,7 +20,6 @@ import { import { Bucket, FieldDetails } from './types'; import { IndexPatternField, IndexPattern } from '../../../../../../../data/public'; import './discover_field_details.scss'; -import { DiscoverFieldDetailsFooter } from './discover_field_details_footer'; interface DiscoverFieldDetailsProps { field: IndexPatternField; @@ -28,7 +27,6 @@ interface DiscoverFieldDetailsProps { details: FieldDetails; onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; - showFooter?: boolean; } export function DiscoverFieldDetails({ @@ -37,7 +35,6 @@ export function DiscoverFieldDetails({ details, onAddFilter, trackUiMetric, - showFooter = true, }: DiscoverFieldDetailsProps) { const warnings = getWarnings(field); const [showVisualizeLink, setShowVisualizeLink] = useState(false); @@ -111,14 +108,6 @@ export function DiscoverFieldDetails({ )} - {!details.error && showFooter && ( - - )} ); } diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx index 0bebec61657b4a..7fbbf6fd3ffdc7 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx @@ -21,6 +21,7 @@ import { EuiPageSideBar, useResizeObserver, } from '@elastic/eui'; +import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect'; import { isEqual, sortBy } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -205,7 +206,7 @@ export function DiscoverSidebar({ return result; }, [fields]); - const multiFields = useMemo(() => { + const calculateMultiFields = () => { if (!useNewFieldsApi || !fields) { return undefined; } @@ -224,7 +225,13 @@ export function DiscoverSidebar({ map.set(parent, value); }); return map; - }, [fields, useNewFieldsApi, selectedFields]); + }; + + const [multiFields, setMultiFields] = useState(() => calculateMultiFields()); + + useShallowCompareEffect(() => { + setMultiFields(calculateMultiFields()); + }, [fields, selectedFields, useNewFieldsApi]); const deleteField = useMemo( () => diff --git a/src/plugins/expressions/common/expression_functions/specs/math_column.ts b/src/plugins/expressions/common/expression_functions/specs/math_column.ts index 0ff8faf3ce55a1..633d912c29502f 100644 --- a/src/plugins/expressions/common/expression_functions/specs/math_column.ts +++ b/src/plugins/expressions/common/expression_functions/specs/math_column.ts @@ -69,25 +69,40 @@ export const mathColumn: ExpressionFunctionDefinition< return id === args.id; }); if (existingColumnIndex > -1) { - throw new Error('ID must be unique'); + throw new Error( + i18n.translate('expressions.functions.mathColumn.uniqueIdError', { + defaultMessage: 'ID must be unique', + }) + ); } const newRows = input.rows.map((row) => { - return { - ...row, - [args.id]: math.fn( - { - type: 'datatable', - columns: input.columns, - rows: [row], - }, - { - expression: args.expression, - onError: args.onError, - }, - context - ), - }; + const result = math.fn( + { + type: 'datatable', + columns: input.columns, + rows: [row], + }, + { + expression: args.expression, + onError: args.onError, + }, + context + ); + + if (Array.isArray(result)) { + if (result.length === 1) { + return { ...row, [args.id]: result[0] }; + } + throw new Error( + i18n.translate('expressions.functions.mathColumn.arrayValueError', { + defaultMessage: 'Cannot perform math on array values at {name}', + values: { name: args.name }, + }) + ); + } + + return { ...row, [args.id]: result }; }); const type = newRows.length ? getType(newRows[0][args.id]) : 'null'; const newColumn: DatatableColumn = { diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/math_column.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/math_column.test.ts index bc6699a2b689bf..e0fb0a3a9f23d5 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/math_column.test.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/math_column.test.ts @@ -34,6 +34,30 @@ describe('mathColumn', () => { }); }); + it('extracts a single array value, but not a multi-value array', () => { + const arrayTable = { + ...testTable, + rows: [ + { + name: 'product1', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: [605, 500], + quantity: [100], + in_stock: true, + }, + ], + }; + const args = { + id: 'output', + name: 'output', + expression: 'quantity', + }; + expect(fn(arrayTable, args).rows[0].output).toEqual(100); + expect(() => fn(arrayTable, { ...args, expression: 'price' })).toThrowError( + `Cannot perform math on array values` + ); + }); + it('handles onError', () => { const args = { id: 'output', diff --git a/src/plugins/expressions/common/expression_types/get_type.test.ts b/src/plugins/expressions/common/expression_types/get_type.test.ts index 6eca54d2aea44a..b1a9cb703182fe 100644 --- a/src/plugins/expressions/common/expression_types/get_type.test.ts +++ b/src/plugins/expressions/common/expression_types/get_type.test.ts @@ -30,6 +30,7 @@ describe('getType()', () => { }); test('throws if object has no .type property', () => { + expect(() => getType([])).toThrow(); expect(() => getType({})).toThrow(); expect(() => getType({ _type: 'foo' })).toThrow(); expect(() => getType({ tipe: 'foo' })).toThrow(); diff --git a/src/plugins/expressions/common/expression_types/get_type.ts b/src/plugins/expressions/common/expression_types/get_type.ts index e29a610b3ed90f..052508df413292 100644 --- a/src/plugins/expressions/common/expression_types/get_type.ts +++ b/src/plugins/expressions/common/expression_types/get_type.ts @@ -8,6 +8,9 @@ export function getType(node: any) { if (node == null) return 'null'; + if (Array.isArray(node)) { + throw new Error('Unexpected array value encountered.'); + } if (typeof node === 'object') { if (!node.type) throw new Error('Objects must have a type property'); return node.type; diff --git a/src/plugins/home/public/application/components/tutorial_directory.js b/src/plugins/home/public/application/components/tutorial_directory.js index 1fda865ebd8476..d7e6c07d6dd183 100644 --- a/src/plugins/home/public/application/components/tutorial_directory.js +++ b/src/plugins/home/public/application/components/tutorial_directory.js @@ -9,27 +9,15 @@ import _ from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; +import { EuiFlexItem, EuiFlexGrid, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { Synopsis } from './synopsis'; import { SampleDataSetCards } from './sample_data_set_cards'; import { getServices } from '../kibana_services'; - -import { - EuiPage, - EuiTabs, - EuiTab, - EuiFlexItem, - EuiFlexGrid, - EuiFlexGroup, - EuiSpacer, - EuiTitle, - EuiPageBody, -} from '@elastic/eui'; - +import { KibanaPageTemplate } from '../../../../kibana_react/public'; import { getTutorials } from '../load_tutorials'; -import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - const ALL_TAB_ID = 'all'; const SAMPLE_DATA_TAB_ID = 'sampleData'; @@ -184,17 +172,13 @@ class TutorialDirectoryUi extends React.Component { }); }; - renderTabs = () => { - return this.tabs.map((tab, index) => ( - this.onSelectedTabChanged(tab.id)} - isSelected={tab.id === this.state.selectedTabId} - key={index} - > - {tab.name} - - )); + getTabs = () => { + return this.tabs.map((tab) => ({ + label: tab.name, + onClick: () => this.onSelectedTabChanged(tab.id), + isSelected: tab.id === this.state.selectedTabId, + 'data-test-subj': `homeTab-${tab.id}`, + })); }; renderTabContent = () => { @@ -258,41 +242,31 @@ class TutorialDirectoryUi extends React.Component { ) : null; }; - renderHeader = () => { - const notices = this.renderNotices(); + render() { const headerLinks = this.renderHeaderLinks(); + const tabs = this.getTabs(); + const notices = this.renderNotices(); return ( - <> - - - -

- -

-
-
- {headerLinks ? {headerLinks} : null} -
- {notices} - - ); - }; - - render() { - return ( - - - {this.renderHeader()} - - {this.renderTabs()} - - {this.renderTabContent()} - - + + ), + tabs, + rightSideItems: headerLinks ? [headerLinks] : [], + }} + > + {notices && ( + <> + {notices} + + + )} + {this.renderTabContent()} + ); } } diff --git a/src/plugins/kibana_react/public/field_button/field_button.scss b/src/plugins/kibana_react/public/field_button/field_button.scss index 43f60e4503576c..f71e097ab71380 100644 --- a/src/plugins/kibana_react/public/field_button/field_button.scss +++ b/src/plugins/kibana_react/public/field_button/field_button.scss @@ -38,6 +38,7 @@ padding: $euiSizeS; display: flex; align-items: flex-start; + line-height: normal; } .kbnFieldButton__fieldIcon { diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/constants.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/constants.ts new file mode 100644 index 00000000000000..1753c87c9d0054 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/constants.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * Roll daily indices every 24h + */ +export const ROLL_DAILY_INDICES_INTERVAL = 24 * 60 * 60 * 1000; + +/** + * Start rolling indices after 5 minutes up + */ +export const ROLL_INDICES_START = 5 * 60 * 1000; + +/** + * Reset the event loop delay historgram every 1 hour + */ +export const MONITOR_EVENT_LOOP_DELAYS_INTERVAL = 1 * 60 * 60 * 1000; + +/** + * Reset the event loop delay historgram every 24h + */ +export const MONITOR_EVENT_LOOP_DELAYS_RESET = 24 * 60 * 60 * 1000; + +/** + * Start monitoring the event loop delays after 1 minute + */ +export const MONITOR_EVENT_LOOP_DELAYS_START = 1 * 60 * 1000; + +/** + * Event loop monitoring sampling rate in milliseconds. + */ +export const MONITOR_EVENT_LOOP_DELAYS_RESOLUTION = 10; diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.mocks.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.mocks.ts new file mode 100644 index 00000000000000..6b03d3cc5cbd12 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.mocks.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 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 moment from 'moment'; +import type { IntervalHistogram } from './event_loop_delays'; + +export const mockMonitorEnable = jest.fn(); +export const mockMonitorPercentile = jest.fn(); +export const mockMonitorReset = jest.fn(); +export const mockMonitorDisable = jest.fn(); +export const monitorEventLoopDelay = jest.fn().mockReturnValue({ + enable: mockMonitorEnable, + percentile: mockMonitorPercentile, + disable: mockMonitorDisable, + reset: mockMonitorReset, +}); + +jest.doMock('perf_hooks', () => ({ + monitorEventLoopDelay, +})); + +function createMockHistogram(overwrites: Partial = {}): IntervalHistogram { + const now = moment(); + + return { + min: 9093120, + max: 53247999, + mean: 11993238.600747818, + exceeds: 0, + stddev: 1168191.9357543814, + fromTimestamp: now.startOf('day').toISOString(), + lastUpdatedAt: now.toISOString(), + percentiles: { + '50': 12607487, + '75': 12615679, + '95': 12648447, + '99': 12713983, + }, + ...overwrites, + }; +} + +export const mocked = { + createHistogram: createMockHistogram, +}; diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.test.ts new file mode 100644 index 00000000000000..d03236a9756b3b --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.test.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Subject } from 'rxjs'; + +import { + mockMonitorEnable, + mockMonitorPercentile, + monitorEventLoopDelay, + mockMonitorReset, + mockMonitorDisable, +} from './event_loop_delays.mocks'; +import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; +import { startTrackingEventLoopDelaysUsage, EventLoopDelaysCollector } from './event_loop_delays'; + +describe('EventLoopDelaysCollector', () => { + jest.useFakeTimers('modern'); + const mockNow = jest.getRealSystemTime(); + jest.setSystemTime(mockNow); + + beforeEach(() => jest.clearAllMocks()); + afterAll(() => jest.useRealTimers()); + + test('#constructor enables monitoring', () => { + new EventLoopDelaysCollector(); + expect(monitorEventLoopDelay).toBeCalledWith({ resolution: 10 }); + expect(mockMonitorEnable).toBeCalledTimes(1); + }); + + test('#collect returns event loop delays histogram', () => { + const eventLoopDelaysCollector = new EventLoopDelaysCollector(); + const histogramData = eventLoopDelaysCollector.collect(); + expect(mockMonitorPercentile).toHaveBeenNthCalledWith(1, 50); + expect(mockMonitorPercentile).toHaveBeenNthCalledWith(2, 75); + expect(mockMonitorPercentile).toHaveBeenNthCalledWith(3, 95); + expect(mockMonitorPercentile).toHaveBeenNthCalledWith(4, 99); + + expect(Object.keys(histogramData)).toMatchInlineSnapshot(` + Array [ + "min", + "max", + "mean", + "exceeds", + "stddev", + "fromTimestamp", + "lastUpdatedAt", + "percentiles", + ] + `); + }); + test('#reset resets histogram data', () => { + const eventLoopDelaysCollector = new EventLoopDelaysCollector(); + eventLoopDelaysCollector.reset(); + expect(mockMonitorReset).toBeCalledTimes(1); + }); + test('#stop disables monitoring event loop delays', () => { + const eventLoopDelaysCollector = new EventLoopDelaysCollector(); + eventLoopDelaysCollector.stop(); + expect(mockMonitorDisable).toBeCalledTimes(1); + }); +}); + +describe('startTrackingEventLoopDelaysUsage', () => { + const mockInternalRepository = savedObjectsRepositoryMock.create(); + const stopMonitoringEventLoop$ = new Subject(); + + beforeAll(() => jest.useFakeTimers('modern')); + beforeEach(() => jest.clearAllMocks()); + afterEach(() => stopMonitoringEventLoop$.next()); + + it('initializes EventLoopDelaysCollector and starts timer', () => { + const collectionStartDelay = 1000; + startTrackingEventLoopDelaysUsage( + mockInternalRepository, + stopMonitoringEventLoop$, + collectionStartDelay + ); + + expect(monitorEventLoopDelay).toBeCalledTimes(1); + expect(mockMonitorPercentile).toBeCalledTimes(0); + jest.advanceTimersByTime(collectionStartDelay); + expect(mockMonitorPercentile).toBeCalled(); + }); + + it('stores event loop delays every collectionInterval duration', () => { + const collectionStartDelay = 100; + const collectionInterval = 1000; + startTrackingEventLoopDelaysUsage( + mockInternalRepository, + stopMonitoringEventLoop$, + collectionStartDelay, + collectionInterval + ); + + expect(mockInternalRepository.create).toBeCalledTimes(0); + jest.advanceTimersByTime(collectionStartDelay); + expect(mockInternalRepository.create).toBeCalledTimes(1); + jest.advanceTimersByTime(collectionInterval); + expect(mockInternalRepository.create).toBeCalledTimes(2); + jest.advanceTimersByTime(collectionInterval); + expect(mockInternalRepository.create).toBeCalledTimes(3); + }); + + it('resets histogram every histogramReset duration', () => { + const collectionStartDelay = 0; + const collectionInterval = 1000; + const histogramReset = 5000; + startTrackingEventLoopDelaysUsage( + mockInternalRepository, + stopMonitoringEventLoop$, + collectionStartDelay, + collectionInterval, + histogramReset + ); + + expect(mockMonitorReset).toBeCalledTimes(0); + jest.advanceTimersByTime(collectionInterval * 5); + expect(mockMonitorReset).toBeCalledTimes(1); + jest.advanceTimersByTime(collectionInterval * 5); + expect(mockMonitorReset).toBeCalledTimes(2); + }); + + it('stops monitoring event loop delays once stopMonitoringEventLoop$.next is called', () => { + startTrackingEventLoopDelaysUsage(mockInternalRepository, stopMonitoringEventLoop$); + + expect(mockMonitorDisable).toBeCalledTimes(0); + stopMonitoringEventLoop$.next(); + expect(mockMonitorDisable).toBeCalledTimes(1); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.ts new file mode 100644 index 00000000000000..655cba580fc5df --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EventLoopDelayMonitor } from 'perf_hooks'; +import { monitorEventLoopDelay } from 'perf_hooks'; +import { takeUntil, finalize, map } from 'rxjs/operators'; +import { Observable, timer } from 'rxjs'; +import type { ISavedObjectsRepository } from 'kibana/server'; +import { + MONITOR_EVENT_LOOP_DELAYS_START, + MONITOR_EVENT_LOOP_DELAYS_INTERVAL, + MONITOR_EVENT_LOOP_DELAYS_RESET, + MONITOR_EVENT_LOOP_DELAYS_RESOLUTION, +} from './constants'; +import { storeHistogram } from './saved_objects'; + +export interface IntervalHistogram { + fromTimestamp: string; + lastUpdatedAt: string; + min: number; + max: number; + mean: number; + exceeds: number; + stddev: number; + percentiles: { + 50: number; + 75: number; + 95: number; + 99: number; + }; +} + +export class EventLoopDelaysCollector { + private readonly loopMonitor: EventLoopDelayMonitor; + private fromTimestamp: Date; + + constructor() { + const monitor = monitorEventLoopDelay({ + resolution: MONITOR_EVENT_LOOP_DELAYS_RESOLUTION, + }); + monitor.enable(); + this.fromTimestamp = new Date(); + this.loopMonitor = monitor; + } + + public collect(): IntervalHistogram { + const { min, max, mean, exceeds, stddev } = this.loopMonitor; + + return { + min, + max, + mean, + exceeds, + stddev, + fromTimestamp: this.fromTimestamp.toISOString(), + lastUpdatedAt: new Date().toISOString(), + percentiles: { + 50: this.loopMonitor.percentile(50), + 75: this.loopMonitor.percentile(75), + 95: this.loopMonitor.percentile(95), + 99: this.loopMonitor.percentile(99), + }, + }; + } + + public reset() { + this.loopMonitor.reset(); + this.fromTimestamp = new Date(); + } + + public stop() { + this.loopMonitor.disable(); + } +} + +/** + * The monitoring of the event loop starts immediately. + * The first collection of the histogram happens after 1 minute. + * The daily histogram data is updated every 1 hour. + */ +export function startTrackingEventLoopDelaysUsage( + internalRepository: ISavedObjectsRepository, + stopMonitoringEventLoop$: Observable, + collectionStartDelay = MONITOR_EVENT_LOOP_DELAYS_START, + collectionInterval = MONITOR_EVENT_LOOP_DELAYS_INTERVAL, + histogramReset = MONITOR_EVENT_LOOP_DELAYS_RESET +) { + const eventLoopDelaysCollector = new EventLoopDelaysCollector(); + + const resetOnCount = Math.ceil(histogramReset / collectionInterval); + timer(collectionStartDelay, collectionInterval) + .pipe( + map((i) => (i + 1) % resetOnCount === 0), + takeUntil(stopMonitoringEventLoop$), + finalize(() => eventLoopDelaysCollector.stop()) + ) + .subscribe(async (shouldReset) => { + const histogram = eventLoopDelaysCollector.collect(); + if (shouldReset) { + eventLoopDelaysCollector.reset(); + } + await storeHistogram(histogram, internalRepository); + }); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.test.ts new file mode 100644 index 00000000000000..06c51f6afa3a88 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.test.ts @@ -0,0 +1,84 @@ +/* + * 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 { + Collector, + createUsageCollectionSetupMock, + createCollectorFetchContextMock, +} from '../../../../usage_collection/server/mocks'; +import { registerEventLoopDelaysCollector } from './event_loop_delays_usage_collector'; +import { loggingSystemMock, savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; +import type { SavedObjectsFindResponse } from '../../../../../core/server'; + +const logger = loggingSystemMock.createLogger(); + +describe('registerEventLoopDelaysCollector', () => { + let collector: Collector; + const mockRegisterType = jest.fn(); + const mockInternalRepository = savedObjectsRepositoryMock.create(); + const mockGetSavedObjectsClient = () => mockInternalRepository; + + const usageCollectionMock = createUsageCollectionSetupMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = new Collector(logger, config); + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + + const collectorFetchContext = createCollectorFetchContextMock(); + + beforeAll(() => { + registerEventLoopDelaysCollector( + logger, + usageCollectionMock, + mockRegisterType, + mockGetSavedObjectsClient + ); + }); + + it('registers event_loop_delays collector', () => { + expect(collector).not.toBeUndefined(); + expect(collector.type).toBe('event_loop_delays'); + }); + + it('registers savedObjectType "event_loop_delays_daily"', () => { + expect(mockRegisterType).toBeCalledTimes(1); + expect(mockRegisterType).toBeCalledWith( + expect.objectContaining({ + name: 'event_loop_delays_daily', + }) + ); + }); + + it('returns objects from event_loop_delays_daily from fetch function', async () => { + const mockFind = jest.fn().mockResolvedValue(({ + saved_objects: [{ attributes: { test: 1 } }], + } as unknown) as SavedObjectsFindResponse); + mockInternalRepository.find = mockFind; + const fetchResult = await collector.fetch(collectorFetchContext); + + expect(fetchResult).toMatchInlineSnapshot(` + Object { + "daily": Array [ + Object { + "test": 1, + }, + ], + } + `); + expect(mockFind).toBeCalledTimes(1); + expect(mockFind.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "sortField": "updated_at", + "sortOrder": "desc", + "type": "event_loop_delays_daily", + }, + ] + `); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.ts new file mode 100644 index 00000000000000..774e021d7a549e --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/event_loop_delays_usage_collector.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { timer } from 'rxjs'; +import { SavedObjectsServiceSetup, ISavedObjectsRepository, Logger } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { rollDailyData } from './rollups'; +import { registerSavedObjectTypes, EventLoopDelaysDaily } from './saved_objects'; +import { eventLoopDelaysUsageSchema, EventLoopDelaysUsageReport } from './schema'; +import { SAVED_OBJECTS_DAILY_TYPE } from './saved_objects'; +import { ROLL_DAILY_INDICES_INTERVAL, ROLL_INDICES_START } from './constants'; + +export function registerEventLoopDelaysCollector( + logger: Logger, + usageCollection: UsageCollectionSetup, + registerType: SavedObjectsServiceSetup['registerType'], + getSavedObjectsClient: () => ISavedObjectsRepository | undefined +) { + registerSavedObjectTypes(registerType); + + timer(ROLL_INDICES_START, ROLL_DAILY_INDICES_INTERVAL).subscribe(() => + rollDailyData(logger, getSavedObjectsClient()) + ); + + const collector = usageCollection.makeUsageCollector({ + type: 'event_loop_delays', + isReady: () => typeof getSavedObjectsClient() !== 'undefined', + schema: eventLoopDelaysUsageSchema, + fetch: async () => { + const internalRepository = getSavedObjectsClient(); + if (!internalRepository) { + return { daily: [] }; + } + + const { saved_objects: savedObjects } = await internalRepository.find({ + type: SAVED_OBJECTS_DAILY_TYPE, + sortField: 'updated_at', + sortOrder: 'desc', + }); + + return { + daily: savedObjects.map((savedObject) => savedObject.attributes), + }; + }, + }); + + usageCollection.registerCollector(collector); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/index.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/index.ts new file mode 100644 index 00000000000000..693b173c2759ea --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { startTrackingEventLoopDelaysUsage } from './event_loop_delays'; +export { registerEventLoopDelaysCollector } from './event_loop_delays_usage_collector'; +export { SAVED_OBJECTS_DAILY_TYPE } from './saved_objects'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.test.ts new file mode 100644 index 00000000000000..cb59d6a44b07e6 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { rollDailyData } from './daily'; +import { loggingSystemMock, savedObjectsRepositoryMock } from '../../../../../../core/server/mocks'; +import type { SavedObjectsFindResponse } from '../../../../../../core/server'; + +describe('rollDailyData', () => { + const logger = loggingSystemMock.createLogger(); + const mockSavedObjectsClient = savedObjectsRepositoryMock.create(); + + beforeEach(() => jest.clearAllMocks()); + + it('returns false if no savedObjectsClient', async () => { + await rollDailyData(logger, undefined); + expect(mockSavedObjectsClient.find).toBeCalledTimes(0); + }); + + it('calls delete on documents older than 3 days', async () => { + mockSavedObjectsClient.find.mockResolvedValueOnce({ + saved_objects: [{ id: 'test_id_1' }, { id: 'test_id_2' }], + } as SavedObjectsFindResponse); + + await rollDailyData(logger, mockSavedObjectsClient); + + expect(mockSavedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsClient.delete).toBeCalledTimes(2); + expect(mockSavedObjectsClient.delete).toHaveBeenNthCalledWith( + 1, + 'event_loop_delays_daily', + 'test_id_1' + ); + expect(mockSavedObjectsClient.delete).toHaveBeenNthCalledWith( + 2, + 'event_loop_delays_daily', + 'test_id_2' + ); + }); + + it('calls logger.debug on repository find error', async () => { + const mockError = new Error('find error'); + mockSavedObjectsClient.find.mockRejectedValueOnce(mockError); + + await rollDailyData(logger, mockSavedObjectsClient); + expect(logger.debug).toBeCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + 'Failed to rollup transactional to daily entries' + ); + expect(logger.debug).toHaveBeenNthCalledWith(2, mockError); + }); + + it('settles all deletes before logging failures', async () => { + const mockError1 = new Error('delete error 1'); + const mockError2 = new Error('delete error 2'); + mockSavedObjectsClient.find.mockResolvedValueOnce({ + saved_objects: [{ id: 'test_id_1' }, { id: 'test_id_2' }, { id: 'test_id_3' }], + } as SavedObjectsFindResponse); + + mockSavedObjectsClient.delete.mockRejectedValueOnce(mockError1); + mockSavedObjectsClient.delete.mockResolvedValueOnce(true); + mockSavedObjectsClient.delete.mockRejectedValueOnce(mockError2); + + await rollDailyData(logger, mockSavedObjectsClient); + expect(mockSavedObjectsClient.delete).toBeCalledTimes(3); + expect(logger.debug).toBeCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + 'Failed to rollup transactional to daily entries' + ); + expect(logger.debug).toHaveBeenNthCalledWith(2, [ + { reason: mockError1, status: 'rejected' }, + { reason: mockError2, status: 'rejected' }, + ]); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.ts new file mode 100644 index 00000000000000..29072335d272b1 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/daily.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Logger } from '@kbn/logging'; +import { ISavedObjectsRepository } from '../../../../../../core/server'; +import { deleteHistogramSavedObjects } from '../saved_objects'; + +/** + * daily rollup function. Deletes histogram saved objects older than 3 days + * @param logger + * @param savedObjectsClient + */ +export async function rollDailyData( + logger: Logger, + savedObjectsClient?: ISavedObjectsRepository +): Promise { + if (!savedObjectsClient) { + return; + } + try { + const settledDeletes = await deleteHistogramSavedObjects(savedObjectsClient); + const failedDeletes = settledDeletes.filter(({ status }) => status !== 'fulfilled'); + if (failedDeletes.length) { + throw failedDeletes; + } + } catch (err) { + logger.debug(`Failed to rollup transactional to daily entries`); + logger.debug(err); + } +} diff --git a/packages/kbn-storybook/typings.d.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/index.ts similarity index 75% rename from packages/kbn-storybook/typings.d.ts rename to src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/index.ts index b940de28299092..4523069a820e7c 100644 --- a/packages/kbn-storybook/typings.d.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/index.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -// Storybook react doesn't declare this in its typings, but it's there. -declare module '@storybook/react/standalone'; +export { rollDailyData } from './daily'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts new file mode 100644 index 00000000000000..8c227f260da6e6 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/rollups/integration_tests/daily_rollups.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Logger, ISavedObjectsRepository } from '../../../../../../../core/server'; +import { + createTestServers, + TestElasticsearchUtils, + TestKibanaUtils, + createRootWithCorePlugins, +} from '../../../../../../../core/test_helpers/kbn_server'; +import { rollDailyData } from '../daily'; +import { mocked } from '../../event_loop_delays.mocks'; + +import { + SAVED_OBJECTS_DAILY_TYPE, + serializeSavedObjectId, + EventLoopDelaysDaily, +} from '../../saved_objects'; +import moment from 'moment'; + +const { startES } = createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), +}); + +function createRawObject(date: moment.MomentInput) { + const pid = Math.round(Math.random() * 10000); + return { + type: SAVED_OBJECTS_DAILY_TYPE, + id: serializeSavedObjectId({ pid, date }), + attributes: { + ...mocked.createHistogram({ + fromTimestamp: moment(date).startOf('day').toISOString(), + lastUpdatedAt: moment(date).toISOString(), + }), + processId: pid, + }, + }; +} + +const rawEventLoopDelaysDaily = [ + createRawObject(moment.now()), + createRawObject(moment.now()), + createRawObject(moment().subtract(1, 'days')), + createRawObject(moment().subtract(3, 'days')), +]; + +const outdatedRawEventLoopDelaysDaily = [ + createRawObject(moment().subtract(5, 'days')), + createRawObject(moment().subtract(7, 'days')), +]; + +describe('daily rollups integration test', () => { + let esServer: TestElasticsearchUtils; + let root: TestKibanaUtils['root']; + let internalRepository: ISavedObjectsRepository; + let logger: Logger; + + beforeAll(async () => { + esServer = await startES(); + root = createRootWithCorePlugins(); + + await root.setup(); + const start = await root.start(); + logger = root.logger.get('test dailt rollups'); + internalRepository = start.savedObjects.createInternalRepository([SAVED_OBJECTS_DAILY_TYPE]); + + await internalRepository.bulkCreate( + [...rawEventLoopDelaysDaily, ...outdatedRawEventLoopDelaysDaily], + { refresh: true } + ); + }); + + afterAll(async () => { + await esServer.stop(); + await root.shutdown(); + }); + + it('deletes documents older that 3 days from the saved objects repository', async () => { + await rollDailyData(logger, internalRepository); + const { + total, + saved_objects: savedObjects, + } = await internalRepository.find({ type: SAVED_OBJECTS_DAILY_TYPE }); + expect(total).toBe(rawEventLoopDelaysDaily.length); + expect(savedObjects.map(({ id, type, attributes }) => ({ id, type, attributes }))).toEqual( + rawEventLoopDelaysDaily + ); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.test.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.test.ts new file mode 100644 index 00000000000000..022040615bd457 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.test.ts @@ -0,0 +1,122 @@ +/* + * 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 { + storeHistogram, + serializeSavedObjectId, + deleteHistogramSavedObjects, +} from './saved_objects'; +import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; +import type { SavedObjectsFindResponse } from '../../../../../core/server/'; +import { mocked } from './event_loop_delays.mocks'; + +describe('serializeSavedObjectId', () => { + it('returns serialized id', () => { + const id = serializeSavedObjectId({ date: 1623233091278, pid: 123 }); + expect(id).toBe('123::09062021'); + }); +}); + +describe('storeHistogram', () => { + const mockHistogram = mocked.createHistogram(); + const mockInternalRepository = savedObjectsRepositoryMock.create(); + + jest.useFakeTimers('modern'); + const mockNow = jest.getRealSystemTime(); + jest.setSystemTime(mockNow); + + beforeEach(() => jest.clearAllMocks()); + afterAll(() => jest.useRealTimers()); + + it('stores histogram data in a savedObject', async () => { + await storeHistogram(mockHistogram, mockInternalRepository); + const pid = process.pid; + const id = serializeSavedObjectId({ date: mockNow, pid }); + + expect(mockInternalRepository.create).toBeCalledWith( + 'event_loop_delays_daily', + { ...mockHistogram, processId: pid }, + { id, overwrite: true } + ); + }); +}); + +describe('deleteHistogramSavedObjects', () => { + const mockInternalRepository = savedObjectsRepositoryMock.create(); + + beforeEach(() => { + jest.clearAllMocks(); + mockInternalRepository.find.mockResolvedValue({ + saved_objects: [{ id: 'test_obj_1' }, { id: 'test_obj_1' }], + } as SavedObjectsFindResponse); + }); + + it('builds filter query based on time range passed in days', async () => { + await deleteHistogramSavedObjects(mockInternalRepository); + await deleteHistogramSavedObjects(mockInternalRepository, 20); + expect(mockInternalRepository.find.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "filter": "event_loop_delays_daily.attributes.lastUpdatedAt < \\"now-3d/d\\"", + "type": "event_loop_delays_daily", + }, + ], + Array [ + Object { + "filter": "event_loop_delays_daily.attributes.lastUpdatedAt < \\"now-20d/d\\"", + "type": "event_loop_delays_daily", + }, + ], + ] + `); + }); + + it('loops over saved objects and deletes them', async () => { + mockInternalRepository.delete.mockImplementation(async (type, id) => { + return id; + }); + + const results = await deleteHistogramSavedObjects(mockInternalRepository); + expect(results).toMatchInlineSnapshot(` + Array [ + Object { + "status": "fulfilled", + "value": "test_obj_1", + }, + Object { + "status": "fulfilled", + "value": "test_obj_1", + }, + ] + `); + }); + + it('settles all promises even if some of the deletes fail.', async () => { + mockInternalRepository.delete.mockImplementationOnce(async (type, id) => { + throw new Error('Intentional failure'); + }); + mockInternalRepository.delete.mockImplementationOnce(async (type, id) => { + return id; + }); + + const results = await deleteHistogramSavedObjects(mockInternalRepository); + expect(results).toMatchInlineSnapshot(` + Array [ + Object { + "reason": [Error: Intentional failure], + "status": "rejected", + }, + Object { + "status": "fulfilled", + "value": "test_obj_1", + }, + ] + `); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.ts new file mode 100644 index 00000000000000..610a6697da364d --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/saved_objects.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { + SavedObjectAttributes, + SavedObjectsServiceSetup, + ISavedObjectsRepository, +} from 'kibana/server'; +import moment from 'moment'; +import type { IntervalHistogram } from './event_loop_delays'; + +export const SAVED_OBJECTS_DAILY_TYPE = 'event_loop_delays_daily'; + +export interface EventLoopDelaysDaily extends SavedObjectAttributes, IntervalHistogram { + processId: number; +} + +export function registerSavedObjectTypes(registerType: SavedObjectsServiceSetup['registerType']) { + registerType({ + name: SAVED_OBJECTS_DAILY_TYPE, + hidden: true, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: { + // This type requires `lastUpdatedAt` to be indexed so we can use it when rolling up totals (lastUpdatedAt < now-90d) + lastUpdatedAt: { type: 'date' }, + }, + }, + }); +} + +export function serializeSavedObjectId({ date, pid }: { date: moment.MomentInput; pid: number }) { + const formattedDate = moment(date).format('DDMMYYYY'); + + return `${pid}::${formattedDate}`; +} + +export async function deleteHistogramSavedObjects( + internalRepository: ISavedObjectsRepository, + daysTimeRange = 3 +) { + const { saved_objects: savedObjects } = await internalRepository.find({ + type: SAVED_OBJECTS_DAILY_TYPE, + filter: `${SAVED_OBJECTS_DAILY_TYPE}.attributes.lastUpdatedAt < "now-${daysTimeRange}d/d"`, + }); + + return await Promise.allSettled( + savedObjects.map(async (savedObject) => { + return await internalRepository.delete(SAVED_OBJECTS_DAILY_TYPE, savedObject.id); + }) + ); +} + +export async function storeHistogram( + histogram: IntervalHistogram, + internalRepository: ISavedObjectsRepository +) { + const pid = process.pid; + const id = serializeSavedObjectId({ date: histogram.lastUpdatedAt, pid }); + + return await internalRepository.create( + SAVED_OBJECTS_DAILY_TYPE, + { ...histogram, processId: pid }, + { id, overwrite: true } + ); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/schema.ts new file mode 100644 index 00000000000000..319e8c77438b8f --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/event_loop_delays/schema.ts @@ -0,0 +1,111 @@ +/* + * 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 { MakeSchemaFrom } from 'src/plugins/usage_collection/server'; + +export interface EventLoopDelaysUsageReport { + daily: Array<{ + processId: number; + lastUpdatedAt: string; + fromTimestamp: string; + min: number; + max: number; + mean: number; + exceeds: number; + stddev: number; + percentiles: { + '50': number; + '75': number; + '95': number; + '99': number; + }; + }>; +} + +export const eventLoopDelaysUsageSchema: MakeSchemaFrom = { + daily: { + type: 'array', + items: { + processId: { + type: 'long', + _meta: { + description: 'The process id of the monitored kibana instance.', + }, + }, + fromTimestamp: { + type: 'date', + _meta: { + description: 'Timestamp at which the histogram started monitoring.', + }, + }, + lastUpdatedAt: { + type: 'date', + _meta: { + description: 'Latest timestamp this histogram object was updated this day.', + }, + }, + min: { + type: 'long', + _meta: { + description: 'The minimum recorded event loop delay.', + }, + }, + max: { + type: 'long', + _meta: { + description: 'The maximum recorded event loop delay.', + }, + }, + mean: { + type: 'long', + _meta: { + description: 'The mean of the recorded event loop delays.', + }, + }, + exceeds: { + type: 'long', + _meta: { + description: + 'The number of times the event loop delay exceeded the maximum 1 hour eventloop delay threshold.', + }, + }, + stddev: { + type: 'long', + _meta: { + description: 'The standard deviation of the recorded event loop delays.', + }, + }, + percentiles: { + '50': { + type: 'long', + _meta: { + description: 'The 50th accumulated percentile distribution', + }, + }, + '75': { + type: 'long', + _meta: { + description: 'The 75th accumulated percentile distribution', + }, + }, + '95': { + type: 'long', + _meta: { + description: 'The 95th accumulated percentile distribution', + }, + }, + '99': { + type: 'long', + _meta: { + description: 'The 99th accumulated percentile distribution', + }, + }, + }, + }, + }, +}; diff --git a/src/plugins/kibana_usage_collection/server/collectors/index.ts b/src/plugins/kibana_usage_collection/server/collectors/index.ts index 761989938e56d8..e4ed24611bfa8c 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/index.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/index.ts @@ -28,3 +28,4 @@ export { registerUsageCountersRollups, registerUsageCountersUsageCollector, } from './usage_counters'; +export { registerEventLoopDelaysCollector } from './event_loop_delays'; diff --git a/src/plugins/kibana_usage_collection/server/plugin.test.ts b/src/plugins/kibana_usage_collection/server/plugin.test.ts index 2100b9bbb918b4..1584366a42dc1a 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.test.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.test.ts @@ -16,7 +16,6 @@ import { createUsageCollectionSetupMock, } from '../../usage_collection/server/mocks'; import { cloudDetailsMock } from './mocks'; - import { plugin } from './'; describe('kibana_usage_collection', () => { @@ -105,6 +104,10 @@ describe('kibana_usage_collection', () => { "isReady": true, "type": "localization", }, + Object { + "isReady": false, + "type": "event_loop_delays", + }, ] `); }); diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index da6445ce957d83..4ec717c48610ea 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -22,6 +22,10 @@ import type { CoreUsageDataStart, } from 'src/core/server'; import { SavedObjectsClient } from '../../../core/server'; +import { + startTrackingEventLoopDelaysUsage, + SAVED_OBJECTS_DAILY_TYPE, +} from './collectors/event_loop_delays'; import { registerApplicationUsageCollector, registerKibanaUsageCollector, @@ -39,6 +43,7 @@ import { registerUsageCountersRollups, registerUsageCountersUsageCollector, registerSavedObjectsCountUsageCollector, + registerEventLoopDelaysCollector, } from './collectors'; interface KibanaUsageCollectionPluginsDepsSetup { @@ -54,46 +59,46 @@ export class KibanaUsageCollectionPlugin implements Plugin { private uiSettingsClient?: IUiSettingsClient; private metric$: Subject; private coreUsageData?: CoreUsageDataStart; - private stopUsingUiCounterIndicies$: Subject; + private pluginStop$: Subject; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); this.legacyConfig$ = initializerContext.config.legacy.globalConfig$; this.metric$ = new Subject(); - this.stopUsingUiCounterIndicies$ = new Subject(); + this.pluginStop$ = new Subject(); } public setup(coreSetup: CoreSetup, { usageCollection }: KibanaUsageCollectionPluginsDepsSetup) { usageCollection.createUsageCounter('uiCounters'); - this.registerUsageCollectors( usageCollection, coreSetup, this.metric$, - this.stopUsingUiCounterIndicies$, + this.pluginStop$, coreSetup.savedObjects.registerType.bind(coreSetup.savedObjects) ); } public start(core: CoreStart) { const { savedObjects, uiSettings } = core; - this.savedObjectsClient = savedObjects.createInternalRepository(); + this.savedObjectsClient = savedObjects.createInternalRepository([SAVED_OBJECTS_DAILY_TYPE]); const savedObjectsClient = new SavedObjectsClient(this.savedObjectsClient); this.uiSettingsClient = uiSettings.asScopedToClient(savedObjectsClient); core.metrics.getOpsMetrics$().subscribe(this.metric$); this.coreUsageData = core.coreUsageData; + startTrackingEventLoopDelaysUsage(this.savedObjectsClient, this.pluginStop$.asObservable()); } public stop() { this.metric$.complete(); - this.stopUsingUiCounterIndicies$.complete(); + this.pluginStop$.complete(); } private registerUsageCollectors( usageCollection: UsageCollectionSetup, coreSetup: CoreSetup, metric$: Subject, - stopUsingUiCounterIndicies$: Subject, + pluginStop$: Subject, registerType: SavedObjectsRegisterType ) { const getSavedObjectsClient = () => this.savedObjectsClient; @@ -101,12 +106,8 @@ export class KibanaUsageCollectionPlugin implements Plugin { const getCoreUsageDataService = () => this.coreUsageData!; registerUiCounterSavedObjectType(coreSetup.savedObjects); - registerUiCountersRollups( - this.logger.get('ui-counters'), - stopUsingUiCounterIndicies$, - getSavedObjectsClient - ); - registerUiCountersUsageCollector(usageCollection, stopUsingUiCounterIndicies$); + registerUiCountersRollups(this.logger.get('ui-counters'), pluginStop$, getSavedObjectsClient); + registerUiCountersUsageCollector(usageCollection, pluginStop$); registerUsageCountersRollups(this.logger.get('usage-counters-rollup'), getSavedObjectsClient); registerUsageCountersUsageCollector(usageCollection); @@ -127,5 +128,11 @@ export class KibanaUsageCollectionPlugin implements Plugin { registerCoreUsageCollector(usageCollection, getCoreUsageDataService); registerConfigUsageCollector(usageCollection, getCoreUsageDataService); registerLocalizationUsageCollector(usageCollection, coreSetup.i18n); + registerEventLoopDelaysCollector( + this.logger.get('event-loop-delays'), + usageCollection, + registerType, + getSavedObjectsClient + ); } } diff --git a/src/plugins/management/common/index.ts b/src/plugins/management/common/index.ts new file mode 100644 index 00000000000000..c701ba846bcac0 --- /dev/null +++ b/src/plugins/management/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { ManagementAppLocator } from './locator'; diff --git a/src/plugins/management/common/locator.test.ts b/src/plugins/management/common/locator.test.ts index dda393a4203ecd..20773b97327824 100644 --- a/src/plugins/management/common/locator.test.ts +++ b/src/plugins/management/common/locator.test.ts @@ -7,16 +7,16 @@ */ import { MANAGEMENT_APP_ID } from './contants'; -import { ManagementAppLocator, MANAGEMENT_APP_LOCATOR } from './locator'; +import { ManagementAppLocatorDefinition, MANAGEMENT_APP_LOCATOR } from './locator'; test('locator has the right ID', () => { - const locator = new ManagementAppLocator(); + const locator = new ManagementAppLocatorDefinition(); expect(locator.id).toBe(MANAGEMENT_APP_LOCATOR); }); test('returns management app ID', async () => { - const locator = new ManagementAppLocator(); + const locator = new ManagementAppLocatorDefinition(); const location = await locator.getLocation({ sectionId: 'a', appId: 'b', @@ -28,26 +28,26 @@ test('returns management app ID', async () => { }); test('returns Kibana location for section ID and app ID pair', async () => { - const locator = new ManagementAppLocator(); + const locator = new ManagementAppLocatorDefinition(); const location = await locator.getLocation({ sectionId: 'ingest', appId: 'index', }); expect(location).toMatchObject({ - route: '/ingest/index', + path: '/ingest/index', state: {}, }); }); test('when app ID is not provided, returns path to just the section ID', async () => { - const locator = new ManagementAppLocator(); + const locator = new ManagementAppLocatorDefinition(); const location = await locator.getLocation({ sectionId: 'data', }); expect(location).toMatchObject({ - route: '/data', + path: '/data', state: {}, }); }); diff --git a/src/plugins/management/common/locator.ts b/src/plugins/management/common/locator.ts index 4a4a50f468adc6..7dbf5e28880111 100644 --- a/src/plugins/management/common/locator.ts +++ b/src/plugins/management/common/locator.ts @@ -7,7 +7,7 @@ */ import { SerializableState } from 'src/plugins/kibana_utils/common'; -import { LocatorDefinition } from 'src/plugins/share/common'; +import { LocatorDefinition, LocatorPublic } from 'src/plugins/share/common'; import { MANAGEMENT_APP_ID } from './contants'; export const MANAGEMENT_APP_LOCATOR = 'MANAGEMENT_APP_LOCATOR'; @@ -17,15 +17,18 @@ export interface ManagementAppLocatorParams extends SerializableState { appId?: string; } -export class ManagementAppLocator implements LocatorDefinition { +export type ManagementAppLocator = LocatorPublic; + +export class ManagementAppLocatorDefinition + implements LocatorDefinition { public readonly id = MANAGEMENT_APP_LOCATOR; public readonly getLocation = async (params: ManagementAppLocatorParams) => { - const route = `/${params.sectionId}${params.appId ? '/' + params.appId : ''}`; + const path = `/${params.sectionId}${params.appId ? '/' + params.appId : ''}`; return { app: MANAGEMENT_APP_ID, - route, + path, state: {}, }; }; diff --git a/src/plugins/management/public/mocks/index.ts b/src/plugins/management/public/mocks/index.ts index 70d853f32dfcc3..b06e41502e9df4 100644 --- a/src/plugins/management/public/mocks/index.ts +++ b/src/plugins/management/public/mocks/index.ts @@ -33,9 +33,11 @@ const createSetupContract = (): ManagementSetup => ({ locator: { getLocation: jest.fn(async () => ({ app: 'MANAGEMENT', - route: '', + path: '', state: {}, })), + getUrl: jest.fn(), + useUrl: jest.fn(), navigate: jest.fn(), }, }); diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index 3289b2f6f5446a..34719fb5070e10 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -25,7 +25,7 @@ import { } from '../../../core/public'; import { MANAGEMENT_APP_ID } from '../common/contants'; -import { ManagementAppLocator } from '../common/locator'; +import { ManagementAppLocatorDefinition } from '../common/locator'; import { ManagementSectionsService, getSectionsServiceStartPrivate, @@ -74,7 +74,7 @@ export class ManagementPlugin public setup(core: CoreSetup, { home, share }: ManagementSetupDependencies) { const kibanaVersion = this.initializerContext.env.packageInfo.version; - const locator = share.url.locators.create(new ManagementAppLocator()); + const locator = share.url.locators.create(new ManagementAppLocatorDefinition()); if (home) { home.featureCatalogue.register({ diff --git a/src/plugins/management/server/plugin.ts b/src/plugins/management/server/plugin.ts index 349cab6206babc..cc3798d855c595 100644 --- a/src/plugins/management/server/plugin.ts +++ b/src/plugins/management/server/plugin.ts @@ -9,7 +9,7 @@ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from 'kibana/server'; import { LocatorPublic } from 'src/plugins/share/common'; import type { SharePluginSetup } from 'src/plugins/share/server'; -import { ManagementAppLocator, ManagementAppLocatorParams } from '../common/locator'; +import { ManagementAppLocatorDefinition, ManagementAppLocatorParams } from '../common/locator'; import { capabilitiesProvider } from './capabilities_provider'; interface ManagementSetupDependencies { @@ -31,7 +31,7 @@ export class ManagementServerPlugin public setup(core: CoreSetup, { share }: ManagementSetupDependencies) { this.logger.debug('management: Setup'); - const locator = share.url.locators.create(new ManagementAppLocator()); + const locator = share.url.locators.create(new ManagementAppLocatorDefinition()); core.capabilities.registerProvider(capabilitiesProvider); diff --git a/src/plugins/share/common/index.ts b/src/plugins/share/common/index.ts index 8b5d8d45571942..e724117f5b7f7d 100644 --- a/src/plugins/share/common/index.ts +++ b/src/plugins/share/common/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { LocatorDefinition, LocatorPublic } from './url_service'; +export { LocatorDefinition, LocatorPublic, useLocatorUrl } from './url_service'; diff --git a/src/plugins/share/common/url_service/__tests__/locators.test.ts b/src/plugins/share/common/url_service/__tests__/locators.test.ts index 45d727df7de48c..93ba76c7399f46 100644 --- a/src/plugins/share/common/url_service/__tests__/locators.test.ts +++ b/src/plugins/share/common/url_service/__tests__/locators.test.ts @@ -53,7 +53,7 @@ describe('locators', () => { expect(location).toEqual({ app: 'test_app', - route: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=21', + path: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=21', state: { isFlyoutOpen: true }, }); }); @@ -97,7 +97,7 @@ describe('locators', () => { expect(deps.navigate).toHaveBeenCalledWith( { app: 'test_app', - route: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=1', + path: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=1', state: { isFlyoutOpen: false, }, @@ -130,7 +130,7 @@ describe('locators', () => { expect(deps.navigate).toHaveBeenCalledWith( { app: 'test_app', - route: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=1', + path: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=1', state: { isFlyoutOpen: false, }, @@ -153,7 +153,7 @@ describe('locators', () => { expect(deps.navigate).toHaveBeenCalledWith( { app: 'test_app', - route: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=2', + path: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=2', state: { isFlyoutOpen: false, }, diff --git a/src/plugins/share/common/url_service/__tests__/setup.ts b/src/plugins/share/common/url_service/__tests__/setup.ts index ad13bb8d8d2160..fea3e1b945f99a 100644 --- a/src/plugins/share/common/url_service/__tests__/setup.ts +++ b/src/plugins/share/common/url_service/__tests__/setup.ts @@ -21,7 +21,7 @@ export const testLocator: LocatorDefinition = { getLocation: async ({ savedObjectId, pageNumber, showFlyout }) => { return { app: 'test_app', - route: `/my-object/${savedObjectId}?page=${pageNumber}`, + path: `/my-object/${savedObjectId}?page=${pageNumber}`, state: { isFlyoutOpen: showFlyout, }, @@ -34,6 +34,9 @@ export const urlServiceTestSetup = (partialDeps: Partial navigate: async () => { throw new Error('not implemented'); }, + getUrl: async () => { + throw new Error('not implemented'); + }, ...partialDeps, }; const service = new UrlService(deps); diff --git a/src/plugins/share/common/url_service/locators/index.ts b/src/plugins/share/common/url_service/locators/index.ts index f9f87215eb4db5..7ab3938984f237 100644 --- a/src/plugins/share/common/url_service/locators/index.ts +++ b/src/plugins/share/common/url_service/locators/index.ts @@ -9,3 +9,4 @@ export * from './types'; export * from './locator'; export * from './locator_client'; +export { useLocatorUrl } from './use_locator_url'; diff --git a/src/plugins/share/common/url_service/locators/locator.ts b/src/plugins/share/common/url_service/locators/locator.ts index 68c3b05a7f4111..680fb2231fc48d 100644 --- a/src/plugins/share/common/url_service/locators/locator.ts +++ b/src/plugins/share/common/url_service/locators/locator.ts @@ -7,16 +7,27 @@ */ import type { SavedObjectReference } from 'kibana/server'; +import { DependencyList } from 'react'; import type { PersistableState, SerializableState } from 'src/plugins/kibana_utils/common'; +import { useLocatorUrl } from './use_locator_url'; import type { LocatorDefinition, LocatorPublic, KibanaLocation, LocatorNavigationParams, + LocatorGetUrlParams, } from './types'; export interface LocatorDependencies { + /** + * Navigate without reloading the page to a KibanaLocation. + */ navigate: (location: KibanaLocation, params?: LocatorNavigationParams) => Promise; + + /** + * Resolve a Kibana URL given KibanaLocation. + */ + getUrl: (location: KibanaLocation, getUrlParams: LocatorGetUrlParams) => Promise; } export class Locator

implements PersistableState

, LocatorPublic

{ @@ -57,13 +68,29 @@ export class Locator

implements PersistableState

return await this.definition.getLocation(params); } + public async getUrl(params: P, { absolute = false }: LocatorGetUrlParams = {}): Promise { + const location = await this.getLocation(params); + const url = this.deps.getUrl(location, { absolute }); + + return url; + } + public async navigate( params: P, { replace = false }: LocatorNavigationParams = {} ): Promise { const location = await this.getLocation(params); + await this.deps.navigate(location, { replace, }); } + + /* eslint-disable react-hooks/rules-of-hooks */ + public readonly useUrl = ( + params: P, + getUrlParams?: LocatorGetUrlParams, + deps: DependencyList = [] + ): string => useLocatorUrl

(this, params, getUrlParams, deps); + /* eslint-enable react-hooks/rules-of-hooks */ } diff --git a/src/plugins/share/common/url_service/locators/types.ts b/src/plugins/share/common/url_service/locators/types.ts index d811ae0fd4aa23..870eaa3718d3fc 100644 --- a/src/plugins/share/common/url_service/locators/types.ts +++ b/src/plugins/share/common/url_service/locators/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { DependencyList } from 'react'; import { PersistableState, SerializableState } from 'src/plugins/kibana_utils/common'; /** @@ -51,23 +52,57 @@ export interface LocatorDefinition

*/ export interface LocatorPublic

{ /** - * Returns a relative URL to the client-side redirect endpoint using this - * locator. (This method is necessary for compatibility with URL generators.) + * Returns a reference to a Kibana client-side location. + * + * @param params URL locator parameters. */ getLocation(params: P): Promise; + /** + * Returns a URL as a string. + * + * @param params URL locator parameters. + * @param getUrlParams URL construction parameters. + */ + getUrl(params: P, getUrlParams?: LocatorGetUrlParams): Promise; + /** * Navigate using the `core.application.navigateToApp()` method to a Kibana * location generated by this locator. This method is available only on the * browser. + * + * @param params URL locator parameters. + * @param navigationParams Navigation parameters. */ navigate(params: P, navigationParams?: LocatorNavigationParams): Promise; + + /** + * React hook which returns a URL string given locator parameters. Returns + * empty string if URL is being loaded or an error happened. + */ + useUrl: (params: P, getUrlParams?: LocatorGetUrlParams, deps?: DependencyList) => string; } +/** + * Parameters used when navigating on client-side using browser history object. + */ export interface LocatorNavigationParams { + /** + * Whether to replace a navigation entry in history queue or push a new entry. + */ replace?: boolean; } +/** + * Parameters used when constructing a string URL. + */ +export interface LocatorGetUrlParams { + /** + * Whether to return an absolute long URL or relative short URL. + */ + absolute?: boolean; +} + /** * This interface represents a location in Kibana to which one can navigate * using the `core.application.navigateToApp()` method. @@ -79,9 +114,9 @@ export interface KibanaLocation { app: string; /** - * A URL route within a Kibana application. + * A relative URL path within a Kibana application. */ - route: string; + path: string; /** * A serializable location state object, which the app can use to determine diff --git a/src/plugins/share/common/url_service/locators/use_locator_url.ts b/src/plugins/share/common/url_service/locators/use_locator_url.ts new file mode 100644 index 00000000000000..a84c712e16248a --- /dev/null +++ b/src/plugins/share/common/url_service/locators/use_locator_url.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { DependencyList, useEffect, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import { SerializableState } from 'src/plugins/kibana_utils/common'; +import { LocatorGetUrlParams, LocatorPublic } from '../../../common/url_service'; + +export const useLocatorUrl =

( + locator: LocatorPublic

| null | undefined, + params: P, + getUrlParams?: LocatorGetUrlParams, + deps: DependencyList = [] +): string => { + const [url, setUrl] = useState(''); + const isMounted = useMountedState(); + + /* eslint-disable react-hooks/exhaustive-deps */ + useEffect(() => { + if (!locator) { + setUrl(''); + return; + } + + locator + .getUrl(params, getUrlParams) + .then((result: string) => { + if (!isMounted()) return; + setUrl(result); + }) + .catch((error) => { + if (!isMounted()) return; + // eslint-disable-next-line no-console + console.error('useLocatorUrl', error); + setUrl(''); + }); + }, [locator, ...deps]); + /* eslint-enable react-hooks/exhaustive-deps */ + + return url; +}; diff --git a/src/plugins/share/common/url_service/url_service.ts b/src/plugins/share/common/url_service/url_service.ts index 0c3a0aabb750bc..5daba1500cdfdf 100644 --- a/src/plugins/share/common/url_service/url_service.ts +++ b/src/plugins/share/common/url_service/url_service.ts @@ -17,7 +17,9 @@ export class UrlService { /** * Client to work with locators. */ - locators: LocatorClient = new LocatorClient(this.deps); + public readonly locators: LocatorClient; - constructor(protected readonly deps: UrlServiceDependencies) {} + constructor(protected readonly deps: UrlServiceDependencies) { + this.locators = new LocatorClient(deps); + } } diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index d13bb15f8c72ca..8f5356f6a22012 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -29,6 +29,8 @@ export { UrlGeneratorsService, } from './url_generators'; +export { useLocatorUrl } from '../common/url_service/locators/use_locator_url'; + import { SharePlugin } from './plugin'; export { KibanaURL } from './kibana_url'; diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index eb7c46cdaef867..893108b56bcfad 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -68,14 +68,22 @@ export class SharePlugin implements Plugin { core.application.register(createShortUrlRedirectApp(core, window.location)); this.url = new UrlService({ - navigate: async (location, { replace = false } = {}) => { + navigate: async ({ app, path, state }, { replace = false } = {}) => { const [start] = await core.getStartServices(); - await start.application.navigateToApp(location.app, { - path: location.route, - state: location.state, + await start.application.navigateToApp(app, { + path, + state, replace, }); }, + getUrl: async ({ app, path }, { absolute }) => { + const start = await core.getStartServices(); + const url = start[0].application.getUrlForApp(app, { + path, + absolute, + }); + return url; + }, }); return { diff --git a/src/plugins/share/server/plugin.ts b/src/plugins/share/server/plugin.ts index 6e3c68935f77bf..76e10372cdb671 100644 --- a/src/plugins/share/server/plugin.ts +++ b/src/plugins/share/server/plugin.ts @@ -32,7 +32,10 @@ export class SharePlugin implements Plugin { public setup(core: CoreSetup) { this.url = new UrlService({ navigate: async () => { - throw new Error('Locator .navigate() does not work on server.'); + throw new Error('Locator .navigate() currently is not supported on the server.'); + }, + getUrl: async () => { + throw new Error('Locator .getUrl() currently is not supported on the server.'); }, }); diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 6ab550389a12d7..99c6dcb40e57d4 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7900,6 +7900,93 @@ } } }, + "event_loop_delays": { + "properties": { + "daily": { + "type": "array", + "items": { + "properties": { + "processId": { + "type": "long", + "_meta": { + "description": "The process id of the monitored kibana instance." + } + }, + "fromTimestamp": { + "type": "date", + "_meta": { + "description": "Timestamp at which the histogram started monitoring." + } + }, + "lastUpdatedAt": { + "type": "date", + "_meta": { + "description": "Latest timestamp this histogram object was updated this day." + } + }, + "min": { + "type": "long", + "_meta": { + "description": "The minimum recorded event loop delay." + } + }, + "max": { + "type": "long", + "_meta": { + "description": "The maximum recorded event loop delay." + } + }, + "mean": { + "type": "long", + "_meta": { + "description": "The mean of the recorded event loop delays." + } + }, + "exceeds": { + "type": "long", + "_meta": { + "description": "The number of times the event loop delay exceeded the maximum 1 hour eventloop delay threshold." + } + }, + "stddev": { + "type": "long", + "_meta": { + "description": "The standard deviation of the recorded event loop delays." + } + }, + "percentiles": { + "properties": { + "50": { + "type": "long", + "_meta": { + "description": "The 50th accumulated percentile distribution" + } + }, + "75": { + "type": "long", + "_meta": { + "description": "The 75th accumulated percentile distribution" + } + }, + "95": { + "type": "long", + "_meta": { + "description": "The 95th accumulated percentile distribution" + } + }, + "99": { + "type": "long", + "_meta": { + "description": "The 99th accumulated percentile distribution" + } + } + } + } + } + } + } + } + }, "localization": { "properties": { "locale": { diff --git a/test/functional/page_objects/time_to_visualize_page.ts b/test/functional/page_objects/time_to_visualize_page.ts index 287b03ec60d88a..57a22103f64094 100644 --- a/test/functional/page_objects/time_to_visualize_page.ts +++ b/test/functional/page_objects/time_to_visualize_page.ts @@ -51,7 +51,10 @@ export class TimeToVisualizePageObject extends FtrService { vizName: string, { saveAsNew, redirectToOrigin, addToDashboard, dashboardId, saveToLibrary }: SaveModalArgs = {} ) { - await this.testSubjects.setValue('savedObjectTitle', vizName); + await this.testSubjects.setValue('savedObjectTitle', vizName, { + typeCharByChar: true, + clearWithKeyboard: true, + }); const hasSaveAsNew = await this.testSubjects.exists('saveAsNewCheckbox'); if (hasSaveAsNew && saveAsNew !== undefined) { diff --git a/x-pack/package.json b/x-pack/package.json index adb0305d0e8772..08a86e8c974ccc 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -27,7 +27,6 @@ }, "devDependencies": { "@kbn/plugin-helpers": "link:../packages/kbn-plugin-helpers", - "@kbn/storybook": "link:../packages/kbn-storybook", "@kbn/test": "link:../packages/kbn-test" }, "dependencies": { diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index e33c410668c251..21aef379715c7d 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -43,4 +43,4 @@ "ml", "observability" ] -} +} \ No newline at end of file diff --git a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx index 5b4f4e24af44d5..ca73f6ddd05b34 100644 --- a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx @@ -18,7 +18,7 @@ import { AlertType } from '../../../../common/alert_types'; import { AlertingFlyout } from '../../alerting/alerting_flyout'; const alertLabel = i18n.translate('xpack.apm.home.alertsMenu.alerts', { - defaultMessage: 'Alerts', + defaultMessage: 'Alerts and rules', }); const transactionDurationLabel = i18n.translate( 'xpack.apm.home.alertsMenu.transactionDuration', @@ -33,11 +33,11 @@ const errorCountLabel = i18n.translate('xpack.apm.home.alertsMenu.errorCount', { }); const createThresholdAlertLabel = i18n.translate( 'xpack.apm.home.alertsMenu.createThresholdAlert', - { defaultMessage: 'Create threshold alert' } + { defaultMessage: 'Create threshold rule' } ); const createAnomalyAlertAlertLabel = i18n.translate( 'xpack.apm.home.alertsMenu.createAnomalyAlert', - { defaultMessage: 'Create anomaly alert' } + { defaultMessage: 'Create anomaly rule' } ); const CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID = @@ -102,7 +102,7 @@ export function AlertingPopoverAndFlyout({ { name: i18n.translate( 'xpack.apm.home.alertsMenu.viewActiveAlerts', - { defaultMessage: 'View active alerts' } + { defaultMessage: 'Manage rules' } ), href: basePath.prepend( '/app/management/insightsAndAlerting/triggersActions/alerts' diff --git a/x-pack/plugins/apm/server/lib/fleet/register_fleet_policy_callbacks.ts b/x-pack/plugins/apm/server/lib/fleet/register_fleet_policy_callbacks.ts index 35c7f0dfdfd73d..c122a5c406eab1 100644 --- a/x-pack/plugins/apm/server/lib/fleet/register_fleet_policy_callbacks.ts +++ b/x-pack/plugins/apm/server/lib/fleet/register_fleet_policy_callbacks.ts @@ -12,6 +12,7 @@ import { APMPluginStartDependencies } from '../../types'; import { ExternalCallback } from '../../../../fleet/server'; import { AGENT_NAME } from '../../../common/elasticsearch_fieldnames'; import { AgentConfiguration } from '../../../common/agent_configuration/configuration_types'; +import { getPackagePolicyWithSourceMap, listArtifacts } from './source_maps'; export async function registerFleetPolicyCallbacks({ plugins, @@ -31,7 +32,7 @@ export async function registerFleetPolicyCallbacks({ // Registers a callback invoked when a policy is created to populate the APM // integration policy with pre-existing agent configurations - registerAgentConfigExternalCallback({ + registerPackagePolicyExternalCallback({ fleetPluginStart, callbackName: 'packagePolicyCreate', plugins, @@ -42,7 +43,7 @@ export async function registerFleetPolicyCallbacks({ // Registers a callback invoked when a policy is updated to populate the APM // integration policy with existing agent configurations - registerAgentConfigExternalCallback({ + registerPackagePolicyExternalCallback({ fleetPluginStart, callbackName: 'packagePolicyUpdate', plugins, @@ -53,11 +54,11 @@ export async function registerFleetPolicyCallbacks({ } type ExternalCallbackParams = Parameters; -type PackagePolicy = ExternalCallbackParams[0]; +export type PackagePolicy = ExternalCallbackParams[0]; type Context = ExternalCallbackParams[1]; type Request = ExternalCallbackParams[2]; -function registerAgentConfigExternalCallback({ +function registerPackagePolicyExternalCallback({ fleetPluginStart, callbackName, plugins, @@ -91,8 +92,9 @@ function registerAgentConfigExternalCallback({ ruleDataClient, }); const agentConfigurations = await listConfigurations({ setup }); + const artifacts = await listArtifacts({ fleetPluginStart }); return getPackagePolicyWithAgentConfigurations( - packagePolicy, + getPackagePolicyWithSourceMap({ packagePolicy, artifacts }), agentConfigurations ); }; @@ -100,7 +102,7 @@ function registerAgentConfigExternalCallback({ fleetPluginStart.registerExternalCallback(callbackName, callbackFn); } -const APM_SERVER = 'apm-server'; +export const APM_SERVER = 'apm-server'; // Immutable function applies the given package policy with a set of agent configurations export function getPackagePolicyWithAgentConfigurations( diff --git a/x-pack/plugins/apm/server/lib/fleet/source_maps.test.ts b/x-pack/plugins/apm/server/lib/fleet/source_maps.test.ts new file mode 100644 index 00000000000000..61a4fa4436e69c --- /dev/null +++ b/x-pack/plugins/apm/server/lib/fleet/source_maps.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { + ArtifactSourceMap, + getPackagePolicyWithSourceMap, +} from './source_maps'; + +const packagePolicy = { + id: '123', + version: 'WzMxNDI2LDFd', + name: 'apm-1', + description: '', + namespace: 'default', + policy_id: '7a87c160-c961-11eb-81e2-f7327d61c92a', + enabled: true, + output_id: '', + inputs: [ + { + policy_template: 'apmserver', + streams: [], + vars: {}, + type: 'apm', + enabled: true, + compiled_input: { + 'apm-server': { + capture_personal_data: true, + max_event_size: 307200, + api_key: { limit: 100, enabled: false }, + default_service_environment: null, + host: 'localhost:8200', + kibana: { api_key: null }, + secret_token: null, + }, + }, + }, + ], + package: { name: 'apm', title: 'Elastic APM', version: '0.2.0' }, + created_at: '2021-06-16T14:54:32.195Z', + created_by: 'elastic', +}; + +const artifacts = [ + { + type: 'sourcemap', + identifier: 'service_name-1.0.0', + relative_url: '/api/fleet/artifacts/service_name-1.0.0/my-id-1', + body: { + serviceName: 'service_name', + serviceVersion: '1.0.0', + bundleFilepath: 'http://localhost:3000/static/js/main.chunk.js', + sourceMap: { + version: 3, + file: 'static/js/main.chunk.js', + sources: ['foo'], + sourcesContent: ['foo'], + mappings: 'foo', + sourceRoot: '', + }, + }, + created: '2021-06-16T15:03:55.049Z', + id: 'apm:service_name-1.0.0-my-id-1', + compressionAlgorithm: 'zlib', + decodedSha256: 'my-id-1', + decodedSize: 9440, + encodedSha256: 'sha123', + encodedSize: 2622, + encryptionAlgorithm: 'none', + packageName: 'apm', + }, + { + type: 'sourcemap', + identifier: 'service_name-2.0.0', + relative_url: '/api/fleet/artifacts/service_name-2.0.0/my-id-2', + body: { + serviceName: 'service_name', + serviceVersion: '2.0.0', + bundleFilepath: 'http://localhost:3000/static/js/main.chunk.js', + sourceMap: { + version: 3, + file: 'static/js/main.chunk.js', + sources: ['foo'], + sourcesContent: ['foo'], + mappings: 'foo', + sourceRoot: '', + }, + }, + created: '2021-06-16T15:03:55.049Z', + id: 'apm:service_name-2.0.0-my-id-2', + compressionAlgorithm: 'zlib', + decodedSha256: 'my-id-2', + decodedSize: 9440, + encodedSha256: 'sha456', + encodedSize: 2622, + encryptionAlgorithm: 'none', + packageName: 'apm', + }, +] as ArtifactSourceMap[]; + +describe('Source maps', () => { + describe('getPackagePolicyWithSourceMap', () => { + it('returns unchanged package policy when artifacts is empty', () => { + const updatedPackagePolicy = getPackagePolicyWithSourceMap({ + packagePolicy, + artifacts: [], + }); + expect(updatedPackagePolicy).toEqual(packagePolicy); + }); + it('adds source maps into the package policy', () => { + const updatedPackagePolicy = getPackagePolicyWithSourceMap({ + packagePolicy, + artifacts, + }); + expect(updatedPackagePolicy.inputs[0].config).toEqual({ + 'apm-server': { + value: { + rum: { + source_mapping: { + metadata: [ + { + 'service.name': 'service_name', + 'service.version': '1.0.0', + 'bundle.filepath': + 'http://localhost:3000/static/js/main.chunk.js', + 'sourcemap.url': + '/api/fleet/artifacts/service_name-1.0.0/my-id-1', + }, + { + 'service.name': 'service_name', + 'service.version': '2.0.0', + 'bundle.filepath': + 'http://localhost:3000/static/js/main.chunk.js', + 'sourcemap.url': + '/api/fleet/artifacts/service_name-2.0.0/my-id-2', + }, + ], + }, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/fleet/source_maps.ts b/x-pack/plugins/apm/server/lib/fleet/source_maps.ts new file mode 100644 index 00000000000000..b313fbad2806fb --- /dev/null +++ b/x-pack/plugins/apm/server/lib/fleet/source_maps.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as t from 'io-ts'; +import { + CoreSetup, + CoreStart, + ElasticsearchClient, + SavedObjectsClientContract, +} from 'kibana/server'; +import { promisify } from 'util'; +import { unzip } from 'zlib'; +import { Artifact } from '../../../../fleet/server'; +import { sourceMapRt } from '../../routes/source_maps'; +import { APMPluginStartDependencies } from '../../types'; +import { getApmPackgePolicies } from './get_apm_package_policies'; +import { APM_SERVER, PackagePolicy } from './register_fleet_policy_callbacks'; + +export interface ApmArtifactBody { + serviceName: string; + serviceVersion: string; + bundleFilepath: string; + sourceMap: t.TypeOf; +} +export type ArtifactSourceMap = Omit & { + body: ApmArtifactBody; +}; + +export type FleetPluginStart = NonNullable; + +const doUnzip = promisify(unzip); + +function decodeArtifacts(artifacts: Artifact[]): Promise { + return Promise.all( + artifacts.map(async (artifact) => { + const body = await doUnzip(Buffer.from(artifact.body, 'base64')); + return { + ...artifact, + body: JSON.parse(body.toString()) as ApmArtifactBody, + }; + }) + ); +} + +function getApmArtifactClient(fleetPluginStart: FleetPluginStart) { + return fleetPluginStart.createArtifactsClient('apm'); +} + +export async function listArtifacts({ + fleetPluginStart, +}: { + fleetPluginStart: FleetPluginStart; +}) { + const apmArtifactClient = getApmArtifactClient(fleetPluginStart); + const artifacts = await apmArtifactClient.listArtifacts({ + kuery: 'type: sourcemap', + }); + + return decodeArtifacts(artifacts.items); +} + +export async function createApmArtifact({ + apmArtifactBody, + fleetPluginStart, +}: { + apmArtifactBody: ApmArtifactBody; + fleetPluginStart: FleetPluginStart; +}) { + const apmArtifactClient = getApmArtifactClient(fleetPluginStart); + const identifier = `${apmArtifactBody.serviceName}-${apmArtifactBody.serviceVersion}`; + + return apmArtifactClient.createArtifact({ + type: 'sourcemap', + identifier, + content: JSON.stringify(apmArtifactBody), + }); +} + +export async function deleteApmArtifact({ + id, + fleetPluginStart, +}: { + id: string; + fleetPluginStart: FleetPluginStart; +}) { + const apmArtifactClient = getApmArtifactClient(fleetPluginStart); + return apmArtifactClient.deleteArtifact(id); +} + +export function getPackagePolicyWithSourceMap({ + packagePolicy, + artifacts, +}: { + packagePolicy: PackagePolicy; + artifacts: ArtifactSourceMap[]; +}) { + if (!artifacts.length) { + return packagePolicy; + } + const [firstInput, ...restInputs] = packagePolicy.inputs; + return { + ...packagePolicy, + inputs: [ + { + ...firstInput, + config: { + ...firstInput.config, + [APM_SERVER]: { + value: { + ...firstInput?.config?.[APM_SERVER].value, + rum: { + source_mapping: { + metadata: artifacts.map((artifact) => ({ + 'service.name': artifact.body.serviceName, + 'service.version': artifact.body.serviceVersion, + 'bundle.filepath': artifact.body.bundleFilepath, + 'sourcemap.url': artifact.relative_url, + })), + }, + }, + }, + }, + }, + }, + ...restInputs, + ], + }; +} + +export async function updateSourceMapsOnFleetPolicies({ + core, + fleetPluginStart, + savedObjectsClient, + elasticsearchClient, +}: { + core: { setup: CoreSetup; start: () => Promise }; + fleetPluginStart: FleetPluginStart; + savedObjectsClient: SavedObjectsClientContract; + elasticsearchClient: ElasticsearchClient; +}) { + const artifacts = await listArtifacts({ fleetPluginStart }); + + const apmFleetPolicies = await getApmPackgePolicies({ + core, + fleetPluginStart, + }); + + return Promise.all( + apmFleetPolicies.items.map(async (item) => { + const { + id, + revision, + updated_at: updatedAt, + updated_by: updatedBy, + ...packagePolicy + } = item; + + const updatedPackagePolicy = getPackagePolicyWithSourceMap({ + packagePolicy, + artifacts, + }); + + await fleetPluginStart.packagePolicyService.update( + savedObjectsClient, + elasticsearchClient, + id, + updatedPackagePolicy + ); + }) + ); +} diff --git a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts index c151752b4b6e04..f1c08444d2e1e7 100644 --- a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts +++ b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts @@ -26,6 +26,7 @@ import { agentConfigurationRouteRepository } from './settings/agent_configuratio import { anomalyDetectionRouteRepository } from './settings/anomaly_detection'; import { apmIndicesRouteRepository } from './settings/apm_indices'; import { customLinkRouteRepository } from './settings/custom_link'; +import { sourceMapsRouteRepository } from './source_maps'; import { traceRouteRepository } from './traces'; import { transactionRouteRepository } from './transactions'; import { APMRouteHandlerResources } from './typings'; @@ -48,7 +49,8 @@ const getTypedGlobalApmServerRouteRepository = () => { .merge(agentConfigurationRouteRepository) .merge(anomalyDetectionRouteRepository) .merge(apmIndicesRouteRepository) - .merge(customLinkRouteRepository); + .merge(customLinkRouteRepository) + .merge(sourceMapsRouteRepository); return repository; }; diff --git a/x-pack/plugins/apm/server/routes/source_maps.ts b/x-pack/plugins/apm/server/routes/source_maps.ts new file mode 100644 index 00000000000000..24ea825774b0a1 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/source_maps.ts @@ -0,0 +1,138 @@ +/* + * 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 Boom from '@hapi/boom'; +import * as t from 'io-ts'; +import { SavedObjectsClientContract } from 'kibana/server'; +import { + createApmArtifact, + deleteApmArtifact, + listArtifacts, + updateSourceMapsOnFleetPolicies, +} from '../lib/fleet/source_maps'; +import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; + +export const sourceMapRt = t.intersection([ + t.type({ + version: t.number, + sources: t.array(t.string), + mappings: t.string, + }), + t.partial({ + names: t.array(t.string), + file: t.string, + sourceRoot: t.string, + sourcesContent: t.array(t.string), + }), +]); + +const listSourceMapRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/sourcemaps', + options: { tags: ['access:apm'] }, + handler: async ({ plugins, logger }) => { + try { + const fleetPluginStart = await plugins.fleet?.start(); + if (fleetPluginStart) { + const artifacts = await listArtifacts({ fleetPluginStart }); + return { artifacts }; + } + } catch (e) { + throw Boom.internal( + 'Something went wrong while fetching artifacts source maps', + e + ); + } + }, +}); + +const uploadSourceMapRoute = createApmServerRoute({ + endpoint: 'POST /api/apm/sourcemaps/{serviceName}/{serviceVersion}', + options: { tags: ['access:apm', 'access:apm_write'] }, + params: t.type({ + path: t.type({ + serviceName: t.string, + serviceVersion: t.string, + }), + body: t.type({ + bundleFilepath: t.string, + sourceMap: sourceMapRt, + }), + }), + handler: async ({ params, plugins, core }) => { + const { serviceName, serviceVersion } = params.path; + const { bundleFilepath, sourceMap } = params.body; + const fleetPluginStart = await plugins.fleet?.start(); + const coreStart = await core.start(); + const esClient = coreStart.elasticsearch.client.asInternalUser; + const savedObjectsClient = await getInternalSavedObjectsClient(core.setup); + try { + if (fleetPluginStart) { + const artifact = await createApmArtifact({ + fleetPluginStart, + apmArtifactBody: { + serviceName, + serviceVersion, + bundleFilepath, + sourceMap, + }, + }); + await updateSourceMapsOnFleetPolicies({ + core, + fleetPluginStart, + savedObjectsClient: (savedObjectsClient as unknown) as SavedObjectsClientContract, + elasticsearchClient: esClient, + }); + + return artifact; + } + } catch (e) { + throw Boom.internal( + 'Something went wrong while creating a new source map', + e + ); + } + }, +}); + +const deleteSourceMapRoute = createApmServerRoute({ + endpoint: 'DELETE /api/apm/sourcemaps/{id}', + options: { tags: ['access:apm', 'access:apm_write'] }, + params: t.type({ + path: t.type({ + id: t.string, + }), + }), + handler: async ({ context, params, plugins, core }) => { + const fleetPluginStart = await plugins.fleet?.start(); + const { id } = params.path; + const coreStart = await core.start(); + const esClient = coreStart.elasticsearch.client.asInternalUser; + const savedObjectsClient = await getInternalSavedObjectsClient(core.setup); + try { + if (fleetPluginStart) { + await deleteApmArtifact({ id, fleetPluginStart }); + await updateSourceMapsOnFleetPolicies({ + core, + fleetPluginStart, + savedObjectsClient: (savedObjectsClient as unknown) as SavedObjectsClientContract, + elasticsearchClient: esClient, + }); + } + } catch (e) { + throw Boom.internal( + `Something went wrong while deleting source map. id: ${id}`, + e + ); + } + }, +}); + +export const sourceMapsRouteRepository = createApmServerRouteRepository() + .add(listSourceMapRoute) + .add(uploadSourceMapRoute) + .add(deleteSourceMapRoute); diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index 1a60521667bba3..ca41db577700ea 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -12,7 +12,7 @@ export * from '../../common/translations'; export const INCIDENT_MANAGEMENT_SYSTEM_TITLE = i18n.translate( 'xpack.cases.configureCases.incidentManagementSystemTitle', { - defaultMessage: 'Connect to external incident management system', + defaultMessage: 'External incident management system', } ); @@ -20,7 +20,7 @@ export const INCIDENT_MANAGEMENT_SYSTEM_DESC = i18n.translate( 'xpack.cases.configureCases.incidentManagementSystemDesc', { defaultMessage: - 'You may optionally connect cases to an external incident management system of your choosing. This will allow you to push case data as an incident in your chosen third-party system.', + 'Connect your cases to an external incident management system. You can then push case data as an incident in a third-party system.', } ); @@ -38,7 +38,7 @@ export const ADD_NEW_CONNECTOR = i18n.translate('xpack.cases.configureCases.addN export const CASE_CLOSURE_OPTIONS_TITLE = i18n.translate( 'xpack.cases.configureCases.caseClosureOptionsTitle', { - defaultMessage: 'Case Closures', + defaultMessage: 'Case closures', } ); @@ -46,14 +46,14 @@ export const CASE_CLOSURE_OPTIONS_DESC = i18n.translate( 'xpack.cases.configureCases.caseClosureOptionsDesc', { defaultMessage: - 'Define how you wish cases to be closed. Automated case closures require an established connection to an external incident management system.', + 'Define how to close your cases. Automatic closures require an established connection to an external incident management system.', } ); export const CASE_CLOSURE_OPTIONS_SUB_CASES = i18n.translate( 'xpack.cases.configureCases.caseClosureOptionsSubCases', { - defaultMessage: 'Automated closures of sub-cases is not currently supported.', + defaultMessage: 'Automatic closure of sub-cases is not supported.', } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/licensing_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/licensing_logic.mock.ts index 2cea6061b63ab8..f227928b45821f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/licensing_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/licensing_logic.mock.ts @@ -11,8 +11,11 @@ export const mockLicensingValues = { license: licensingMock.createLicense(), hasPlatinumLicense: false, hasGoldLicense: false, + isTrial: false, + canManageLicense: true, }; jest.mock('../../shared/licensing', () => ({ + ...(jest.requireActual('../../shared/licensing') as object), LicensingLogic: { values: mockLicensingValues }, })); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.test.ts index 7b08e82a4cf209..f69e3492d26ebb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.test.ts @@ -6,7 +6,11 @@ */ import { DEFAULT_INITIAL_APP_DATA } from '../../../common/__mocks__'; -import { LogicMounter } from '../__mocks__/kea_logic'; +import { LogicMounter } from '../__mocks__/kea_logic/logic_mounter.test_helper'; + +jest.mock('../shared/licensing', () => ({ + LicensingLogic: { selectors: { hasPlatinumLicense: () => false } }, +})); import { AppLogic } from './app_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts index 44416b596e6ef9..90b37e6a4d4ee4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts @@ -9,6 +9,8 @@ import { kea, MakeLogicType } from 'kea'; import { InitialAppData } from '../../../common/types'; +import { LicensingLogic } from '../shared/licensing'; + import { ConfiguredLimits, Account, Role } from './types'; import { getRoleAbilities } from './utils/role'; @@ -43,8 +45,8 @@ export const AppLogic = kea [selectors.account], - ({ role }) => (role ? getRoleAbilities(role) : {}), + (selectors) => [selectors.account, LicensingLogic.selectors.hasPlatinumLicense], + ({ role }, hasPlatinumLicense) => (role ? getRoleAbilities(role, hasPlatinumLicense) : {}), ], }, }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.test.tsx index 286658c011002d..737908752911d9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.test.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiCopy, EuiLoadingContent, EuiPageContentBody } from '@elastic/eui'; +import { EuiCopy, EuiLoadingContent } from '@elastic/eui'; import { DEFAULT_META } from '../../../shared/constants'; import { externalUrl } from '../../../shared/enterprise_search_url'; @@ -20,6 +20,7 @@ import { externalUrl } from '../../../shared/enterprise_search_url'; import { Credentials } from './credentials'; import { CredentialsFlyout } from './credentials_flyout'; +import { CredentialsList } from './credentials_list'; describe('Credentials', () => { // Kea mocks @@ -42,7 +43,7 @@ describe('Credentials', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiPageContentBody)).toHaveLength(1); + expect(wrapper.find(CredentialsList)).toHaveLength(1); }); it('fetches data on mount', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx index 8918445982ea63..f81d8d64737dfb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx @@ -10,9 +10,7 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; import { - EuiPageHeader, EuiTitle, - EuiPageContentBody, EuiPanel, EuiCopy, EuiButtonIcon, @@ -25,8 +23,7 @@ import { import { i18n } from '@kbn/i18n'; import { externalUrl } from '../../../shared/enterprise_search_url/external_url'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { AppSearchPageTemplate } from '../layout'; import { CREDENTIALS_TITLE } from './constants'; import { CredentialsFlyout } from './credentials_flyout'; @@ -52,74 +49,72 @@ export const Credentials: React.FC = () => { }, []); return ( - <> - - - - {shouldShowCredentialsForm && } - - + + {shouldShowCredentialsForm && } + + +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.apiEndpoint', { + defaultMessage: 'Endpoint', + })} +

+ + + {(copy) => ( + <> + + {externalUrl.enterpriseSearchUrl} + + )} + + + + + +

- {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.apiEndpoint', { - defaultMessage: 'Endpoint', + {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.apiKeys', { + defaultMessage: 'API Keys', })}

- - {(copy) => ( - <> - - {externalUrl.enterpriseSearchUrl} - - )} - - - - - - -

- {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.apiKeys', { - defaultMessage: 'API Keys', - })} -

-
-
- - {!dataLoading && ( - showCredentialsForm()} - > - {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.createKey', { - defaultMessage: 'Create a key', - })} - - )} - -
- - - - {!!dataLoading ? : } - - - +
+ + {!dataLoading && ( + showCredentialsForm()} + > + {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.createKey', { + defaultMessage: 'Create a key', + })} + + )} + +
+ + + {!!dataLoading ? : } + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.test.tsx index 8034b72d885dab..04f05349217c07 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiIcon, EuiButton } from '@elastic/eui'; +import { EuiIcon, EuiButton, EuiTitle, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; import { LoadingOverlay } from '../../../shared/loading'; @@ -27,6 +27,16 @@ describe('DataPanel', () => { expect(wrapper.find('[data-test-subj="children"]').text()).toEqual('Look at this graph'); }); + it('conditionally renders a spacer between the header and children', () => { + const wrapper = shallow(Test} />); + + expect(wrapper.find(EuiSpacer)).toHaveLength(0); + + wrapper.setProps({ children: 'hello world' }); + + expect(wrapper.find(EuiSpacer)).toHaveLength(1); + }); + describe('components', () => { it('renders with an icon', () => { const wrapper = shallow(The Smoke Monster} iconType="eye" />); @@ -70,6 +80,26 @@ describe('DataPanel', () => { }); describe('props', () => { + it('passes titleSize to the title', () => { + const wrapper = shallow(Test} />); + + expect(wrapper.find(EuiTitle).prop('size')).toEqual('xs'); // Default + + wrapper.setProps({ titleSize: 's' }); + + expect(wrapper.find(EuiTitle).prop('size')).toEqual('s'); + }); + + it('passes responsive to the header flex group', () => { + const wrapper = shallow(Test} />); + + expect(wrapper.find(EuiFlexGroup).first().prop('responsive')).toEqual(false); + + wrapper.setProps({ responsive: true }); + + expect(wrapper.find(EuiFlexGroup).first().prop('responsive')).toEqual(true); + }); + it('renders panel color based on filled flag', () => { const wrapper = shallow(Test} />); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.tsx index ce878dc3cf29a9..4b22fbc93d4119 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/data_panel/data_panel.tsx @@ -13,10 +13,12 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiIconProps, EuiPanel, EuiSpacer, EuiText, EuiTitle, + EuiTitleProps, } from '@elastic/eui'; import { LoadingOverlay } from '../../../shared/loading'; @@ -25,9 +27,11 @@ import './data_panel.scss'; interface Props { title: React.ReactElement; // e.g., h2 tag - subtitle?: string; - iconType?: string; + titleSize?: EuiTitleProps['size']; + subtitle?: React.ReactNode; + iconType?: EuiIconProps['type']; action?: React.ReactNode; + responsive?: boolean; filled?: boolean; hasBorder?: boolean; isLoading?: boolean; @@ -36,9 +40,11 @@ interface Props { export const DataPanel: React.FC = ({ title, + titleSize = 'xs', subtitle, iconType, action, + responsive = false, filled, hasBorder, isLoading, @@ -59,7 +65,7 @@ export const DataPanel: React.FC = ({ hasShadow={false} aria-busy={isLoading} > - + {iconType && ( @@ -68,7 +74,7 @@ export const DataPanel: React.FC = ({ )} - {title} + {title} {subtitle && ( @@ -79,8 +85,12 @@ export const DataPanel: React.FC = ({ {action && {action}} - - {children} + {children && ( + <> + + {children} + + )} {isLoading && } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/components/empty_state.tsx index 0f9455a3b9228c..39fe02a84854cf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/components/empty_state.tsx @@ -7,43 +7,41 @@ import React from 'react'; -import { EuiButton, EuiEmptyPrompt, EuiPanel } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DOCS_PREFIX } from '../../../routes'; export const EmptyState = () => ( - - - {i18n.translate('xpack.enterpriseSearch.appSearch.documents.empty.title', { - defaultMessage: 'Add your first documents', - })} - - } - body={ -

- {i18n.translate('xpack.enterpriseSearch.appSearch.documents.empty.description', { - defaultMessage: - 'You can index documents using the App Search Web Crawler, by uploading JSON, or by using the API.', - })} -

- } - actions={ - - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.documents.empty.buttonLabel', { - defaultMessage: 'Read the documents guide', - })} - - } - /> -
+ + {i18n.translate('xpack.enterpriseSearch.appSearch.documents.empty.title', { + defaultMessage: 'Add your first documents', + })} + + } + body={ +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.documents.empty.description', { + defaultMessage: + 'You can index documents using the App Search Web Crawler, by uploading JSON, or by using the API.', + })} +

+ } + actions={ + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.documents.empty.buttonLabel', { + defaultMessage: 'Read the documents guide', + })} + + } + /> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx index 4aade8e61b0851..90da5bebe6d230 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx @@ -14,9 +14,10 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageHeader, EuiPageContent, EuiBasicTable } from '@elastic/eui'; +import { EuiPanel, EuiBasicTable } from '@elastic/eui'; + +import { getPageHeaderActions } from '../../../test_helpers'; -import { Loading } from '../../../shared/loading'; import { ResultFieldValue } from '../result'; import { DocumentDetail } from '.'; @@ -45,7 +46,7 @@ describe('DocumentDetail', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiPageContent).length).toBe(1); + expect(wrapper.find(EuiPanel).length).toBe(1); }); it('initializes data on mount', () => { @@ -59,17 +60,6 @@ describe('DocumentDetail', () => { expect(actions.setFields).toHaveBeenCalledWith([]); }); - it('will show a loader while data is loading', () => { - setMockValues({ - ...values, - dataLoading: true, - }); - - const wrapper = shallow(); - - expect(wrapper.find(Loading).length).toBe(1); - }); - describe('field values list', () => { let columns: any; @@ -102,8 +92,7 @@ describe('DocumentDetail', () => { it('will delete the document when the delete button is pressed', () => { const wrapper = shallow(); - const header = wrapper.find(EuiPageHeader).dive().children().dive(); - const button = header.find('[data-test-subj="DeleteDocumentButton"]'); + const button = getPageHeaderActions(wrapper).find('[data-test-subj="DeleteDocumentButton"]'); button.simulate('click'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx index 314c3529cf4db7..175fb1239d3802 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx @@ -10,22 +10,13 @@ import { useParams } from 'react-router-dom'; import { useActions, useValues } from 'kea'; -import { - EuiButton, - EuiPageHeader, - EuiPageContentBody, - EuiPageContent, - EuiBasicTable, - EuiBasicTableColumn, -} from '@elastic/eui'; +import { EuiPanel, EuiButton, EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DELETE_BUTTON_LABEL } from '../../../shared/constants'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; import { useDecodedParams } from '../../utils/encode_path_params'; import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { ResultFieldValue } from '../result'; import { DOCUMENTS_TITLE } from './constants'; @@ -52,10 +43,6 @@ export const DocumentDetail: React.FC = () => { }; }, []); - if (dataLoading) { - return ; - } - const columns: Array> = [ { name: i18n.translate('xpack.enterpriseSearch.appSearch.documentDetail.fieldHeader', { @@ -74,11 +61,11 @@ export const DocumentDetail: React.FC = () => { ]; return ( - <> - - { > {DELETE_BUTTON_LABEL} , - ]} - /> - - - - - - - + ], + }} + isLoading={dataLoading} + > + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx index 143ad3f55ff2fb..b5b6dd453c9df1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx @@ -10,9 +10,9 @@ import '../../__mocks__/engine_logic.mock'; import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; +import { getPageHeaderActions } from '../../../test_helpers'; import { DocumentCreationButton } from './components'; import { SearchExperience } from './search_experience'; @@ -22,6 +22,7 @@ import { Documents } from '.'; describe('Documents', () => { const values = { isMetaEngine: false, + engine: { document_count: 1 }, myRole: { canManageEngineDocuments: true }, }; @@ -36,9 +37,6 @@ describe('Documents', () => { }); describe('DocumentCreationButton', () => { - const getHeader = (wrapper: ShallowWrapper) => - wrapper.find(EuiPageHeader).dive().children().dive(); - it('renders a DocumentCreationButton if the user can manage engine documents', () => { setMockValues({ ...values, @@ -46,7 +44,7 @@ describe('Documents', () => { }); const wrapper = shallow(); - expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(true); + expect(getPageHeaderActions(wrapper).find(DocumentCreationButton).exists()).toBe(true); }); it('does not render a DocumentCreationButton if the user cannot manage engine documents', () => { @@ -56,7 +54,7 @@ describe('Documents', () => { }); const wrapper = shallow(); - expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(false); + expect(getPageHeaderActions(wrapper).find(DocumentCreationButton).exists()).toBe(false); }); it('does not render a DocumentCreationButton for meta engines even if the user can manage engine documents', () => { @@ -67,7 +65,7 @@ describe('Documents', () => { }); const wrapper = shallow(); - expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(false); + expect(getPageHeaderActions(wrapper).find(DocumentCreationButton).exists()).toBe(false); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx index b4122a715f9270..62c7759757bda8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx @@ -9,35 +9,32 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiPageHeader, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; - import { AppLogic } from '../../app_logic'; import { EngineLogic, getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; -import { DocumentCreationButton } from './components'; +import { DocumentCreationButton, EmptyState } from './components'; import { DOCUMENTS_TITLE } from './constants'; import { SearchExperience } from './search_experience'; export const Documents: React.FC = () => { - const { isMetaEngine } = useValues(EngineLogic); + const { isMetaEngine, engine } = useValues(EngineLogic); const { myRole } = useValues(AppLogic); return ( - <> - - ] - : undefined - } - /> - + ] : [], + }} + isEmptyState={!engine.document_count} + emptyState={} + > {isMetaEngine && ( <> { )} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss index d2e0a8155fa557..34aac402fbb39a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss @@ -15,6 +15,7 @@ .documentsSearchExperience__content { flex-grow: 4; + position: relative; } .documentsSearchExperience__pagingInfo { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.test.tsx index a4d1a92ee45a4f..3e8a9c1ab307c0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.test.tsx @@ -20,8 +20,6 @@ jest.mock('../../../../shared/use_local_storage', () => ({ })); import { useLocalStorage } from '../../../../shared/use_local_storage'; -import { EmptyState } from '../components'; - import { CustomizationCallout } from './customization_callout'; import { CustomizationModal } from './customization_modal'; import { SearchExperienceContent } from './search_experience_content'; @@ -58,14 +56,6 @@ describe('SearchExperience', () => { expect(wrapper.find(SearchExperienceContent)).toHaveLength(1); }); - it('renders an empty state when the engine does not have documents', () => { - setMockValues({ ...values, engine: { ...values.engine, document_count: 0 } }); - const wrapper = shallow(); - - expect(wrapper.find(EmptyState)).toHaveLength(1); - expect(wrapper.find(SearchExperienceContent)).toHaveLength(0); - }); - describe('when there are no selected filter fields', () => { let wrapper: ShallowWrapper; beforeEach(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx index 22029956601a65..709dfc69905f0a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx @@ -21,7 +21,6 @@ import './search_experience.scss'; import { externalUrl } from '../../../../shared/enterprise_search_url'; import { useLocalStorage } from '../../../../shared/use_local_storage'; import { EngineLogic } from '../../engine'; -import { EmptyState } from '../components'; import { buildSearchUIConfig } from './build_search_ui_config'; import { buildSortOptions } from './build_sort_options'; @@ -141,11 +140,7 @@ export const SearchExperience: React.FC = () => { )} - {engine.document_count && engine.document_count > 0 ? ( - - ) : ( - - )} + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.test.tsx index 44a6da51ec8d68..e573502d76b9fb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.test.tsx @@ -15,6 +15,7 @@ import { shallow } from 'enzyme'; // @ts-expect-error types are not available for this package yet import { Results } from '@elastic/react-search-ui'; +import { Loading } from '../../../../shared/loading'; import { SchemaType } from '../../../../shared/schema/types'; import { Pagination } from './pagination'; @@ -82,13 +83,13 @@ describe('SearchExperienceContent', () => { expect(wrapper.find(Pagination).exists()).toBe(true); }); - it('renders empty if a search was not performed yet', () => { + it('renders a loading state if a search was not performed yet', () => { setMockSearchContextState({ ...searchState, wasSearched: false, }); const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(true); + expect(wrapper.find(Loading)).toHaveLength(1); }); it('renders results if a search was performed and there are more than 0 totalResults', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx index 84fe721f9eb7f0..2322bcde831eba 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx @@ -14,6 +14,7 @@ import { EuiFlexGroup, EuiSpacer, EuiEmptyPrompt } from '@elastic/eui'; import { Results, Paging, ResultsPerPage } from '@elastic/react-search-ui'; import { i18n } from '@kbn/i18n'; +import { Loading } from '../../../../shared/loading'; import { EngineLogic } from '../../engine'; import { Result } from '../../result/types'; @@ -26,7 +27,7 @@ export const SearchExperienceContent: React.FC = () => { const { isMetaEngine, engine } = useValues(EngineLogic); - if (!wasSearched) return null; + if (!wasSearched) return ; if (totalResults) { return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.scss index c750f63dab248a..486abeb3dce4c9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.scss @@ -6,21 +6,17 @@ */ .appSearchNavEngineLabel { - padding-top: $euiSizeS; + margin-left: $euiSizeS; + padding-top: $euiSizeXS; padding-bottom: $euiSizeS; - .euiText { - font-weight: $euiFontWeightMedium; - } .euiBadge { margin-top: $euiSizeXS; } } -.appSearchNavIcons { - // EUI override - &.euiFlexItem { - flex-grow: 0; - flex-direction: row; - } +.appSearchNavIcon { + // EuiSideNav renders icons to the left of the nav link by default, but we use icons + // as warning or error indicators & prefer to render them on the right side of the nav + order: 1; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx index c2b0a6a50fd068..015fb997c29ed4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.test.tsx @@ -6,8 +6,14 @@ */ import { setMockValues } from '../../../__mocks__/kea_logic'; +import { mockUseRouteMatch } from '../../../__mocks__/react_router'; import { mockEngineValues } from '../../__mocks__'; +jest.mock('../../../shared/layout', () => ({ + ...jest.requireActual('../../../shared/layout'), // TODO: Remove once side nav components are gone + generateNavLink: jest.fn(({ to }) => ({ href: to })), +})); + import React from 'react'; import { shallow } from 'enzyme'; @@ -16,7 +22,305 @@ import { EuiBadge, EuiIcon } from '@elastic/eui'; import { rerender } from '../../../test_helpers'; -import { EngineNav } from './engine_nav'; +import { useEngineNav, EngineNav } from './engine_nav'; + +describe('useEngineNav', () => { + const values = { ...mockEngineValues, myRole: {}, dataLoading: false }; + + beforeEach(() => { + setMockValues(values); + mockUseRouteMatch.mockReturnValue(true); + }); + + describe('returns empty', () => { + it('does not return engine nav items if not on an engine route', () => { + mockUseRouteMatch.mockReturnValueOnce(false); + expect(useEngineNav()).toBeUndefined(); + }); + + it('does not return engine nav items if data is still loading', () => { + setMockValues({ ...values, dataLoading: true }); + expect(useEngineNav()).toBeUndefined(); + }); + + it('does not return engine nav items if engine data is missing', () => { + setMockValues({ ...values, engineName: '' }); + expect(useEngineNav()).toBeUndefined(); + }); + }); + + describe('returns an array of EUI side nav items', () => { + const BASE_NAV = [ + { + id: 'engineName', + name: 'some-engine', + renderItem: expect.any(Function), + 'data-test-subj': 'EngineLabel', + }, + { + id: 'overview', + name: 'Overview', + href: '/engines/some-engine', + 'data-test-subj': 'EngineOverviewLink', + }, + ]; + + it('always returns an engine label and overview link', () => { + expect(useEngineNav()).toEqual(BASE_NAV); + }); + + describe('engine label', () => { + const renderEngineLabel = (engineNav: any) => { + return shallow(engineNav[0].renderItem() as any); + }; + + it('renders the capitalized engine name', () => { + const wrapper = renderEngineLabel(useEngineNav()); + const name = wrapper.find('.eui-textTruncate'); + + expect(name.text()).toEqual('SOME-ENGINE'); + expect(wrapper.find(EuiBadge)).toHaveLength(0); + }); + + it('renders a sample engine badge for the sample engine', () => { + setMockValues({ ...values, isSampleEngine: true }); + const wrapper = renderEngineLabel(useEngineNav()); + + expect(wrapper.find(EuiBadge).prop('children')).toEqual('SAMPLE ENGINE'); + }); + + it('renders a meta engine badge for meta engines', () => { + setMockValues({ ...values, isMetaEngine: true }); + const wrapper = renderEngineLabel(useEngineNav()); + + expect(wrapper.find(EuiBadge).prop('children')).toEqual('META ENGINE'); + }); + }); + + it('returns an analytics nav item', () => { + setMockValues({ ...values, myRole: { canViewEngineAnalytics: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'analytics', + name: 'Analytics', + href: '/engines/some-engine/analytics', + 'data-test-subj': 'EngineAnalyticsLink', + }, + ]); + }); + + it('returns a documents nav item', () => { + setMockValues({ ...values, myRole: { canViewEngineDocuments: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'documents', + name: 'Documents', + href: '/engines/some-engine/documents', + 'data-test-subj': 'EngineDocumentsLink', + }, + ]); + }); + + it('returns a schema nav item', () => { + setMockValues({ ...values, myRole: { canViewEngineSchema: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'schema', + name: 'Schema', + href: '/engines/some-engine/schema', + 'data-test-subj': 'EngineSchemaLink', + icon: expect.anything(), + }, + ]); + }); + + describe('schema nav icons', () => { + const myRole = { canViewEngineSchema: true }; + + const renderIcons = (engineNav: any) => { + return shallow(
{engineNav[2].icon}
); + }; + + it('renders schema errors alert icon', () => { + setMockValues({ ...values, myRole, hasSchemaErrors: true }); + const wrapper = renderIcons(useEngineNav()); + + expect(wrapper.find('[data-test-subj="EngineNavSchemaErrors"]')).toHaveLength(1); + }); + + it('renders unconfirmed schema fields info icon', () => { + setMockValues({ ...values, myRole, hasUnconfirmedSchemaFields: true }); + const wrapper = renderIcons(useEngineNav()); + + expect(wrapper.find('[data-test-subj="EngineNavSchemaUnconfirmedFields"]')).toHaveLength(1); + }); + + it('renders schema conflicts alert icon', () => { + setMockValues({ ...values, myRole, hasSchemaConflicts: true }); + const wrapper = renderIcons(useEngineNav()); + + expect(wrapper.find('[data-test-subj="EngineNavSchemaConflicts"]')).toHaveLength(1); + }); + }); + + describe('crawler', () => { + const myRole = { canViewEngineCrawler: true }; + + it('returns a crawler nav item', () => { + setMockValues({ ...values, myRole }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'crawler', + name: 'Web Crawler', + href: '/engines/some-engine/crawler', + 'data-test-subj': 'EngineCrawlerLink', + }, + ]); + }); + + it('does not return a crawler nav item for meta engines', () => { + setMockValues({ ...values, myRole, isMetaEngine: true }); + expect(useEngineNav()).toEqual(BASE_NAV); + }); + }); + + describe('meta engine source engines', () => { + const myRole = { canViewMetaEngineSourceEngines: true }; + + it('returns a source engines nav item', () => { + setMockValues({ ...values, myRole, isMetaEngine: true }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'sourceEngines', + name: 'Engines', + href: '/engines/some-engine/engines', + 'data-test-subj': 'MetaEngineEnginesLink', + }, + ]); + }); + + it('does not return a source engines nav item for non-meta engines', () => { + setMockValues({ ...values, myRole, isMetaEngine: false }); + expect(useEngineNav()).toEqual(BASE_NAV); + }); + }); + + it('returns a relevance tuning nav item', () => { + setMockValues({ ...values, myRole: { canManageEngineRelevanceTuning: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'relevanceTuning', + name: 'Relevance Tuning', + href: '/engines/some-engine/relevance_tuning', + 'data-test-subj': 'EngineRelevanceTuningLink', + icon: expect.anything(), + }, + ]); + }); + + describe('relevance tuning nav icons', () => { + const myRole = { canManageEngineRelevanceTuning: true }; + + const renderIcons = (engineNav: any) => { + return shallow(
{engineNav[2].icon}
); + }; + + it('renders unconfirmed schema fields info icon', () => { + setMockValues({ ...values, myRole, engine: { unsearchedUnconfirmedFields: true } }); + const wrapper = renderIcons(useEngineNav()); + expect( + wrapper.find('[data-test-subj="EngineNavRelevanceTuningUnsearchedFields"]') + ).toHaveLength(1); + }); + + it('renders schema conflicts alert icon', () => { + setMockValues({ ...values, myRole, engine: { invalidBoosts: true } }); + const wrapper = renderIcons(useEngineNav()); + expect( + wrapper.find('[data-test-subj="EngineNavRelevanceTuningInvalidBoosts"]') + ).toHaveLength(1); + }); + + it('can render multiple icons', () => { + const engine = { invalidBoosts: true, unsearchedUnconfirmedFields: true }; + setMockValues({ ...values, myRole, engine }); + const wrapper = renderIcons(useEngineNav()); + expect(wrapper.find(EuiIcon)).toHaveLength(2); + }); + }); + + it('returns a synonyms nav item', () => { + setMockValues({ ...values, myRole: { canManageEngineSynonyms: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'synonyms', + name: 'Synonyms', + href: '/engines/some-engine/synonyms', + 'data-test-subj': 'EngineSynonymsLink', + }, + ]); + }); + + it('returns a curations nav item', () => { + setMockValues({ ...values, myRole: { canManageEngineCurations: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'curations', + name: 'Curations', + href: '/engines/some-engine/curations', + 'data-test-subj': 'EngineCurationsLink', + }, + ]); + }); + + it('returns a results settings nav item', () => { + setMockValues({ ...values, myRole: { canManageEngineResultSettings: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'resultSettings', + name: 'Result Settings', + href: '/engines/some-engine/result_settings', + 'data-test-subj': 'EngineResultSettingsLink', + }, + ]); + }); + + it('returns a Search UI nav item', () => { + setMockValues({ ...values, myRole: { canManageEngineSearchUi: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'searchUI', + name: 'Search UI', + href: '/engines/some-engine/search_ui', + 'data-test-subj': 'EngineSearchUILink', + }, + ]); + }); + + it('returns an API logs nav item', () => { + setMockValues({ ...values, myRole: { canViewEngineApiLogs: true } }); + expect(useEngineNav()).toEqual([ + ...BASE_NAV, + { + id: 'apiLogs', + name: 'API Logs', + href: '/engines/some-engine/api_logs', + 'data-test-subj': 'EngineAPILogsLink', + }, + ]); + }); + }); +}); describe('EngineNav', () => { const values = { ...mockEngineValues, myRole: {}, dataLoading: 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 0edf01bada9381..76e751cf4da5f1 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 @@ -6,13 +6,21 @@ */ import React from 'react'; +import { useRouteMatch } from 'react-router-dom'; import { useValues } from 'kea'; -import { EuiText, EuiBadge, EuiIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiSideNavItemType, + EuiText, + EuiBadge, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { SideNavLink, SideNavItem } from '../../../shared/layout'; +import { generateNavLink, SideNavLink, SideNavItem } from '../../../shared/layout'; import { AppLogic } from '../../app_logic'; import { ENGINE_PATH, @@ -47,6 +55,255 @@ import { EngineLogic, generateEnginePath } from './'; import './engine_nav.scss'; +export const useEngineNav = () => { + const isEngineRoute = !!useRouteMatch(ENGINE_PATH); + const { + myRole: { + canViewEngineAnalytics, + canViewEngineDocuments, + canViewEngineSchema, + canViewEngineCrawler, + canViewMetaEngineSourceEngines, + canManageEngineSynonyms, + canManageEngineCurations, + canManageEngineRelevanceTuning, + canManageEngineResultSettings, + canManageEngineSearchUi, + canViewEngineApiLogs, + }, + } = useValues(AppLogic); + const { + engineName, + dataLoading, + isSampleEngine, + isMetaEngine, + hasSchemaErrors, + hasSchemaConflicts, + hasUnconfirmedSchemaFields, + engine, + } = useValues(EngineLogic); + + if (!isEngineRoute) return undefined; + if (dataLoading) return undefined; + if (!engineName) return undefined; + + const navItems: Array> = [ + { + id: 'engineName', + name: engineName, + renderItem: () => ( + +
{engineName.toUpperCase()}
+ {isSampleEngine && ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.sampleEngineBadge', { + defaultMessage: 'SAMPLE ENGINE', + })} + + )} + {isMetaEngine && ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.metaEngineBadge', { + defaultMessage: 'META ENGINE', + })} + + )} +
+ ), + 'data-test-subj': 'EngineLabel', + }, + { + id: 'overview', + name: OVERVIEW_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_PATH) }), + 'data-test-subj': 'EngineOverviewLink', + }, + ]; + + if (canViewEngineAnalytics) { + navItems.push({ + id: 'analytics', + name: ANALYTICS_TITLE, + ...generateNavLink({ + to: generateEnginePath(ENGINE_ANALYTICS_PATH), + shouldShowActiveForSubroutes: true, + }), + 'data-test-subj': 'EngineAnalyticsLink', + }); + } + + if (canViewEngineDocuments) { + navItems.push({ + id: 'documents', + name: DOCUMENTS_TITLE, + ...generateNavLink({ + to: generateEnginePath(ENGINE_DOCUMENTS_PATH), + shouldShowActiveForSubroutes: true, + }), + 'data-test-subj': 'EngineDocumentsLink', + }); + } + + if (canViewEngineSchema) { + navItems.push({ + id: 'schema', + name: SCHEMA_TITLE, + ...generateNavLink({ + to: generateEnginePath(ENGINE_SCHEMA_PATH), + shouldShowActiveForSubroutes: true, + }), + 'data-test-subj': 'EngineSchemaLink', + icon: ( + <> + {hasSchemaErrors && ( + + )} + {hasUnconfirmedSchemaFields && ( + + )} + {hasSchemaConflicts && ( + + )} + + ), + }); + } + + if (canViewEngineCrawler && !isMetaEngine) { + navItems.push({ + id: 'crawler', + name: CRAWLER_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_CRAWLER_PATH) }), + 'data-test-subj': 'EngineCrawlerLink', + }); + } + + if (canViewMetaEngineSourceEngines && isMetaEngine) { + navItems.push({ + id: 'sourceEngines', + name: ENGINES_TITLE, + ...generateNavLink({ to: generateEnginePath(META_ENGINE_SOURCE_ENGINES_PATH) }), + 'data-test-subj': 'MetaEngineEnginesLink', + }); + } + + if (canManageEngineRelevanceTuning) { + const { invalidBoosts, unsearchedUnconfirmedFields } = engine; + + navItems.push({ + id: 'relevanceTuning', + name: RELEVANCE_TUNING_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_RELEVANCE_TUNING_PATH) }), + 'data-test-subj': 'EngineRelevanceTuningLink', + icon: ( + <> + {invalidBoosts && ( + + )} + {unsearchedUnconfirmedFields && ( + + )} + + ), + }); + } + + if (canManageEngineSynonyms) { + navItems.push({ + id: 'synonyms', + name: SYNONYMS_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_SYNONYMS_PATH) }), + 'data-test-subj': 'EngineSynonymsLink', + }); + } + + if (canManageEngineCurations) { + navItems.push({ + id: 'curations', + name: CURATIONS_TITLE, + ...generateNavLink({ + to: generateEnginePath(ENGINE_CURATIONS_PATH), + shouldShowActiveForSubroutes: true, + }), + 'data-test-subj': 'EngineCurationsLink', + }); + } + + if (canManageEngineResultSettings) { + navItems.push({ + id: 'resultSettings', + name: RESULT_SETTINGS_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_RESULT_SETTINGS_PATH) }), + 'data-test-subj': 'EngineResultSettingsLink', + }); + } + + if (canManageEngineSearchUi) { + navItems.push({ + id: 'searchUI', + name: SEARCH_UI_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_SEARCH_UI_PATH) }), + 'data-test-subj': 'EngineSearchUILink', + }); + } + + if (canViewEngineApiLogs) { + navItems.push({ + id: 'apiLogs', + name: API_LOGS_TITLE, + ...generateNavLink({ to: generateEnginePath(ENGINE_API_LOGS_PATH) }), + 'data-test-subj': 'EngineAPILogsLink', + }); + } + + return navItems; +}; + +// TODO: Delete the below once page template migration is complete + export const EngineNav: React.FC = () => { const { myRole: { 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 b74c31adca4386..ee1c0578debfc0 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 @@ -19,7 +19,6 @@ import { Switch, Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { Loading } from '../../../shared/loading'; import { AnalyticsRouter } from '../analytics'; import { ApiLogs } from '../api_logs'; import { CrawlerRouter } from '../crawler'; @@ -80,20 +79,20 @@ describe('EngineRouter', () => { ); }); - it('renders a loading component if async data is still loading', () => { + it('renders a loading page template if async data is still loading', () => { setMockValues({ ...values, dataLoading: true }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('isLoading')).toEqual(true); }); // This would happen if a user jumps around from one engine route to another. If the engine name // on the path has changed, but we still have an engine stored in state, we do not want to load // any route views as they would be rendering with the wrong data. - it('renders a loading component if the engine stored in state is stale', () => { + it('renders a loading page template if the engine stored in state is stale', () => { setMockValues({ ...values, engineName: 'some-engine' }); mockUseParams.mockReturnValue({ engineName: 'some-new-engine' }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('isLoading')).toEqual(true); }); it('renders a default engine overview', () => { 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 40cc2ef0368c05..98627950016fb4 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 @@ -13,11 +13,12 @@ import { useValues, useActions } from 'kea'; import { i18n } from '@kbn/i18n'; import { setQueuedErrorMessage } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; +import { Layout } from '../../../shared/layout'; import { AppLogic } from '../../app_logic'; +import { AppSearchNav } from '../../index'; import { + ENGINE_PATH, ENGINES_PATH, ENGINE_ANALYTICS_PATH, ENGINE_DOCUMENTS_PATH, @@ -38,6 +39,7 @@ import { CrawlerRouter } from '../crawler'; import { CurationsRouter } from '../curations'; import { DocumentDetail, Documents } from '../documents'; import { EngineOverview } from '../engine_overview'; +import { AppSearchPageTemplate } from '../layout'; import { RelevanceTuning } from '../relevance_tuning'; import { ResultSettings } from '../result_settings'; import { SchemaRouter } from '../schema'; @@ -45,7 +47,7 @@ import { SearchUI } from '../search_ui'; import { SourceEngines } from '../source_engines'; import { Synonyms } from '../synonyms'; -import { EngineLogic, getEngineBreadcrumbs } from './'; +import { EngineLogic } from './'; export const EngineRouter: React.FC = () => { const { @@ -85,15 +87,13 @@ export const EngineRouter: React.FC = () => { } const isLoadingNewEngine = engineName !== engineNameFromUrl; - if (isLoadingNewEngine || dataLoading) return ; + if (isLoadingNewEngine || dataLoading) return ; return ( - {canViewEngineAnalytics && ( - - - - )} + + + {canViewEngineDocuments && ( @@ -104,55 +104,59 @@ export const EngineRouter: React.FC = () => { )} - {canViewEngineSchema && ( - - - - )} - {canManageEngineCurations && ( - - - - )} - {canManageEngineRelevanceTuning && ( - - - - )} - {canManageEngineSynonyms && ( - - - - )} - {canManageEngineResultSettings && ( - - - - )} - {canViewEngineApiLogs && ( - - - - )} - {canManageEngineSearchUi && ( - - - - )} - {canViewMetaEngineSourceEngines && ( - - - - )} - {canViewEngineCrawler && ( - - - - )} - - - - + {/* TODO: Remove layout once page template migration is over */} + }> + {canViewEngineAnalytics && ( + + + + )} + {canViewEngineSchema && ( + + + + )} + {canManageEngineCurations && ( + + + + )} + {canManageEngineRelevanceTuning && ( + + + + )} + {canManageEngineSynonyms && ( + + + + )} + {canManageEngineResultSettings && ( + + + + )} + {canViewEngineApiLogs && ( + + + + )} + {canManageEngineSearchUi && ( + + + + )} + {canViewMetaEngineSourceEngines && ( + + + + )} + {canViewEngineCrawler && ( + + + + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx index a3b2f4cfd8b9f5..edacd74e046a28 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -12,8 +12,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Loading } from '../../../shared/loading'; - import { EmptyEngineOverview } from './engine_overview_empty'; import { EngineOverviewMetrics } from './engine_overview_metrics'; @@ -46,10 +44,10 @@ describe('EngineOverview', () => { expect(actions.pollForOverviewMetrics).toHaveBeenCalledTimes(1); }); - it('renders a loading component if async data is still loading', () => { + it('renders a loading page template if async data is still loading', () => { setMockValues({ ...values, dataLoading: true }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('isLoading')).toEqual(true); }); describe('EmptyEngineOverview', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 77552b36af2391..4c15ffd8b7f947 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -9,9 +9,9 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { Loading } from '../../../shared/loading'; import { AppLogic } from '../../app_logic'; import { EngineLogic } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { EmptyEngineOverview } from './engine_overview_empty'; @@ -32,9 +32,7 @@ export const EngineOverview: React.FC = () => { pollForOverviewMetrics(); }, []); - if (dataLoading) { - return ; - } + if (dataLoading) return ; const engineHasDocuments = documentCount > 0; const canAddDocuments = canManageEngineDocuments && canViewEngineCredentials; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx index ea47dc8956ddd9..6750ebf1140e03 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx @@ -5,13 +5,16 @@ * 2.0. */ +import '../../__mocks__/engine_logic.mock'; + import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiPageHeader, EuiButton } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { docLinks } from '../../../shared/doc_links'; +import { getPageTitle, getPageHeaderActions } from '../../../test_helpers'; import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation'; @@ -25,12 +28,13 @@ describe('EmptyEngineOverview', () => { }); it('renders', () => { - expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('Engine setup'); + expect(getPageTitle(wrapper)).toEqual('Engine setup'); }); it('renders a documentation link', () => { - const header = wrapper.find(EuiPageHeader).dive().children().dive(); - expect(header.find(EuiButton).prop('href')).toEqual(`${docLinks.appSearchBase}/index.html`); + expect(getPageHeaderActions(wrapper).find(EuiButton).prop('href')).toEqual( + `${docLinks.appSearchBase}/index.html` + ); }); it('renders document creation components', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx index 959d544a673243..27d9c3723f1268 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx @@ -7,35 +7,36 @@ import React from 'react'; -import { EuiPageHeader, EuiPageContentBody, EuiButton } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../shared/flash_messages'; import { DOCS_PREFIX } from '../../routes'; import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation'; +import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; + export const EmptyEngineOverview: React.FC = () => { return ( - <> - {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.overview.empty.headingAction', { defaultMessage: 'View documentation' } )} , - ]} - /> - - - - - - + ], + }} + > + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx index 00ac2af219bff5..620d913c5f9a7d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx @@ -5,11 +5,13 @@ * 2.0. */ +import '../../__mocks__/engine_logic.mock'; + import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; +import { getPageTitle } from '../../../test_helpers'; import { TotalStats, TotalCharts, RecentApiLogs } from './components'; import { EngineOverviewMetrics } from './engine_overview_metrics'; @@ -18,7 +20,7 @@ describe('EngineOverviewMetrics', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('Engine overview'); + expect(getPageTitle(wrapper)).toEqual('Engine overview'); expect(wrapper.find(TotalStats)).toHaveLength(1); expect(wrapper.find(TotalCharts)).toHaveLength(1); expect(wrapper.find(RecentApiLogs)).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx index 2b01cfae49a201..b47ae21104ae96 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx @@ -7,23 +7,24 @@ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiPageHeader, EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../shared/flash_messages'; +import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { TotalStats, TotalCharts, RecentApiLogs } from './components'; export const EngineOverviewMetrics: React.FC = () => { return ( - <> - - - + }), + }} + > @@ -34,6 +35,6 @@ export const EngineOverviewMetrics: React.FC = () => { - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.test.tsx index 1eab32d64b77f1..8b4f5a69b81415 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.test.tsx @@ -19,7 +19,7 @@ describe('EmptyMetaEnginesState', () => { .find(EuiEmptyPrompt) .dive(); - expect(wrapper.find('h2').text()).toEqual('Create your first meta engine'); + expect(wrapper.find('h3').text()).toEqual('Create your first meta engine'); expect(wrapper.find(EuiButton).prop('href')).toEqual( expect.stringContaining('/meta-engines-guide.html') ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.tsx index 58bf3f0a0195ea..ad96f21022f2b5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_meta_engines_state.tsx @@ -15,12 +15,13 @@ import { DOCS_PREFIX } from '../../../routes'; export const EmptyMetaEnginesState: React.FC = () => ( +

{i18n.translate('xpack.enterpriseSearch.appSearch.engines.metaEngines.emptyPromptTitle', { defaultMessage: 'Create your first meta engine', })} -

+ } + titleSize="s" body={

{i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.tsx similarity index 63% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.tsx index d01e89e004d28d..223c33f9b9592a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.tsx @@ -5,7 +5,17 @@ * 2.0. */ +import React from 'react'; + +import { EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { DOCS_PREFIX } from '../../routes'; +import { + META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION, + META_ENGINE_CREATION_FORM_DOCUMENTATION_LINK, +} from '../meta_engine_creation/constants'; export const ENGINES_TITLE = i18n.translate('xpack.enterpriseSearch.appSearch.engines.title', { defaultMessage: 'Engines', @@ -21,6 +31,24 @@ export const META_ENGINES_TITLE = i18n.translate( { defaultMessage: 'Meta Engines' } ); +export const META_ENGINES_DESCRIPTION = ( + <> + {META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION} +
+ + {META_ENGINE_CREATION_FORM_DOCUMENTATION_LINK} + + ), + }} + /> + +); + export const SOURCE_ENGINES_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.enginesOverview.metaEnginesTable.sourceEngines.title', { defaultMessage: 'Source Engines' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx index 8825c322fb8d5f..a90e1369593d97 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx @@ -42,7 +42,7 @@ describe('EnginesOverview', () => { metaEnginesLoading: false, hasPlatinumLicense: false, // AppLogic - myRole: { canManageEngines: false }, + myRole: { canManageEngines: false, canManageMetaEngines: false }, // MetaEnginesTableLogic expandedSourceEngines: {}, conflictingEnginesSets: {}, @@ -85,17 +85,25 @@ describe('EnginesOverview', () => { expect(actions.loadEngines).toHaveBeenCalled(); }); - describe('when the user can manage/create engines', () => { - it('renders a create engine button which takes users to the create engine page', () => { + describe('engine creation', () => { + it('renders a create engine action when the users can create engines', () => { setMockValues({ ...valuesWithEngines, myRole: { canManageEngines: true }, }); const wrapper = shallow(); - expect( - wrapper.find('[data-test-subj="appSearchEnginesEngineCreationButton"]').prop('to') - ).toEqual('/engine_creation'); + expect(wrapper.find('[data-test-subj="appSearchEngines"]').prop('action')).toBeTruthy(); + }); + + it('does not render a create engine action if the user cannot create engines', () => { + setMockValues({ + ...valuesWithEngines, + myRole: { canManageEngines: false }, + }); + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="appSearchEngines"]').prop('action')).toBeFalsy(); }); }); @@ -111,19 +119,41 @@ describe('EnginesOverview', () => { expect(actions.loadMetaEngines).toHaveBeenCalled(); }); - describe('when the user can manage/create engines', () => { - it('renders a create engine button which takes users to the create meta engine page', () => { + describe('meta engine creation', () => { + it('renders a create meta engine action when the user can create meta engines', () => { setMockValues({ ...valuesWithEngines, hasPlatinumLicense: true, - myRole: { canManageEngines: true }, + myRole: { canManageMetaEngines: true }, }); const wrapper = shallow(); - expect( - wrapper.find('[data-test-subj="appSearchEnginesMetaEngineCreationButton"]').prop('to') - ).toEqual('/meta_engine_creation'); + expect(wrapper.find('[data-test-subj="appSearchMetaEngines"]').prop('action')).toBeTruthy(); }); + + it('does not render a create meta engine action if user cannot create meta engines', () => { + setMockValues({ + ...valuesWithEngines, + hasPlatinumLicense: true, + myRole: { canManageMetaEngines: false }, + }); + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="appSearchMetaEngines"]').prop('action')).toBeFalsy(); + }); + }); + }); + + describe('when an account does not have a platinum license', () => { + it('renders a license call to action in place of the meta engines table', () => { + setMockValues({ + ...valuesWithEngines, + hasPlatinumLicense: false, + }); + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="metaEnginesLicenseCTA"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="appSearchMetaEngines"]')).toHaveLength(0); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx index 44111a5ecbe66f..4dff2460521388 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx @@ -9,23 +9,15 @@ import React, { useEffect } from 'react'; import { useValues, useActions } from 'kea'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiPageContentHeader, - EuiPageContentHeaderSection, - EuiPageContentBody, - EuiTitle, - EuiSpacer, -} from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; -import { LicensingLogic } from '../../../shared/licensing'; +import { LicensingLogic, ManageLicenseButton } from '../../../shared/licensing'; import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { convertMetaToPagination, handlePageChange } from '../../../shared/table_pagination'; import { AppLogic } from '../../app_logic'; import { EngineIcon, MetaEngineIcon } from '../../icons'; import { ENGINE_CREATION_PATH, META_ENGINE_CREATION_PATH } from '../../routes'; +import { DataPanel } from '../data_panel'; import { AppSearchPageTemplate } from '../layout'; import { LaunchAppSearchButton, EmptyState, EmptyMetaEnginesState } from './components'; @@ -37,13 +29,14 @@ import { CREATE_A_META_ENGINE_BUTTON_LABEL, ENGINES_TITLE, META_ENGINES_TITLE, + META_ENGINES_DESCRIPTION, } from './constants'; import { EnginesLogic } from './engines_logic'; export const EnginesOverview: React.FC = () => { const { hasPlatinumLicense } = useValues(LicensingLogic); const { - myRole: { canManageEngines }, + myRole: { canManageEngines, canManageMetaEngines }, } = useValues(AppLogic); const { @@ -80,93 +73,81 @@ export const EnginesOverview: React.FC = () => { isEmptyState={!engines.length} emptyState={} > - - - - - - - - - -

{ENGINES_TITLE}

- - - - - - {canManageEngines && ( + {ENGINES_TITLE}} + titleSize="s" + action={ + canManageEngines && ( + + {CREATE_AN_ENGINE_BUTTON_LABEL} + + ) + } + data-test-subj="appSearchEngines" + > + + + + {hasPlatinumLicense ? ( + {META_ENGINES_TITLE}} + titleSize="s" + action={ + canManageMetaEngines && ( - {CREATE_AN_ENGINE_BUTTON_LABEL} + {CREATE_A_META_ENGINE_BUTTON_LABEL} - )} - - - - - + } + onChange={handlePageChange(onMetaEnginesPagination)} /> - - - {hasPlatinumLicense && ( - <> - - - - - - - - - -

{META_ENGINES_TITLE}

-
-
-
-
- - {canManageEngines && ( - - {CREATE_A_META_ENGINE_BUTTON_LABEL} - - )} - -
- - - } - onChange={handlePageChange(onMetaEnginesPagination)} - /> - - - )} - +
+ ) : ( + {META_ENGINES_TITLE}} + titleSize="s" + subtitle={META_ENGINES_DESCRIPTION} + action={} + data-test-subj="metaEnginesLicenseCTA" + /> + )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx index 8b06f4b26835d4..80230394ce2a2f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx @@ -10,6 +10,9 @@ import { setMockValues } from '../../../__mocks__/kea_logic'; jest.mock('../../../shared/layout', () => ({ generateNavLink: jest.fn(({ to }) => ({ href: to })), })); +jest.mock('../engine/engine_nav', () => ({ + useEngineNav: () => [], +})); import { useAppSearchNav } from './nav'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx index 57fa740caebec2..4737fbcf07e23c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx @@ -15,6 +15,7 @@ import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; import { AppLogic } from '../../app_logic'; import { ENGINES_PATH, SETTINGS_PATH, CREDENTIALS_PATH, ROLE_MAPPINGS_PATH } from '../../routes'; import { CREDENTIALS_TITLE } from '../credentials'; +import { useEngineNav } from '../engine/engine_nav'; import { ENGINES_TITLE } from '../engines'; import { SETTINGS_TITLE } from '../settings'; @@ -28,7 +29,7 @@ export const useAppSearchNav = () => { id: 'engines', name: ENGINES_TITLE, ...generateNavLink({ to: ENGINES_PATH, isRoot: true }), - items: [], // TODO: Engine nav + items: useEngineNav(), }, ]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_panel.tsx index 76fdcdac58ad46..fb4b503c7e62c1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_panel.tsx @@ -9,7 +9,15 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiLink, EuiSpacer, EuiSwitch, EuiText, EuiTextColor, EuiTitle } from '@elastic/eui'; +import { + EuiPanel, + EuiLink, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTextColor, + EuiTitle, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DOCS_PREFIX } from '../../../routes'; @@ -30,7 +38,7 @@ export const LogRetentionPanel: React.FC = () => { }, []); return ( -
+

{i18n.translate('xpack.enterpriseSearch.appSearch.settings.logRetention.title', { @@ -104,6 +112,6 @@ export const LogRetentionPanel: React.FC = () => { data-test-subj="LogRetentionPanelAPISwitch" /> -

+ ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.test.tsx index 41d446b8e36fcb..1ad12856a92e1c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.test.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageContentBody } from '@elastic/eui'; +import { LogRetentionPanel } from './log_retention'; import { Settings } from './settings'; describe('Settings', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiPageContentBody)).toHaveLength(1); + expect(wrapper.find(LogRetentionPanel)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.tsx index 2d5dd08f81288a..ddbf046d75ec13 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/settings.tsx @@ -7,10 +7,7 @@ import React from 'react'; -import { EuiPageHeader, EuiPageContent, EuiPageContentBody } from '@elastic/eui'; - -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { AppSearchPageTemplate } from '../layout'; import { LogRetentionPanel, LogRetentionConfirmationModal } from './log_retention'; @@ -18,16 +15,9 @@ import { SETTINGS_TITLE } from './'; export const Settings: React.FC = () => { return ( - <> - - - - - - - - - - + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 4d8ff80326715b..2402a6ecc64016 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -24,6 +24,7 @@ import { rerender } from '../test_helpers'; jest.mock('./app_logic', () => ({ AppLogic: jest.fn() })); import { AppLogic } from './app_logic'; +import { Credentials } from './components/credentials'; import { EngineRouter, EngineNav } from './components/engine'; import { EngineCreation } from './components/engine_creation'; import { EnginesOverview } from './components/engines'; @@ -31,6 +32,7 @@ import { ErrorConnecting } from './components/error_connecting'; import { Library } from './components/library'; import { MetaEngineCreation } from './components/meta_engine_creation'; import { RoleMappings } from './components/role_mappings'; +import { Settings } from './components/settings'; import { SetupGuide } from './components/setup_guide'; import { AppSearch, AppSearchUnconfigured, AppSearchConfigured, AppSearchNav } from './'; @@ -103,52 +105,28 @@ describe('AppSearchConfigured', () => { expect(wrapper.find(Layout).first().prop('readOnlyMode')).toEqual(true); }); - describe('ability checks', () => { - describe('canViewRoleMappings', () => { - it('renders RoleMappings when canViewRoleMappings is true', () => { - setMockValues({ myRole: { canViewRoleMappings: true } }); - rerender(wrapper); - expect(wrapper.find(RoleMappings)).toHaveLength(1); + describe('routes with ability checks', () => { + const runRouteAbilityCheck = (routeAbility: string, View: React.FC) => { + describe(View.name, () => { + it(`renders ${View.name} when user ${routeAbility} is true`, () => { + setMockValues({ myRole: { [routeAbility]: true } }); + rerender(wrapper); + expect(wrapper.find(View)).toHaveLength(1); + }); + + it(`does not render ${View.name} when user ${routeAbility} is false`, () => { + setMockValues({ myRole: { [routeAbility]: false } }); + rerender(wrapper); + expect(wrapper.find(View)).toHaveLength(0); + }); }); + }; - it('does not render RoleMappings when user canViewRoleMappings is false', () => { - setMockValues({ myRole: { canManageEngines: false } }); - rerender(wrapper); - expect(wrapper.find(RoleMappings)).toHaveLength(0); - }); - }); - - describe('canManageEngines', () => { - it('renders EngineCreation when user canManageEngines is true', () => { - setMockValues({ myRole: { canManageEngines: true } }); - rerender(wrapper); - - expect(wrapper.find(EngineCreation)).toHaveLength(1); - }); - - it('does not render EngineCreation when user canManageEngines is false', () => { - setMockValues({ myRole: { canManageEngines: false } }); - rerender(wrapper); - - expect(wrapper.find(EngineCreation)).toHaveLength(0); - }); - }); - - describe('canManageMetaEngines', () => { - it('renders MetaEngineCreation when user canManageMetaEngines is true', () => { - setMockValues({ myRole: { canManageMetaEngines: true } }); - rerender(wrapper); - - expect(wrapper.find(MetaEngineCreation)).toHaveLength(1); - }); - - it('does not render MetaEngineCreation when user canManageMetaEngines is false', () => { - setMockValues({ myRole: { canManageMetaEngines: false } }); - rerender(wrapper); - - expect(wrapper.find(MetaEngineCreation)).toHaveLength(0); - }); - }); + runRouteAbilityCheck('canViewSettings', Settings); + runRouteAbilityCheck('canViewAccountCredentials', Credentials); + runRouteAbilityCheck('canViewRoleMappings', RoleMappings); + runRouteAbilityCheck('canManageEngines', EngineCreation); + runRouteAbilityCheck('canManageMetaEngines', MetaEngineCreation); }); describe('library', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index b2cd3d7b54a1a9..7b3b13aef05d67 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -76,7 +76,13 @@ export const AppSearchUnconfigured: React.FC = () => ( export const AppSearchConfigured: React.FC> = (props) => { const { - myRole: { canManageEngines, canManageMetaEngines, canViewRoleMappings }, + myRole: { + canManageEngines, + canManageMetaEngines, + canViewSettings, + canViewAccountCredentials, + canViewRoleMappings, + }, } = useValues(AppLogic(props)); const { renderHeaderActions } = useValues(KibanaLogic); const { readOnlyMode } = useValues(HttpLogic); @@ -98,6 +104,9 @@ export const AppSearchConfigured: React.FC> = (props) = + + + {canManageEngines && ( @@ -108,6 +117,16 @@ export const AppSearchConfigured: React.FC> = (props) = )} + {canViewSettings && ( + + + + )} + {canViewAccountCredentials && ( + + + + )} {canViewRoleMappings && ( @@ -116,15 +135,6 @@ export const AppSearchConfigured: React.FC> = (props) = } readOnlyMode={readOnlyMode}> - - - - - - - - - diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.test.ts index 4d4c84e4146ef1..60d0dcc0c5911e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.test.ts @@ -10,7 +10,7 @@ import { DEFAULT_INITIAL_APP_DATA } from '../../../../../common/__mocks__'; import { getRoleAbilities } from './'; describe('getRoleAbilities', () => { - const mockRole = DEFAULT_INITIAL_APP_DATA.appSearch.role; + const mockRole = DEFAULT_INITIAL_APP_DATA.appSearch.role as any; it('transforms server role data into a flat role obj with helper shorthands', () => { expect(getRoleAbilities(mockRole)).toEqual({ @@ -53,9 +53,10 @@ describe('getRoleAbilities', () => { describe('can()', () => { it('sets view abilities to true if manage abilities are true', () => { - const role = { ...mockRole }; - role.ability.view = []; - role.ability.manage = ['account_settings']; + const role = { + ...mockRole, + ability: { view: [], manage: ['account_settings'] }, + }; const myRole = getRoleAbilities(role); @@ -70,4 +71,26 @@ describe('getRoleAbilities', () => { expect(myRole.can('edit', 'fakeSubject')).toEqual(false); }); }); + + describe('canManageMetaEngines', () => { + const canManageEngines = { ability: { manage: ['account_engines'] } }; + + it('returns true when the user can manage any engines and the account has a platinum license', () => { + const myRole = getRoleAbilities({ ...mockRole, ...canManageEngines }, true); + + expect(myRole.canManageMetaEngines).toEqual(true); + }); + + it('returns false when the user can manage any engines but the account does not have a platinum license', () => { + const myRole = getRoleAbilities({ ...mockRole, ...canManageEngines }, false); + + expect(myRole.canManageMetaEngines).toEqual(false); + }); + + it('returns false when has a platinum license but the user cannot manage any engines', () => { + const myRole = getRoleAbilities({ ...mockRole, ability: { manage: [] } }, true); + + expect(myRole.canManageMetaEngines).toEqual(false); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.ts index 81ac971d00d448..ef3e22d851f387 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/role/get_role_abilities.ts @@ -13,7 +13,7 @@ import { RoleTypes, AbilityTypes, Role } from './types'; * Transforms the `role` data we receive from the Enterprise Search * server into a more convenient format for front-end use */ -export const getRoleAbilities = (role: Account['role']): Role => { +export const getRoleAbilities = (role: Account['role'], hasPlatinumLicense = false): Role => { // Role ability function helpers const myRole = { can: (action: AbilityTypes, subject: string): boolean => { @@ -49,7 +49,7 @@ export const getRoleAbilities = (role: Account['role']): Role => { canViewSettings: myRole.can('view', 'account_settings'), canViewRoleMappings: myRole.can('view', 'role_mappings'), canManageEngines: myRole.can('manage', 'account_engines'), - canManageMetaEngines: myRole.can('manage', 'account_meta_engines'), + canManageMetaEngines: hasPlatinumLicense && myRole.can('manage', 'account_engines'), canManageLogSettings: myRole.can('manage', 'account_log_settings'), canManageSettings: myRole.can('manage', 'account_settings'), canManageEngineCrawler: myRole.can('manage', 'engine_crawler'), diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/constants.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/constants.ts index 903d1768f3cc14..f51eeb1c8160c1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/constants.ts @@ -11,10 +11,3 @@ export const LICENSE_CALLOUT_BODY = i18n.translate('xpack.enterpriseSearch.licen defaultMessage: 'Enterprise authentication via SAML, document-level permission and authorization support, custom search experiences and more are available with a valid Platinum license.', }); - -export const LICENSE_CALLOUT_BUTTON = i18n.translate( - 'xpack.enterpriseSearch.licenseCalloutButton', - { - defaultMessage: 'Manage your license', - } -); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.test.tsx index 0c77a0fbf6f5af..75a9700691ebb2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.test.tsx @@ -13,7 +13,7 @@ import { shallow } from 'enzyme'; import { EuiPanel, EuiText } from '@elastic/eui'; -import { EuiButtonTo } from '../../../shared/react_router_helpers'; +import { ManageLicenseButton } from '../../../shared/licensing'; import { LicenseCallout } from './'; @@ -27,9 +27,7 @@ describe('LicenseCallout', () => { expect(wrapper.find(EuiPanel)).toHaveLength(1); expect(wrapper.find(EuiText)).toHaveLength(2); - expect(wrapper.find(EuiButtonTo).prop('to')).toEqual( - '/app/management/stack/license_management' - ); + expect(wrapper.find(ManageLicenseButton)).toHaveLength(1); }); it('does not render for platinum', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.tsx index 4a4de17450f1bc..f9f329c8591102 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/license_callout/license_callout.tsx @@ -11,12 +11,11 @@ import { useValues } from 'kea'; import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { LicensingLogic } from '../../../shared/licensing'; -import { EuiButtonTo } from '../../../shared/react_router_helpers'; +import { LicensingLogic, ManageLicenseButton } from '../../../shared/licensing'; import { PRODUCT_SELECTOR_CALLOUT_HEADING } from '../../constants'; -import { LICENSE_CALLOUT_BODY, LICENSE_CALLOUT_BUTTON } from './constants'; +import { LICENSE_CALLOUT_BODY } from './constants'; export const LicenseCallout: React.FC = () => { const { hasPlatinumLicense, isTrial } = useValues(LicensingLogic); @@ -34,9 +33,7 @@ export const LicenseCallout: React.FC = () => { - - {LICENSE_CALLOUT_BUTTON} - + diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index ba2b28e64b9cf0..414957656467a4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -57,6 +57,7 @@ export const renderApp = ( }); const unmountLicensingLogic = mountLicensingLogic({ license$: plugins.licensing.license$, + canManageLicense: core.application.capabilities.management?.stack?.license_management, }); const unmountHttpLogic = mountHttpLogic({ http: core.http, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts index 4cc907c3de9e4c..39392d0c5c78e6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts @@ -33,6 +33,12 @@ describe('KibanaLogic', () => { expect(KibanaLogic.values.config).toEqual({}); }); + it('gracefully handles disabled security', () => { + mountKibanaLogic({ ...mockKibanaValues, security: undefined } as any); + + expect(KibanaLogic.values.security).toEqual({}); + }); + it('gracefully handles non-cloud installs', () => { mountKibanaLogic({ ...mockKibanaValues, cloud: undefined } as any); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts index c83e578bdd0903..74281d45ae0a54 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/index.ts @@ -6,3 +6,4 @@ */ export { LicensingLogic, mountLicensingLogic } from './licensing_logic'; +export { ManageLicenseButton } from './manage_license_button'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.test.ts index 4ea74e1c0d4f20..5d210cee1a926d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.test.ts @@ -15,13 +15,21 @@ import { LicensingLogic, mountLicensingLogic } from './licensing_logic'; describe('LicensingLogic', () => { const mockLicense = licensingMock.createLicense(); const mockLicense$ = new BehaviorSubject(mockLicense); - const mount = () => mountLicensingLogic({ license$: mockLicense$ }); + const mount = (props?: object) => + mountLicensingLogic({ license$: mockLicense$, canManageLicense: true, ...props }); beforeEach(() => { jest.clearAllMocks(); resetContext({}); }); + describe('canManageLicense', () => { + it('sets value from props', () => { + mount({ canManageLicense: false }); + expect(LicensingLogic.values.canManageLicense).toEqual(false); + }); + }); + describe('setLicense()', () => { it('sets license value', () => { mount(); @@ -61,7 +69,7 @@ describe('LicensingLogic', () => { describe('on unmount', () => { it('unsubscribes to the license observable', () => { const mockUnsubscribe = jest.fn(); - const unmount = mountLicensingLogic({ + const unmount = mount({ license$: { subscribe: () => ({ unsubscribe: mockUnsubscribe }) } as any, }); unmount(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.ts index 7d0222f476214f..f94a1fff0cd311 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/licensing_logic.ts @@ -16,6 +16,7 @@ interface LicensingValues { hasPlatinumLicense: boolean; hasGoldLicense: boolean; isTrial: boolean; + canManageLicense: boolean; } interface LicensingActions { setLicense(license: ILicense): ILicense; @@ -28,7 +29,7 @@ export const LicensingLogic = kea license, setLicenseSubscription: (licenseSubscription) => licenseSubscription, }, - reducers: { + reducers: ({ props }) => ({ license: [ null, { @@ -41,7 +42,8 @@ export const LicensingLogic = kea licenseSubscription, }, ], - }, + canManageLicense: [props.canManageLicense || false, {}], + }), selectors: { hasPlatinumLicense: [ (selectors) => [selectors.license], @@ -80,6 +82,7 @@ export const LicensingLogic = kea; + canManageLicense: boolean; } export const mountLicensingLogic = (props: LicensingLogicProps) => { LicensingLogic(props); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/manage_license_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/manage_license_button.test.tsx new file mode 100644 index 00000000000000..1877a4cbd0e42d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/manage_license_button.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues } from '../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButton } from '@elastic/eui'; + +import { EuiButtonTo } from '../react_router_helpers'; + +import { ManageLicenseButton } from './'; + +describe('ManageLicenseButton', () => { + describe('when the user can access license management', () => { + it('renders a SPA link to the license management plugin', () => { + setMockValues({ canManageLicense: true }); + const wrapper = shallow(); + + expect(wrapper.find(EuiButtonTo).prop('to')).toEqual( + '/app/management/stack/license_management' + ); + }); + }); + + describe('when the user cannot access license management', () => { + it('renders an external link to our license management documentation', () => { + setMockValues({ canManageLicense: false }); + const wrapper = shallow(); + + expect(wrapper.find(EuiButton).prop('href')).toEqual( + expect.stringContaining('/license-management.html') + ); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/licensing/manage_license_button.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/manage_license_button.tsx new file mode 100644 index 00000000000000..af3b33e3d7a3d9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/licensing/manage_license_button.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { EuiButton, EuiButtonProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { docLinks } from '../doc_links'; +import { EuiButtonTo } from '../react_router_helpers'; + +import { LicensingLogic } from './licensing_logic'; + +export const ManageLicenseButton: React.FC = (props) => { + const { canManageLicense } = useValues(LicensingLogic); + + return canManageLicense ? ( + + {i18n.translate('xpack.enterpriseSearch.licenseManagementLink', { + defaultMessage: 'Manage your license', + })} + + ) : ( + + {i18n.translate('xpack.enterpriseSearch.licenseDocumentationLink', { + defaultMessage: 'Learn more about license features', + })} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx index 3d5d0a8e6f2cfd..04b0880a7351cd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx @@ -9,6 +9,9 @@ jest.mock('../../../shared/layout', () => ({ ...jest.requireActual('../../../shared/layout'), generateNavLink: jest.fn(({ to }) => ({ href: to })), })); +jest.mock('../../views/content_sources/components/source_sub_nav', () => ({ + useSourceSubNav: () => [], +})); jest.mock('../../views/groups/components/group_sub_nav', () => ({ useGroupSubNav: () => [], })); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index f59679e0ee0484..99225bc36e892b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -19,6 +19,7 @@ import { GROUPS_PATH, ORG_SETTINGS_PATH, } from '../../routes'; +import { useSourceSubNav } from '../../views/content_sources/components/source_sub_nav'; import { useGroupSubNav } from '../../views/groups/components/group_sub_nav'; import { useSettingsSubNav } from '../../views/settings/components/settings_sub_nav'; @@ -33,7 +34,7 @@ export const useWorkplaceSearchNav = () => { id: 'sources', name: NAV.SOURCES, ...generateNavLink({ to: SOURCES_PATH }), - items: [], // TODO: Source subnav + items: useSourceSubNav(), }, { id: 'groups', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss index 175f6b9ebca208..3287cb21783cbf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.scss @@ -6,18 +6,20 @@ */ .personalDashboardLayout { - $sideBarWidth: $euiSize * 30; - $consoleHeaderHeight: 48px; // NOTE: Keep an eye on this for changes - $pageHeight: calc(100vh - #{$consoleHeaderHeight}); + &__sideBar { + padding: $euiSizeXL $euiSizeXXL $euiSizeXXL; - left: $sideBarWidth; - width: calc(100% - #{$sideBarWidth}); - min-height: $pageHeight; + @include euiBreakpoint('m', 'l') { + min-width: $euiSize * 20; + } + @include euiBreakpoint('xl') { + min-width: $euiSize * 30; + } + } - &__sideBar { - padding: 32px 40px 40px; - width: $sideBarWidth; - margin-left: -$sideBarWidth; - height: $pageHeight; + &__body { + position: relative; + width: 100%; + height: 100%; } } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx index faeaa7323e93f0..6847e91d46f6e6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.test.tsx @@ -5,37 +5,102 @@ * 2.0. */ +import { setMockValues } from '../../../../__mocks__/kea_logic'; +import { mockUseRouteMatch } from '../../../../__mocks__/react_router'; + import React from 'react'; import { shallow } from 'enzyme'; import { EuiCallOut } from '@elastic/eui'; -import { AccountHeader } from '..'; +import { FlashMessages } from '../../../../shared/flash_messages'; +import { SetWorkplaceSearchChrome } from '../../../../shared/kibana_chrome'; +import { Loading } from '../../../../shared/loading'; + +import { AccountHeader, AccountSettingsSidebar, PrivateSourcesSidebar } from '../index'; import { PersonalDashboardLayout } from './personal_dashboard_layout'; describe('PersonalDashboardLayout', () => { const children =

test

; - const sidebar =

test

; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues({ readOnlyMode: false }); + }); it('renders', () => { - const wrapper = shallow( - {children} - ); + const wrapper = shallow({children}); expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(1); - expect(wrapper.find('[data-test-subj="TestSidebar"]')).toHaveLength(1); + expect(wrapper.find('.personalDashboardLayout')).toHaveLength(1); expect(wrapper.find(AccountHeader)).toHaveLength(1); + expect(wrapper.find(FlashMessages)).toHaveLength(1); }); - it('renders callout when in read-only mode', () => { + describe('renders sidebar content based on the route', () => { + it('renders the private sources sidebar on the private sources path', () => { + (mockUseRouteMatch as jest.Mock).mockImplementation((path: string) => path === '/p/sources'); + const wrapper = shallow({children}); + + expect(wrapper.find(PrivateSourcesSidebar)).toHaveLength(1); + }); + + it('renders the account settings sidebar on the account settings path', () => { + (mockUseRouteMatch as jest.Mock).mockImplementation((path: string) => path === '/p/settings'); + const wrapper = shallow({children}); + + expect(wrapper.find(AccountSettingsSidebar)).toHaveLength(1); + }); + + it('does not render a sidebar if not on a valid personal dashboard path', () => { + (mockUseRouteMatch as jest.Mock).mockImplementation((path: string) => path === '/test'); + const wrapper = shallow({children}); + + expect(wrapper.find(AccountSettingsSidebar)).toHaveLength(0); + expect(wrapper.find(PrivateSourcesSidebar)).toHaveLength(0); + }); + }); + + describe('loading state', () => { + it('renders a loading icon in place of children', () => { + const wrapper = shallow( + {children} + ); + + expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(0); + }); + + it('renders children & does not render a loading icon when the page is done loading', () => { + const wrapper = shallow( + {children} + ); + + expect(wrapper.find(Loading)).toHaveLength(0); + expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(1); + }); + }); + + it('sets WS page chrome (primarily document title)', () => { const wrapper = shallow( - + {children} ); + expect(wrapper.find(SetWorkplaceSearchChrome).prop('trail')).toEqual([ + 'Sources', + 'Add source', + 'Gmail', + ]); + }); + + it('renders callout when in read-only mode', () => { + setMockValues({ readOnlyMode: true }); + const wrapper = shallow({children}); + expect(wrapper.find(EuiCallOut)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx index 1ab9e07dfa14d5..5b68d661ac5df4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_layout/personal_dashboard_layout.tsx @@ -6,44 +6,67 @@ */ import React from 'react'; +import { useRouteMatch } from 'react-router-dom'; -import { EuiPage, EuiPageSideBar, EuiPageBody, EuiCallOut } from '@elastic/eui'; +import { useValues } from 'kea'; -import { AccountHeader } from '..'; +import { + EuiPage, + EuiPageSideBar, + EuiPageBody, + EuiPageContentBody, + EuiCallOut, + EuiSpacer, +} from '@elastic/eui'; +import { FlashMessages } from '../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../shared/http'; +import { SetWorkplaceSearchChrome } from '../../../../shared/kibana_chrome'; +import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs'; +import { Loading } from '../../../../shared/loading'; + +import { PERSONAL_SOURCES_PATH, PERSONAL_SETTINGS_PATH } from '../../../routes'; import { PRIVATE_DASHBOARD_READ_ONLY_MODE_WARNING } from '../../../views/content_sources/constants'; +import { AccountHeader, AccountSettingsSidebar, PrivateSourcesSidebar } from '../index'; import './personal_dashboard_layout.scss'; interface LayoutProps { - restrictWidth?: boolean; - readOnlyMode?: boolean; - sidebar: React.ReactNode; + isLoading?: boolean; + pageChrome?: BreadcrumbTrail; } export const PersonalDashboardLayout: React.FC = ({ children, - restrictWidth, - readOnlyMode, - sidebar, + isLoading, + pageChrome, }) => { + const { readOnlyMode } = useValues(HttpLogic); + return ( <> + {pageChrome && } - - - {sidebar} + + + {useRouteMatch(PERSONAL_SOURCES_PATH) && } + {useRouteMatch(PERSONAL_SETTINGS_PATH) && } - - {readOnlyMode && ( - - )} - {children} + + + {readOnlyMode && ( + <> + + + + )} + + {isLoading ? : children} + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx index 387724af970f89..9fa4d4dd1b237c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.test.tsx @@ -7,17 +7,22 @@ import { setMockValues } from '../../../../__mocks__/kea_logic'; +jest.mock('../../../views/content_sources/components/source_sub_nav', () => ({ + useSourceSubNav: () => [], +})); + import React from 'react'; import { shallow } from 'enzyme'; +import { EuiSideNav } from '@elastic/eui'; + import { PRIVATE_CAN_CREATE_PAGE_TITLE, PRIVATE_VIEW_ONLY_PAGE_TITLE, PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION, PRIVATE_CAN_CREATE_PAGE_DESCRIPTION, } from '../../../constants'; -import { SourceSubNav } from '../../../views/content_sources/components/source_sub_nav'; import { ViewContentHeader } from '../../shared/view_content_header'; @@ -26,6 +31,7 @@ import { PrivateSourcesSidebar } from './private_sources_sidebar'; describe('PrivateSourcesSidebar', () => { const mockValues = { account: { canCreatePersonalSources: true }, + contentSource: {}, }; beforeEach(() => { @@ -36,25 +42,42 @@ describe('PrivateSourcesSidebar', () => { const wrapper = shallow(); expect(wrapper.find(ViewContentHeader)).toHaveLength(1); - expect(wrapper.find(SourceSubNav)).toHaveLength(1); }); - it('uses correct title and description when private sources are enabled', () => { - const wrapper = shallow(); + describe('header text', () => { + it('uses correct title and description when private sources are enabled', () => { + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_CAN_CREATE_PAGE_TITLE); + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + PRIVATE_CAN_CREATE_PAGE_DESCRIPTION + ); + }); - expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_CAN_CREATE_PAGE_TITLE); - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - PRIVATE_CAN_CREATE_PAGE_DESCRIPTION - ); + it('uses correct title and description when private sources are disabled', () => { + setMockValues({ ...mockValues, account: { canCreatePersonalSources: false } }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_VIEW_ONLY_PAGE_TITLE); + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION + ); + }); }); - it('uses correct title and description when private sources are disabled', () => { - setMockValues({ account: { canCreatePersonalSources: false } }); - const wrapper = shallow(); + describe('sub nav', () => { + it('renders a side nav when viewing a single source', () => { + setMockValues({ ...mockValues, contentSource: { id: '1', name: 'test source' } }); + const wrapper = shallow(); + + expect(wrapper.find(EuiSideNav)).toHaveLength(1); + }); + + it('does not render a side nav if not on a source page', () => { + setMockValues({ ...mockValues, contentSource: {} }); + const wrapper = shallow(); - expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_VIEW_ONLY_PAGE_TITLE); - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION - ); + expect(wrapper.find(EuiSideNav)).toHaveLength(0); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx index 5505ae57b2ad5f..36496b83b31231 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/personal_dashboard_sidebar/private_sources_sidebar.tsx @@ -9,6 +9,8 @@ import React from 'react'; import { useValues } from 'kea'; +import { EuiSideNav } from '@elastic/eui'; + import { AppLogic } from '../../../app_logic'; import { PRIVATE_CAN_CREATE_PAGE_TITLE, @@ -16,7 +18,8 @@ import { PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION, PRIVATE_CAN_CREATE_PAGE_DESCRIPTION, } from '../../../constants'; -import { SourceSubNav } from '../../../views/content_sources/components/source_sub_nav'; +import { useSourceSubNav } from '../../../views/content_sources/components/source_sub_nav'; +import { SourceLogic } from '../../../views/content_sources/source_logic'; import { ViewContentHeader } from '../../shared/view_content_header'; export const PrivateSourcesSidebar = () => { @@ -31,10 +34,17 @@ export const PrivateSourcesSidebar = () => { ? PRIVATE_CAN_CREATE_PAGE_DESCRIPTION : PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION; + const { + contentSource: { id = '', name = '' }, + } = useValues(SourceLogic); + + const navItems = [{ id, name, items: useSourceSubNav() }]; + return ( <> - + {/* @ts-expect-error: TODO, uncomment this once EUI 34.x lands in Kibana & `mobileBreakpoints` is a valid prop */} + {id && } ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index f4278d5083143a..8a1e9c02753225 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -19,11 +19,6 @@ import { NotFound } from '../shared/not_found'; import { AppLogic } from './app_logic'; import { WorkplaceSearchNav, WorkplaceSearchHeaderActions } from './components/layout'; -import { - PersonalDashboardLayout, - PrivateSourcesSidebar, - AccountSettingsSidebar, -} from './components/layout'; import { GROUPS_PATH, SETUP_GUIDE_PATH, @@ -34,11 +29,11 @@ import { ROLE_MAPPINGS_PATH, SECURITY_PATH, PERSONAL_SETTINGS_PATH, + PERSONAL_PATH, } from './routes'; import { AccountSettings } from './views/account_settings'; import { SourcesRouter } from './views/content_sources'; import { SourceAdded } from './views/content_sources/components/source_added'; -import { SourceSubNav } from './views/content_sources/components/source_sub_nav'; import { ErrorState } from './views/error_state'; import { GroupsRouter } from './views/groups'; import { Overview } from './views/overview'; @@ -60,9 +55,6 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { const { pathname } = useLocation(); - // We don't want so show the subnavs on the container root pages. - const showSourcesSubnav = pathname !== SOURCES_PATH && pathname !== PERSONAL_SOURCES_PATH; - /** * Personal dashboard urls begin with /p/ * EX: http://localhost:5601/app/enterprise_search/workplace_search/p/sources @@ -95,32 +87,18 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { - - } - > - - - - - } - > - - + + + + + + + + + - } />} - restrictWidth - readOnlyMode={readOnlyMode} - > - - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx index a5a3d6b491bb96..b89a1451f7e571 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx @@ -76,13 +76,13 @@ describe('getReindexJobRoute', () => { it('should format org path', () => { expect(getReindexJobRoute(SOURCE_ID, REINDEX_ID, true)).toEqual( - `/sources/${SOURCE_ID}/schema_errors/${REINDEX_ID}` + `/sources/${SOURCE_ID}/schemas/${REINDEX_ID}` ); }); it('should format user path', () => { expect(getReindexJobRoute(SOURCE_ID, REINDEX_ID, false)).toEqual( - `/p/sources/${SOURCE_ID}/schema_errors/${REINDEX_ID}` + `/p/sources/${SOURCE_ID}/schemas/${REINDEX_ID}` ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 1fe8019c4b3646..3c564c1f912ecc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -88,7 +88,7 @@ export const SOURCE_CONTENT_PATH = `${SOURCES_PATH}/:sourceId/content`; export const SOURCE_SCHEMAS_PATH = `${SOURCES_PATH}/:sourceId/schemas`; export const SOURCE_DISPLAY_SETTINGS_PATH = `${SOURCES_PATH}/:sourceId/display_settings`; export const SOURCE_SETTINGS_PATH = `${SOURCES_PATH}/:sourceId/settings`; -export const REINDEX_JOB_PATH = `${SOURCES_PATH}/:sourceId/schema_errors/:activeReindexJobId`; +export const REINDEX_JOB_PATH = `${SOURCE_SCHEMAS_PATH}/:activeReindexJobId`; export const DISPLAY_SETTINGS_SEARCH_RESULT_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/`; export const DISPLAY_SETTINGS_RESULT_DETAIL_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/result_detail`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.test.tsx new file mode 100644 index 00000000000000..5ff80a7683db6a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/shallow_useeffect.mock'; +import { mockKibanaValues } from '../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { AccountSettings } from './'; + +describe('AccountSettings', () => { + const { + security: { + authc: { getCurrentUser }, + uiApi: { + components: { getPersonalInfo, getChangePassword }, + }, + }, + } = mockKibanaValues; + + const mockCurrentUser = (user?: unknown) => + (getCurrentUser as jest.Mock).mockReturnValue(Promise.resolve(user)); + + beforeAll(() => { + mockCurrentUser(); + }); + + it('gets the current user on mount', () => { + shallow(); + + expect(getCurrentUser).toHaveBeenCalled(); + }); + + it('does not render if the current user does not exist', async () => { + mockCurrentUser(null); + const wrapper = await shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders the security UI components when the user exists', async () => { + mockCurrentUser({ username: 'mock user' }); + (getPersonalInfo as jest.Mock).mockReturnValue(
); + (getChangePassword as jest.Mock).mockReturnValue(
); + + const wrapper = await shallow(); + + expect(wrapper.childAt(0).dive().find('[data-test-subj="PersonalInfo"]')).toHaveLength(1); + expect(wrapper.childAt(1).dive().find('[data-test-subj="ChangePassword"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx index e28faaeec8993a..313d3ffa59d48f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx @@ -11,6 +11,8 @@ import { useValues } from 'kea'; import type { AuthenticatedUser } from '../../../../../../security/public'; import { KibanaLogic } from '../../../shared/kibana/kibana_logic'; +import { PersonalDashboardLayout } from '../../components/layout'; +import { ACCOUNT_SETTINGS_TITLE } from '../../constants'; export const AccountSettings: React.FC = () => { const { security } = useValues(KibanaLogic); @@ -31,9 +33,9 @@ export const AccountSettings: React.FC = () => { } return ( - <> + - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx index 92cbfcf6eeafe4..0501509b3a8ef7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx @@ -17,7 +17,10 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Loading } from '../../../../../shared/loading'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; import { AddSource } from './add_source'; import { AddSourceSteps } from './add_source_logic'; @@ -68,11 +71,27 @@ describe('AddSourceList', () => { expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); }); - it('handles loading state', () => { - setMockValues({ ...mockValues, dataLoading: true }); + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); + + it('renders a breadcrumb fallback while data is loading', () => { + setMockValues({ ...mockValues, dataLoading: true, sourceConfigData: {} }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('pageChrome')).toEqual(['Sources', 'Add Source', '...']); }); it('renders Config Completed step', () => { 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 ee4bcfb9afd341..b0c3ebe64830cb 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 @@ -13,9 +13,12 @@ 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 { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants'; import { SOURCES_PATH, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; import { staticSourceData } from '../../source_data'; @@ -71,8 +74,6 @@ export const AddSource: React.FC = (props) => { return resetSourceState; }, []); - if (dataLoading) return ; - const goToConfigurationIntro = () => setAddSourceStep(AddSourceSteps.ConfigIntroStep); const goToSaveConfig = () => setAddSourceStep(AddSourceSteps.SaveConfigStep); const setConfigCompletedStep = () => setAddSourceStep(AddSourceSteps.ConfigCompletedStep); @@ -99,9 +100,10 @@ export const AddSource: React.FC = (props) => { }; const header = ; + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; return ( - <> + {addSourceCurrentStep === AddSourceSteps.ConfigIntroStep && ( )} @@ -158,6 +160,6 @@ export const AddSource: React.FC = (props) => { {addSourceCurrentStep === AddSourceSteps.ReauthenticateStep && ( )} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.test.tsx index 6bf71cd73ec354..b30511f0a6d80b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.test.tsx @@ -19,7 +19,11 @@ import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiFieldSearch } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; +import { getPageDescription } from '../../../../../test_helpers'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; import { AddSourceList } from './add_source_list'; @@ -54,14 +58,21 @@ describe('AddSourceList', () => { expect(wrapper.find(AvailableSourcesList)).toHaveLength(1); }); - it('returns loading when loading', () => { - setMockValues({ - ...mockValues, - dataLoading: true, + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); }); - const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + it('renders the personal dashboard layout and a header when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + }); }); describe('filters sources', () => { @@ -97,49 +108,51 @@ describe('AddSourceList', () => { }); describe('content headings', () => { - it('should render correct organization heading with sources', () => { - const wrapper = shallow(); - - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - ADD_SOURCE_ORG_SOURCE_DESCRIPTION - ); - }); + describe('organization view', () => { + it('should render the correct organization heading with sources', () => { + const wrapper = shallow(); - it('should render correct organization heading without sources', () => { - setMockValues({ - ...mockValues, - contentSources: [], + expect(getPageDescription(wrapper)).toEqual(ADD_SOURCE_ORG_SOURCE_DESCRIPTION); }); - const wrapper = shallow(); - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - ADD_SOURCE_NEW_SOURCE_DESCRIPTION + ADD_SOURCE_ORG_SOURCE_DESCRIPTION - ); - }); + it('should render the correct organization heading without sources', () => { + setMockValues({ + ...mockValues, + contentSources: [], + }); + const wrapper = shallow(); - it('should render correct account heading with sources', () => { - const wrapper = shallow(); - setMockValues({ - ...mockValues, - isOrganization: false, + expect(getPageDescription(wrapper)).toEqual( + ADD_SOURCE_NEW_SOURCE_DESCRIPTION + ADD_SOURCE_ORG_SOURCE_DESCRIPTION + ); }); - - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - ADD_SOURCE_ORG_SOURCE_DESCRIPTION - ); }); - it('should render correct account heading without sources', () => { - setMockValues({ - ...mockValues, - isOrganization: false, - contentSources: [], + describe('personal dashboard view', () => { + it('should render the correct personal heading with sources', () => { + setMockValues({ + ...mockValues, + isOrganization: false, + }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + ADD_SOURCE_PRIVATE_SOURCE_DESCRIPTION + ); }); - const wrapper = shallow(); - expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( - ADD_SOURCE_NEW_SOURCE_DESCRIPTION + ADD_SOURCE_PRIVATE_SOURCE_DESCRIPTION - ); + it('should render the correct personal heading without sources', () => { + setMockValues({ + ...mockValues, + isOrganization: false, + contentSources: [], + }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + ADD_SOURCE_NEW_SOURCE_DESCRIPTION + ADD_SOURCE_PRIVATE_SOURCE_DESCRIPTION + ); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx index 80d35553bb8bb4..a7a64194cb42f2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx @@ -19,12 +19,15 @@ import { EuiEmptyPrompt, } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; import { AppLogic } from '../../../../app_logic'; import noSharedSourcesIcon from '../../../../assets/share_circle.svg'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; import { ContentSection } from '../../../../components/shared/content_section'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; -import { CUSTOM_SERVICE_TYPE } from '../../../../constants'; +import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants'; import { SourceDataItem } from '../../../../types'; import { SourcesLogic } from '../../sources_logic'; @@ -58,8 +61,6 @@ export const AddSourceList: React.FC = () => { return resetSourcesState; }, []); - if (dataLoading) return ; - const hasSources = contentSources.length > 0; const showConfiguredSourcesList = configuredSources.find( ({ serviceType }) => serviceType !== CUSTOM_SERVICE_TYPE @@ -97,12 +98,22 @@ export const AddSourceList: React.FC = () => { filterConfiguredSources ) as SourceDataItem[]; + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + return ( - <> - + + {!isOrganization && ( +
+ +
+ )} {showConfiguredSourcesList || isOrganization ? ( - { )} - +
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx index aa5cec385738d2..e5714bf4bdfbf5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.test.tsx @@ -16,7 +16,6 @@ import { shallow } from 'enzyme'; import { EuiButton, EuiTabbedContent } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prompt'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; @@ -57,13 +56,6 @@ describe('DisplaySettings', () => { expect(wrapper.find('form')).toHaveLength(1); }); - it('returns loading when loading', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - describe('tabbed content', () => { const tabs = [ { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx index d923fbe7a1a8e8..ae47e20026b68c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx @@ -20,10 +20,10 @@ import { } from '@elastic/eui'; import { clearFlashMessages } from '../../../../../shared/flash_messages'; -import { Loading } from '../../../../../shared/loading'; import { UnsavedChangesPrompt } from '../../../../../shared/unsaved_changes_prompt'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; -import { SAVE_BUTTON } from '../../../../constants'; +import { NAV, SAVE_BUTTON } from '../../../../constants'; +import { SourceLayout } from '../source_layout'; import { UNSAVED_MESSAGE, @@ -64,8 +64,6 @@ export const DisplaySettings: React.FC = ({ tabId }) => { return clearFlashMessages; }, []); - if (dataLoading) return ; - const tabs = [ { id: 'search_results', @@ -89,7 +87,11 @@ export const DisplaySettings: React.FC = ({ tabId }) => { }; return ( - <> + = ({ tabId }) => { )} {addFieldModalVisible && } - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx index f2cf5f50b813b4..d99eac5de74e5a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx @@ -5,8 +5,6 @@ * 2.0. */ -import '../../../../__mocks__/shallow_useeffect.mock'; - import { setMockValues } from '../../../../__mocks__/kea_logic'; import { fullContentSources } from '../../../__mocks__/content_sources.mock'; @@ -16,7 +14,6 @@ import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiPanel, EuiTable } from '@elastic/eui'; -import { Loading } from '../../../../shared/loading'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { Overview } from './overview'; @@ -44,13 +41,6 @@ describe('Overview', () => { expect(documentSummary.find('[data-test-subj="DocumentSummaryRow"]')).toHaveLength(1); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders ComponentLoader when loading', () => { setMockValues({ ...mockValues, 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 153df1bc00496a..cc890e0f104ac8 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 @@ -29,7 +29,6 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Loading } from '../../../../shared/loading'; import { EuiPanelTo } from '../../../../shared/react_router_helpers'; import { AppLogic } from '../../../app_logic'; import aclImage from '../../../assets/supports_acl.svg'; @@ -78,8 +77,10 @@ import { } from '../constants'; import { SourceLogic } from '../source_logic'; +import { SourceLayout } from './source_layout'; + export const Overview: React.FC = () => { - const { contentSource, dataLoading } = useValues(SourceLogic); + const { contentSource } = useValues(SourceLogic); const { isOrganization } = useValues(AppLogic); const { @@ -97,8 +98,6 @@ export const Overview: React.FC = () => { isFederatedSource, } = contentSource; - if (dataLoading) return ; - const DocumentSummary = () => { let totalDocuments = 0; const tableContent = summary?.map((item, index) => { @@ -450,8 +449,9 @@ export const Overview: React.FC = () => { ); return ( - <> + + @@ -513,6 +513,6 @@ export const Overview: React.FC = () => { - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.test.tsx index 178c9125ee4370..47859e4e67b170 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.test.tsx @@ -16,7 +16,6 @@ import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiFieldSearch } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; import { SchemaAddFieldModal, SchemaErrorsCallout } from '../../../../../shared/schema'; import { Schema } from './schema'; @@ -71,13 +70,6 @@ describe('Schema', () => { expect(wrapper.find(SchemaFieldsTable)).toHaveLength(1); }); - it('returns loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('handles empty state', () => { setMockValues({ ...mockValues, activeSchema: {} }); const wrapper = shallow(); @@ -106,7 +98,7 @@ describe('Schema', () => { expect(wrapper.find(SchemaErrorsCallout)).toHaveLength(1); expect(wrapper.find(SchemaErrorsCallout).prop('viewErrorsPath')).toEqual( - '/sources/123/schema_errors/123' + '/sources/123/schemas/123' ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx index 65ed988f45ff08..a0efebdcb5a48c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx @@ -20,11 +20,12 @@ import { EuiPanel, } from '@elastic/eui'; -import { Loading } from '../../../../../shared/loading'; import { SchemaAddFieldModal, SchemaErrorsCallout } from '../../../../../shared/schema'; import { AppLogic } from '../../../../app_logic'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; +import { NAV } from '../../../../constants'; import { getReindexJobRoute } from '../../../../routes'; +import { SourceLayout } from '../source_layout'; import { SCHEMA_ADD_FIELD_BUTTON, @@ -65,8 +66,6 @@ export const Schema: React.FC = () => { initializeSchema(); }, []); - if (dataLoading) return ; - const hasSchemaFields = Object.keys(activeSchema).length > 0; const { hasErrors, activeReindexJobId } = mostRecentIndexJob; @@ -77,7 +76,11 @@ export const Schema: React.FC = () => { ); return ( - <> + { closeAddFieldModal={closeAddFieldModal} /> )} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx index e300823aa3ed30..eb07beda733276 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx @@ -12,6 +12,8 @@ import { useActions, useValues } from 'kea'; import { SchemaErrorsAccordion } from '../../../../../shared/schema'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; +import { NAV } from '../../../../constants'; +import { SourceLayout } from '../source_layout'; import { SCHEMA_ERRORS_HEADING } from './constants'; import { SchemaLogic } from './schema_logic'; @@ -30,9 +32,12 @@ export const SchemaChangeErrors: React.FC = () => { }, []); return ( - <> + - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx index 4bcc4b16166d18..9304f0f344a1be 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.test.tsx @@ -25,7 +25,6 @@ import { } from '@elastic/eui'; import { DEFAULT_META } from '../../../../shared/constants'; -import { Loading } from '../../../../shared/loading'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { TablePaginationBar } from '../../../components/shared/table_pagination_bar'; @@ -61,13 +60,6 @@ describe('SourceContent', () => { expect(wrapper.find(EuiTable)).toHaveLength(1); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('returns ComponentLoader when section loading', () => { setMockValues({ ...mockValues, sectionLoading: true }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx index fbafe54df7493c..a0e3c28f20eb0b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx @@ -31,12 +31,11 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Loading } from '../../../../shared/loading'; import { TruncatedContent } from '../../../../shared/truncate'; import { ComponentLoader } from '../../../components/shared/component_loader'; import { TablePaginationBar } from '../../../components/shared/table_pagination_bar'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; -import { CUSTOM_SERVICE_TYPE } from '../../../constants'; +import { NAV, CUSTOM_SERVICE_TYPE } from '../../../constants'; import { CUSTOM_SOURCE_DOCS_URL } from '../../../routes'; import { SourceContentItem } from '../../../types'; import { @@ -51,6 +50,8 @@ import { } from '../constants'; import { SourceLogic } from '../source_logic'; +import { SourceLayout } from './source_layout'; + const MAX_LENGTH = 28; export const SourceContent: React.FC = () => { @@ -67,7 +68,6 @@ export const SourceContent: React.FC = () => { }, contentItems, contentFilterValue, - dataLoading, sectionLoading, } = useValues(SourceLogic); @@ -75,8 +75,6 @@ export const SourceContent: React.FC = () => { searchContentSourceDocuments(id); }, [contentFilterValue, activePage]); - if (dataLoading) return ; - const showPagination = totalPages > 1; const hasItems = totalItems > 0; const emptyMessage = contentFilterValue @@ -193,7 +191,7 @@ export const SourceContent: React.FC = () => { ); return ( - <> + @@ -219,6 +217,6 @@ export const SourceContent: React.FC = () => { {sectionLoading && } {!sectionLoading && (hasItems ? contentTable : emptyState)} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx new file mode 100644 index 00000000000000..7c7d77ec418e7f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.test.tsx @@ -0,0 +1,84 @@ +/* + * 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__/shallow_useeffect.mock'; + +import { setMockValues } from '../../../../__mocks__/kea_logic'; +import { contentSources } from '../../../__mocks__/content_sources.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCallOut } from '@elastic/eui'; + +import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../../components/layout'; + +import { SourceInfoCard } from './source_info_card'; +import { SourceLayout } from './source_layout'; + +describe('SourceLayout', () => { + const contentSource = contentSources[1]; + const mockValues = { + contentSource, + dataLoading: false, + isOrganization: true, + }; + + beforeEach(() => { + setMockValues({ ...mockValues }); + }); + + it('renders', () => { + const wrapper = shallow( + +
+ + ); + + expect(wrapper.find(SourceInfoCard)).toHaveLength(1); + expect(wrapper.find('.testChild')).toHaveLength(1); + }); + + it('renders the default Workplace Search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders a personal dashboard layout when not on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + + it('passes any page template props to the underlying page template', () => { + const wrapper = shallow(); + + expect(wrapper.find(WorkplaceSearchPageTemplate).prop('pageViewTelemetry')).toEqual('test'); + }); + + it('handles breadcrumbs while loading', () => { + setMockValues({ + ...mockValues, + contentSource: {}, + dataLoading: true, + }); + const wrapper = shallow(); + + expect(wrapper.prop('pageChrome')).toEqual(['Sources', '...']); + }); + + it('renders a callout when the source is not supported by the current license', () => { + setMockValues({ ...mockValues, contentSource: { supportedByLicense: false } }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx new file mode 100644 index 00000000000000..446e93e0c61f3f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_layout.tsx @@ -0,0 +1,84 @@ +/* + * 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 { useValues } from 'kea'; +import moment from 'moment'; + +import { EuiButton, EuiCallOut, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; + +import { PageTemplateProps } from '../../../../shared/layout'; +import { AppLogic } from '../../../app_logic'; +import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../../components/layout'; +import { NAV } from '../../../constants'; +import { ENT_SEARCH_LICENSE_MANAGEMENT } from '../../../routes'; + +import { + SOURCE_DISABLED_CALLOUT_TITLE, + SOURCE_DISABLED_CALLOUT_DESCRIPTION, + SOURCE_DISABLED_CALLOUT_BUTTON, +} from '../constants'; +import { SourceLogic } from '../source_logic'; + +import { SourceInfoCard } from './source_info_card'; + +export const SourceLayout: React.FC = ({ + children, + pageChrome = [], + ...props +}) => { + const { contentSource, dataLoading } = useValues(SourceLogic); + const { isOrganization } = useValues(AppLogic); + + const { + name, + createdAt, + serviceType, + serviceName, + isFederatedSource, + supportedByLicense, + } = contentSource; + + const pageHeader = ( + <> + + + + ); + + const callout = ( + <> + +

{SOURCE_DISABLED_CALLOUT_DESCRIPTION}

+ + {SOURCE_DISABLED_CALLOUT_BUTTON} + +
+ + + ); + + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + {!supportedByLicense && callout} + {pageHeader} + {children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index aa6cbf3cf6574d..667e7fd4dbfb42 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -26,6 +26,8 @@ import { AppLogic } from '../../../app_logic'; import { ContentSection } from '../../../components/shared/content_section'; import { SourceConfigFields } from '../../../components/shared/source_config_fields'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; +import { NAV } from '../../../constants'; + import { CANCEL_BUTTON, OK_BUTTON, @@ -52,6 +54,8 @@ import { import { staticSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; +import { SourceLayout } from './source_layout'; + export const SourceSettings: React.FC = () => { const { updateContentSource, removeContentSource } = useActions(SourceLogic); const { getSourceConfigData } = useActions(AddSourceLogic); @@ -128,7 +132,7 @@ export const SourceSettings: React.FC = () => { ); return ( - <> +
@@ -197,6 +201,6 @@ export const SourceSettings: React.FC = () => { {confirmModalVisible && confirmModal} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx index 25c389419d731e..7f07c59587f96c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.test.tsx @@ -7,34 +7,92 @@ import { setMockValues } from '../../../../__mocks__/kea_logic'; -import React from 'react'; +jest.mock('../../../../shared/layout', () => ({ + generateNavLink: jest.fn(({ to }) => ({ href: to })), +})); -import { shallow } from 'enzyme'; +import { useSourceSubNav } from './source_sub_nav'; -import { SideNavLink } from '../../../../shared/layout'; -import { CUSTOM_SERVICE_TYPE } from '../../../constants'; +describe('useSourceSubNav', () => { + it('returns undefined when no content source id present', () => { + setMockValues({ contentSource: {} }); -import { SourceSubNav } from './source_sub_nav'; + expect(useSourceSubNav()).toEqual(undefined); + }); -describe('SourceSubNav', () => { - it('renders empty when no group id present', () => { - setMockValues({ contentSource: {} }); - const wrapper = shallow(); + it('returns EUI nav items', () => { + setMockValues({ isOrganization: true, contentSource: { id: '1' } }); - expect(wrapper.find(SideNavLink)).toHaveLength(0); + expect(useSourceSubNav()).toEqual([ + { + id: 'sourceOverview', + name: 'Overview', + href: '/sources/1', + }, + { + id: 'sourceContent', + name: 'Content', + href: '/sources/1/content', + }, + { + id: 'sourceSettings', + name: 'Settings', + href: '/sources/1/settings', + }, + ]); }); - it('renders nav items', () => { - setMockValues({ contentSource: { id: '1' } }); - const wrapper = shallow(); + it('returns extra nav items for custom sources', () => { + setMockValues({ isOrganization: true, contentSource: { id: '2', serviceType: 'custom' } }); - expect(wrapper.find(SideNavLink)).toHaveLength(3); + expect(useSourceSubNav()).toEqual([ + { + id: 'sourceOverview', + name: 'Overview', + href: '/sources/2', + }, + { + id: 'sourceContent', + name: 'Content', + href: '/sources/2/content', + }, + { + id: 'sourceSchema', + name: 'Schema', + href: '/sources/2/schemas', + }, + { + id: 'sourceDisplaySettings', + name: 'Display Settings', + href: '/sources/2/display_settings', + }, + { + id: 'sourceSettings', + name: 'Settings', + href: '/sources/2/settings', + }, + ]); }); - it('renders custom source nav items', () => { - setMockValues({ contentSource: { id: '1', serviceType: CUSTOM_SERVICE_TYPE } }); - const wrapper = shallow(); + it('returns nav links to personal dashboard when not on an organization page', () => { + setMockValues({ isOrganization: false, contentSource: { id: '3' } }); - expect(wrapper.find(SideNavLink)).toHaveLength(5); + expect(useSourceSubNav()).toEqual([ + { + id: 'sourceOverview', + name: 'Overview', + href: '/p/sources/3', + }, + { + id: 'sourceContent', + name: 'Content', + href: '/p/sources/3/content', + }, + { + id: 'sourceSettings', + name: 'Settings', + href: '/p/sources/3/settings', + }, + ]); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx index 12e1506ec6efda..6b595a06f0404d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import React from 'react'; - import { useValues } from 'kea'; -import { SideNavLink } from '../../../../shared/layout'; +import { EuiSideNavItemType } from '@elastic/eui'; + +import { generateNavLink } from '../../../../shared/layout'; import { AppLogic } from '../../../app_logic'; import { NAV, CUSTOM_SERVICE_TYPE } from '../../../constants'; import { @@ -22,40 +22,52 @@ import { } from '../../../routes'; import { SourceLogic } from '../source_logic'; -export const SourceSubNav: React.FC = () => { +export const useSourceSubNav = () => { const { isOrganization } = useValues(AppLogic); const { contentSource: { id, serviceType }, } = useValues(SourceLogic); - if (!id) return null; + if (!id) return undefined; + + const navItems: Array> = [ + { + id: 'sourceOverview', + name: NAV.OVERVIEW, + ...generateNavLink({ to: getContentSourcePath(SOURCE_DETAILS_PATH, id, isOrganization) }), + }, + { + id: 'sourceContent', + name: NAV.CONTENT, + ...generateNavLink({ to: getContentSourcePath(SOURCE_CONTENT_PATH, id, isOrganization) }), + }, + ]; const isCustom = serviceType === CUSTOM_SERVICE_TYPE; + if (isCustom) { + navItems.push({ + id: 'sourceSchema', + name: NAV.SCHEMA, + ...generateNavLink({ + to: getContentSourcePath(SOURCE_SCHEMAS_PATH, id, isOrganization), + shouldShowActiveForSubroutes: true, + }), + }); + navItems.push({ + id: 'sourceDisplaySettings', + name: NAV.DISPLAY_SETTINGS, + ...generateNavLink({ + to: getContentSourcePath(SOURCE_DISPLAY_SETTINGS_PATH, id, isOrganization), + shouldShowActiveForSubroutes: true, + }), + }); + } + + navItems.push({ + id: 'sourceSettings', + name: NAV.SETTINGS, + ...generateNavLink({ to: getContentSourcePath(SOURCE_SETTINGS_PATH, id, isOrganization) }), + }); - return ( -
- - {NAV.OVERVIEW} - - - {NAV.CONTENT} - - {isCustom && ( - <> - - {NAV.SCHEMA} - - - {NAV.DISPLAY_SETTINGS} - - - )} - - {NAV.SETTINGS} - -
- ); + return navItems; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx index 9df91406c4b7b4..2317c84af2432b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.test.tsx @@ -10,14 +10,10 @@ import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; import { contentSources } from '../../__mocks__/content_sources.mock'; import React from 'react'; -import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { Loading } from '../../../shared/loading'; import { SourcesTable } from '../../components/shared/sources_table'; -import { ViewContentHeader } from '../../components/shared/view_content_header'; -import { ADD_SOURCE_PATH, getSourcesPath } from '../../routes'; import { OrganizationSources } from './organization_sources'; @@ -42,20 +38,12 @@ describe('OrganizationSources', () => { const wrapper = shallow(); expect(wrapper.find(SourcesTable)).toHaveLength(1); - expect(wrapper.find(ViewContentHeader)).toHaveLength(1); }); - it('returns loading when loading', () => { + it('does not render a page header when data is loading (to prevent a jump after redirect)', () => { setMockValues({ ...mockValues, dataLoading: true }); const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); - }); - - it('returns redirect when no sources', () => { - setMockValues({ ...mockValues, contentSources: [] }); - const wrapper = shallow(); - - expect(wrapper.find(Redirect).prop('to')).toEqual(getSourcesPath(ADD_SOURCE_PATH, true)); + expect(wrapper.prop('pageHeader')).toBeUndefined(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx index 4559003b4597f0..a4273ae2ae6a2d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx @@ -6,16 +6,15 @@ */ import React, { useEffect } from 'react'; -import { Link, Redirect } from 'react-router-dom'; +import { Redirect } from 'react-router-dom'; import { useActions, useValues } from 'kea'; -import { EuiButton } from '@elastic/eui'; - -import { Loading } from '../../../shared/loading'; +import { EuiButtonTo } from '../../../shared/react_router_helpers'; +import { WorkplaceSearchPageTemplate } from '../../components/layout'; import { ContentSection } from '../../components/shared/content_section'; import { SourcesTable } from '../../components/shared/sources_table'; -import { ViewContentHeader } from '../../components/shared/view_content_header'; +import { NAV } from '../../constants'; import { ADD_SOURCE_PATH, getSourcesPath } from '../../routes'; import { @@ -36,33 +35,41 @@ export const OrganizationSources: React.FC = () => { const { dataLoading, contentSources } = useValues(SourcesLogic); - if (dataLoading) return ; - - if (contentSources.length === 0) return ; - return ( - - - - {ORG_SOURCES_LINK} - - - } - description={ORG_SOURCES_HEADER_DESCRIPTION} - alignItems="flexStart" - /> - - - - - + + {ORG_SOURCES_LINK} + , + ], + } + } + isLoading={dataLoading} + isEmptyState={!contentSources.length} + emptyState={} + > + + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx index 08f560c984344d..e2b0dfba1fa97e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx @@ -15,7 +15,6 @@ import { shallow } from 'enzyme'; import { EuiCallOut, EuiEmptyPrompt } from '@elastic/eui'; -import { Loading } from '../../../shared/loading'; import { ContentSection } from '../../components/shared/content_section'; import { SourcesTable } from '../../components/shared/sources_table'; @@ -43,13 +42,6 @@ describe('PrivateSources', () => { expect(wrapper.find(SourcesView)).toHaveLength(1); }); - it('renders Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders only shared sources section when canCreatePersonalSources is false', () => { setMockValues({ ...mockValues }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx index 128c65eeb95daa..693c1e8bd5e403 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx @@ -13,12 +13,13 @@ import { EuiCallOut, EuiEmptyPrompt, EuiSpacer, EuiPanel } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { LicensingLogic } from '../../../shared/licensing'; -import { Loading } from '../../../shared/loading'; import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { AppLogic } from '../../app_logic'; import noSharedSourcesIcon from '../../assets/share_circle.svg'; +import { PersonalDashboardLayout } from '../../components/layout'; import { ContentSection } from '../../components/shared/content_section'; import { SourcesTable } from '../../components/shared/sources_table'; +import { NAV } from '../../constants'; import { ADD_SOURCE_PATH, getSourcesPath } from '../../routes'; import { toSentenceSerial } from '../../utils'; @@ -53,8 +54,6 @@ export const PrivateSources: React.FC = () => { account: { canCreatePersonalSources, groups }, } = useValues(AppLogic); - if (dataLoading) return ; - const hasConfiguredConnectors = serviceTypes.some(({ configured }) => configured); const canAddSources = canCreatePersonalSources && hasConfiguredConnectors; const hasPrivateSources = privateContentSources?.length > 0; @@ -144,10 +143,12 @@ export const PrivateSources: React.FC = () => { ); return ( - - {hasPrivateSources && !hasPlatinumLicense && licenseCallout} - {canCreatePersonalSources && privateSourcesSection} - {sharedSourcesSection} - + + + {hasPrivateSources && !hasPlatinumLicense && licenseCallout} + {canCreatePersonalSources && privateSourcesSection} + {sharedSourcesSection} + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx index 783fc434fe8e5d..afe0d1f89faea0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.test.tsx @@ -5,21 +5,17 @@ * 2.0. */ -import '../../../__mocks__/shallow_useeffect.mock'; - import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; -import { mockLocation, mockUseParams } from '../../../__mocks__/react_router'; +import { mockUseParams } from '../../../__mocks__/react_router'; import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock'; import { contentSources } from '../../__mocks__/content_sources.mock'; import React from 'react'; -import { Route, Switch } from 'react-router-dom'; +import { Route } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; -import { NAV } from '../../constants'; +import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../components/layout'; import { DisplaySettingsRouter } from './components/display_settings'; import { Overview } from './components/overview'; @@ -37,6 +33,7 @@ describe('SourceRouter', () => { const mockValues = { contentSource, dataLoading: false, + isOrganization: true, }; beforeEach(() => { @@ -50,11 +47,41 @@ describe('SourceRouter', () => { })); }); - it('returns Loading when loading', () => { - setMockValues({ ...mockValues, dataLoading: true }); - const wrapper = shallow(); + describe('mount/unmount events', () => { + it('fetches & initializes source data on mount', () => { + shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(initializeSource).toHaveBeenCalledWith(contentSource.id); + }); + + it('resets state on unmount', () => { + shallow(); + unmountHandler(); + + expect(resetSourceState).toHaveBeenCalled(); + }); + }); + + describe('loading state when fetching source data', () => { + // NOTE: The early page isLoading returns are required to prevent a flash of a completely empty + // page (instead of preserving the layout/side nav while loading). We also cannot let the code + // fall through to the router because some routes are conditionally rendered based on isCustomSource. + + it('returns an empty loading Workplace Search page on organization views', () => { + setMockValues({ ...mockValues, dataLoading: true, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + expect(wrapper.prop('isLoading')).toEqual(true); + }); + + it('returns an empty loading personal dashboard page when not on an organization view', () => { + setMockValues({ ...mockValues, dataLoading: true, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + expect(wrapper.prop('isLoading')).toEqual(true); + }); }); it('renders source routes (standard)', () => { @@ -63,7 +90,6 @@ describe('SourceRouter', () => { expect(wrapper.find(Overview)).toHaveLength(1); expect(wrapper.find(SourceSettings)).toHaveLength(1); expect(wrapper.find(SourceContent)).toHaveLength(1); - expect(wrapper.find(Switch)).toHaveLength(1); expect(wrapper.find(Route)).toHaveLength(3); }); @@ -76,55 +102,4 @@ describe('SourceRouter', () => { expect(wrapper.find(SchemaChangeErrors)).toHaveLength(1); expect(wrapper.find(Route)).toHaveLength(6); }); - - it('handles breadcrumbs while loading (standard)', () => { - setMockValues({ - ...mockValues, - contentSource: {}, - }); - - const loadingBreadcrumbs = ['Sources', '...']; - - const wrapper = shallow(); - - const overviewBreadCrumb = wrapper.find(SetPageChrome).at(0); - const contentBreadCrumb = wrapper.find(SetPageChrome).at(1); - const settingsBreadCrumb = wrapper.find(SetPageChrome).at(2); - - expect(overviewBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs]); - expect(contentBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.CONTENT]); - expect(settingsBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SETTINGS]); - }); - - it('handles breadcrumbs while loading (custom)', () => { - setMockValues({ - ...mockValues, - contentSource: { serviceType: 'custom' }, - }); - - const loadingBreadcrumbs = ['Sources', '...']; - - const wrapper = shallow(); - - const schemaBreadCrumb = wrapper.find(SetPageChrome).at(2); - const schemaErrorsBreadCrumb = wrapper.find(SetPageChrome).at(3); - const displaySettingsBreadCrumb = wrapper.find(SetPageChrome).at(4); - - expect(schemaBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SCHEMA]); - expect(schemaErrorsBreadCrumb.prop('trail')).toEqual([...loadingBreadcrumbs, NAV.SCHEMA]); - expect(displaySettingsBreadCrumb.prop('trail')).toEqual([ - ...loadingBreadcrumbs, - NAV.DISPLAY_SETTINGS, - ]); - }); - - describe('reset state', () => { - it('resets state when leaving source tree', () => { - mockLocation.pathname = '/home'; - shallow(); - unmountHandler(); - - expect(resetSourceState).toHaveBeenCalled(); - }); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx index d5d6c8e541e4f2..bf68a60757c0df 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx @@ -10,18 +10,11 @@ import React, { useEffect } from 'react'; import { Route, Switch, useLocation, useParams } from 'react-router-dom'; import { useActions, useValues } from 'kea'; -import moment from 'moment'; -import { EuiButton, EuiCallOut, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; - -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; -import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; -import { NAV } from '../../constants'; +import { WorkplaceSearchPageTemplate, PersonalDashboardLayout } from '../../components/layout'; import { CUSTOM_SERVICE_TYPE } from '../../constants'; import { - ENT_SEARCH_LICENSE_MANAGEMENT, REINDEX_JOB_PATH, SOURCE_DETAILS_PATH, SOURCE_CONTENT_PATH, @@ -37,13 +30,7 @@ import { Overview } from './components/overview'; import { Schema } from './components/schema'; import { SchemaChangeErrors } from './components/schema/schema_change_errors'; import { SourceContent } from './components/source_content'; -import { SourceInfoCard } from './components/source_info_card'; import { SourceSettings } from './components/source_settings'; -import { - SOURCE_DISABLED_CALLOUT_TITLE, - SOURCE_DISABLED_CALLOUT_DESCRIPTION, - SOURCE_DISABLED_CALLOUT_BUTTON, -} from './constants'; import { SourceLogic } from './source_logic'; export const SourceRouter: React.FC = () => { @@ -61,84 +48,43 @@ export const SourceRouter: React.FC = () => { return resetSourceState; }, []); - if (dataLoading) return ; + if (dataLoading) { + return isOrganization ? ( + + ) : ( + + ); + } - const { - name, - createdAt, - serviceType, - serviceName, - isFederatedSource, - supportedByLicense, - } = contentSource; + const { serviceType } = contentSource; const isCustomSource = serviceType === CUSTOM_SERVICE_TYPE; - const pageHeader = ( - <> - - - - ); - - const callout = ( - <> - -

{SOURCE_DISABLED_CALLOUT_DESCRIPTION}

- - {SOURCE_DISABLED_CALLOUT_BUTTON} - -
- - - ); - return ( - <> - {!supportedByLicense && callout} - {pageHeader} - - - - - + + + + + + + + {isCustomSource && ( + + - - - - + )} + {isCustomSource && ( + + - {isCustomSource && ( - - - - - - )} - {isCustomSource && ( - - - - - - )} - {isCustomSource && ( - - - - - - )} - - - - + )} + {isCustomSource && ( + + - - + )} + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index 84bff65e62cef4..2abdba07b5c881 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -11,12 +11,8 @@ import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; import { Location } from 'history'; import { useActions, useValues } from 'kea'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { LicensingLogic } from '../../../shared/licensing'; -import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { AppLogic } from '../../app_logic'; -import { NAV } from '../../constants'; import { ADD_SOURCE_PATH, SOURCE_DETAILS_PATH, @@ -52,71 +48,53 @@ export const SourcesRouter: React.FC = () => { }, [pathname]); return ( - <> - - - - - - + + + + + + + + {staticSourceData.map(({ addPath, accountContextOnly }, i) => ( + + {!hasPlatinumLicense && accountContextOnly ? ( + + ) : ( + + )} - - - - + ))} + {staticSourceData.map(({ addPath }, i) => ( + + - {staticSourceData.map(({ addPath, accountContextOnly, name }, i) => ( - - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ))} - {staticSourceData.map(({ addPath, name }, i) => ( - - - - - ))} - {staticSourceData.map(({ addPath, name }, i) => ( - - - - - ))} - {staticSourceData.map(({ addPath, name, configuration: { needsConfiguration } }, i) => { - if (needsConfiguration) - return ( - - - - - ); - })} - {canCreatePersonalSources ? ( - - - - - - ) : ( - - )} - - - + ))} + {staticSourceData.map(({ addPath }, i) => ( + + - - + ))} + {staticSourceData.map(({ addPath, configuration: { needsConfiguration } }, i) => { + if (needsConfiguration) + return ( + + + + ); + })} + {canCreatePersonalSources ? ( + + - - + ) : ( + + )} + + + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx index cf23470e8155eb..7bd40d6f04a56f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.test.tsx @@ -25,6 +25,13 @@ describe('Overview', () => { expect(mockActions.initializeOverview).toHaveBeenCalled(); }); + it('does not render a page header when data is loading (to prevent a jump between non/onboarding headers)', () => { + setMockValues({ dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.prop('pageHeader')).toBeUndefined(); + }); + it('renders onboarding state', () => { setMockValues({ dataLoading: false }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx index 0049c5b732d3d0..c51fdb64b8f261 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx @@ -53,17 +53,15 @@ export const Overview: React.FC = () => { const hideOnboarding = hasUsers && hasOrgSources && isOldAccount && orgName !== defaultOrgName; - const headerTitle = dataLoading || hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; - const headerDescription = - dataLoading || hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; + const headerTitle = hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; + const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx index b32e3af0218273..35619d2b2d560d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx @@ -40,6 +40,13 @@ describe('SourceConfig', () => { expect(wrapper.find(EuiConfirmModal)).toHaveLength(1); }); + it('renders a breadcrumb fallback while data is loading', () => { + setMockValues({ dataLoading: true, sourceConfigData: {} }); + const wrapper = shallow(); + + expect(wrapper.prop('pageChrome')).toEqual(['Settings', 'Content source connectors', '...']); + }); + it('handles delete click', () => { const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx index f1dfda78ee13ff..c2a0b60e1eca3d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -47,7 +47,7 @@ export const SourceConfig: React.FC = ({ sourceIndex }) => { return ( { expect(sendGetPackages).toHaveBeenCalledTimes(1); }); + + describe('tags', () => { + test('without packages tag, without search term', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + mockSendGetPackages.mockReturnValue( + hot('--(a|)', { a: { data: { response: testResponse } } }) + ); + setupMock.getStartServices.mockReturnValue( + hot('--(a|)', { a: [coreMock.createStart()] }) as any + ); + const packageSearchProvider = createPackageSearchProvider(setupMock); + expectObservable( + packageSearchProvider.find( + { types: ['test'] }, + { aborted$: NEVER, maxResults: 100, preference: '' } + ) + ).toBe('(a|)', { + a: [], + }); + }); + + expect(sendGetPackages).toHaveBeenCalledTimes(0); + }); + + test('with packages tag, with no search term', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + mockSendGetPackages.mockReturnValue( + hot('--(a|)', { a: { data: { response: testResponse } } }) + ); + setupMock.getStartServices.mockReturnValue( + hot('--(a|)', { a: [coreMock.createStart()] }) as any + ); + const packageSearchProvider = createPackageSearchProvider(setupMock); + expectObservable( + packageSearchProvider.find( + { types: ['package'] }, + { aborted$: NEVER, maxResults: 100, preference: '' } + ) + ).toBe('--(a|)', { + a: [ + { + id: 'test-test', + score: 80, + title: 'test', + type: 'package', + url: { + path: 'undefined#/detail/test-test/overview', + prependBasePath: false, + }, + }, + { + id: 'test1-test1', + score: 80, + title: 'test1', + type: 'package', + url: { + path: 'undefined#/detail/test1-test1/overview', + prependBasePath: false, + }, + }, + ], + }); + }); + + expect(sendGetPackages).toHaveBeenCalledTimes(1); + }); + + test('with packages tag, with search term', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + mockSendGetPackages.mockReturnValue( + hot('--(a|)', { a: { data: { response: testResponse } } }) + ); + setupMock.getStartServices.mockReturnValue( + hot('--(a|)', { a: [coreMock.createStart()] }) as any + ); + const packageSearchProvider = createPackageSearchProvider(setupMock); + expectObservable( + packageSearchProvider.find( + { term: 'test1', types: ['package'] }, + { aborted$: NEVER, maxResults: 100, preference: '' } + ) + ).toBe('--(a|)', { + a: [ + { + id: 'test1-test1', + score: 80, + title: 'test1', + type: 'package', + url: { + path: 'undefined#/detail/test1-test1/overview', + prependBasePath: false, + }, + }, + ], + }); + }); + + expect(sendGetPackages).toHaveBeenCalledTimes(1); + }); + }); }); }); diff --git a/x-pack/plugins/fleet/public/search_provider.ts b/x-pack/plugins/fleet/public/search_provider.ts index cd4ec1c29b4579..56e08ecad29fb2 100644 --- a/x-pack/plugins/fleet/public/search_provider.ts +++ b/x-pack/plugins/fleet/public/search_provider.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { CoreSetup, CoreStart } from 'src/core/public'; +import type { CoreSetup, CoreStart, ApplicationStart } from 'src/core/public'; import type { Observable } from 'rxjs'; import { from, of, combineLatest } from 'rxjs'; @@ -34,6 +34,26 @@ const createPackages$ = () => shareReplay(1) ); +const toSearchResult = ( + pkg: GetPackagesResponse['response'][number], + application: ApplicationStart +) => { + const pkgkey = `${pkg.name}-${pkg.version}`; + return { + id: pkgkey, + type: packageType, + title: pkg.title, + score: 80, + url: { + // TODO: See https://github.com/elastic/kibana/issues/96134 for details about why we use '#' here. Below should be updated + // as part of migrating to non-hash based router. + // prettier-ignore + path: `${application.getUrlForApp(INTEGRATIONS_PLUGIN_ID)}#${pagePathGetters.integration_details_overview({ pkgkey })[1]}`, + prependBasePath: false, + }, + }; +}; + export const createPackageSearchProvider = (core: CoreSetup): GlobalSearchResultProvider => { const coreStart$ = from(core.getStartServices()).pipe( map(([coreStart]) => coreStart), @@ -52,12 +72,23 @@ export const createPackageSearchProvider = (core: CoreSetup): GlobalSearchResult return { id: 'packages', getSearchableTypes: () => [packageType], - find: ({ term }, { maxResults, aborted$ }) => { - if (!term) { + find: ({ term, types }, { maxResults, aborted$ }) => { + if (types?.includes(packageType) === false) { return of([]); } - term = term.toLowerCase(); + const hasTypes = Boolean(types); + const typesIncludePackage = hasTypes && types!.includes(packageType); + const noSearchTerm = !term; + const includeAllPackages = typesIncludePackage && noSearchTerm; + + if (!includeAllPackages && noSearchTerm) { + return of([]); + } + + if (term) { + term = term.toLowerCase(); + } const toSearchResults = ( coreStart: CoreStart, @@ -65,25 +96,17 @@ export const createPackageSearchProvider = (core: CoreSetup): GlobalSearchResult ): GlobalSearchProviderResult[] => { const packages = packagesResponse.slice(0, maxResults); - return packages.flatMap((pkg) => { - if (!term || !pkg.title.toLowerCase().includes(term)) { - return []; - } - const pkgkey = `${pkg.name}-${pkg.version}`; - return { - id: pkgkey, - type: packageType, - title: pkg.title, - score: 80, - url: { - // TODO: See https://github.com/elastic/kibana/issues/96134 for details about why we use '#' here. Below should be updated - // as part of migrating to non-hash based router. - // prettier-ignore - path: `${coreStart.application.getUrlForApp(INTEGRATIONS_PLUGIN_ID)}#${pagePathGetters.integration_details_overview({ pkgkey })[1]}`, - prependBasePath: false, - }, - }; - }); + return packages.flatMap( + includeAllPackages + ? (pkg) => toSearchResult(pkg, coreStart.application) + : (pkg) => { + if (!term || !pkg.title.toLowerCase().includes(term)) { + return []; + } + + return toSearchResult(pkg, coreStart.application); + } + ); }; return combineLatest([coreStart$, getPackages$()]).pipe( diff --git a/x-pack/plugins/fleet/server/services/artifacts/artifacts.test.ts b/x-pack/plugins/fleet/server/services/artifacts/artifacts.test.ts index 5681be3e8793bc..b046b41d73722b 100644 --- a/x-pack/plugins/fleet/server/services/artifacts/artifacts.test.ts +++ b/x-pack/plugins/fleet/server/services/artifacts/artifacts.test.ts @@ -125,6 +125,7 @@ describe('When using the artifacts services', () => { expect(esClientMock.delete).toHaveBeenCalledWith({ index: FLEET_SERVER_ARTIFACTS_INDEX, id: '123', + refresh: 'wait_for', }); }); diff --git a/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts b/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts index 26032ab94dbc85..6ac23cb1f9ef8c 100644 --- a/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts +++ b/x-pack/plugins/fleet/server/services/artifacts/artifacts.ts @@ -87,6 +87,7 @@ export const deleteArtifact = async (esClient: ElasticsearchClient, id: string): await esClient.delete({ index: FLEET_SERVER_ARTIFACTS_INDEX, id, + refresh: 'wait_for', }); } catch (e) { throw new ArtifactsElasticsearchError(e); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts index 4c0484c058abf0..f929a4f139981f 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts @@ -59,25 +59,26 @@ processors: } String verified(def ctx, def params) { - // Agents only use API keys. - if (ctx?._security?.authentication_type == null || ctx._security.authentication_type != 'API_KEY') { - return "no_api_key"; + // No agent.id field to validate. + if (ctx?.agent?.id == null) { + return "missing"; } - // Verify the API key owner before trusting any metadata it contains. - if (!is_user_trusted(ctx, params.trusted_users)) { - return "untrusted_user"; - } - - // API keys created by Fleet include metadata about the agent they were issued to. - if (ctx?._security?.api_key?.metadata?.agent_id == null || ctx?.agent?.id == null) { - return "missing_metadata"; + // Check auth metadata from API key. + if (ctx?._security?.authentication_type == null + // Agents only use API keys. + || ctx._security.authentication_type != 'API_KEY' + // Verify the API key owner before trusting any metadata it contains. + || !is_user_trusted(ctx, params.trusted_users) + // Verify the API key has metadata indicating the assigned agent ID. + || ctx?._security?.api_key?.metadata?.agent_id == null) { + return "auth_metadata_missing"; } // The API key can only be used represent the agent.id it was issued to. if (ctx._security.api_key.metadata.agent_id != ctx.agent.id) { // Potential masquerade attempt. - return "agent_id_mismatch"; + return "mismatch"; } return "verified"; diff --git a/x-pack/plugins/index_lifecycle_management/public/index.ts b/x-pack/plugins/index_lifecycle_management/public/index.ts index 9bfff971d5e71d..cbd23a14a6114e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/index.ts @@ -14,4 +14,4 @@ export const plugin = (initializerContext: PluginInitializerContext) => { return new IndexLifecycleManagementPlugin(initializerContext); }; -export { ILM_URL_GENERATOR_ID, IlmUrlGeneratorState } from './url_generator'; +export { ILM_LOCATOR_ID, IlmLocatorParams } from './locator'; diff --git a/x-pack/plugins/index_lifecycle_management/public/locator.ts b/x-pack/plugins/index_lifecycle_management/public/locator.ts new file mode 100644 index 00000000000000..025946a095a6f3 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/locator.ts @@ -0,0 +1,61 @@ +/* + * 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 { SerializableState } from 'src/plugins/kibana_utils/common'; +import { ManagementAppLocator } from 'src/plugins/management/common'; +import { LocatorDefinition } from '../../../../src/plugins/share/public/'; +import { + getPoliciesListPath, + getPolicyCreatePath, + getPolicyEditPath, +} from './application/services/navigation'; +import { PLUGIN } from '../common/constants'; + +export const ILM_LOCATOR_ID = 'ILM_LOCATOR_ID'; + +export interface IlmLocatorParams extends SerializableState { + page: 'policies_list' | 'policy_edit' | 'policy_create'; + policyName?: string; +} + +export interface IlmLocatorDefinitionDependencies { + managementAppLocator: ManagementAppLocator; +} + +export class IlmLocatorDefinition implements LocatorDefinition { + constructor(protected readonly deps: IlmLocatorDefinitionDependencies) {} + + public readonly id = ILM_LOCATOR_ID; + + public readonly getLocation = async (params: IlmLocatorParams) => { + const location = await this.deps.managementAppLocator.getLocation({ + sectionId: 'data', + appId: PLUGIN.ID, + }); + + switch (params.page) { + case 'policy_create': { + return { + ...location, + path: location.path + getPolicyCreatePath(), + }; + } + case 'policy_edit': { + return { + ...location, + path: location.path + getPolicyEditPath(params.policyName!), + }; + } + case 'policies_list': { + return { + ...location, + path: location.path + getPoliciesListPath(), + }; + } + } + }; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx index 069d1e0d10e0bf..163fe2b3d9b5ca 100644 --- a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx @@ -17,7 +17,7 @@ import { init as initNotification } from './application/services/notification'; import { BreadcrumbService } from './application/services/breadcrumbs'; import { addAllExtensions } from './extend_index_management'; import { ClientConfigType, SetupDependencies, StartDependencies } from './types'; -import { registerUrlGenerator } from './url_generator'; +import { IlmLocatorDefinition } from './locator'; export class IndexLifecycleManagementPlugin implements Plugin { @@ -38,7 +38,7 @@ export class IndexLifecycleManagementPlugin getStartServices, } = coreSetup; - const { usageCollection, management, indexManagement, home, cloud, share } = plugins; + const { usageCollection, management, indexManagement, home, cloud } = plugins; // Initialize services even if the app isn't mounted, because they're used by index management extensions. initHttp(http); @@ -110,7 +110,11 @@ export class IndexLifecycleManagementPlugin addAllExtensions(indexManagement.extensionsService); } - registerUrlGenerator(coreSetup, management, share); + plugins.share.url.locators.create( + new IlmLocatorDefinition({ + managementAppLocator: plugins.management.locator, + }) + ); } } diff --git a/x-pack/plugins/index_lifecycle_management/public/url_generator.ts b/x-pack/plugins/index_lifecycle_management/public/url_generator.ts deleted file mode 100644 index f7794c535198f8..00000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/url_generator.ts +++ /dev/null @@ -1,61 +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 { CoreSetup } from 'kibana/public'; -import { UrlGeneratorsDefinition } from '../../../../src/plugins/share/public/'; -import { - getPoliciesListPath, - getPolicyCreatePath, - getPolicyEditPath, -} from './application/services/navigation'; -import { MANAGEMENT_APP_ID } from '../../../../src/plugins/management/public'; -import { SetupDependencies } from './types'; -import { PLUGIN } from '../common/constants'; - -export const ILM_URL_GENERATOR_ID = 'ILM_URL_GENERATOR_ID'; - -export interface IlmUrlGeneratorState { - page: 'policies_list' | 'policy_edit' | 'policy_create'; - policyName?: string; - absolute?: boolean; -} -export const createIlmUrlGenerator = ( - getAppBasePath: (absolute?: boolean) => Promise -): UrlGeneratorsDefinition => { - return { - id: ILM_URL_GENERATOR_ID, - createUrl: async (state: IlmUrlGeneratorState): Promise => { - switch (state.page) { - case 'policy_create': { - return `${await getAppBasePath(!!state.absolute)}${getPolicyCreatePath()}`; - } - case 'policy_edit': { - return `${await getAppBasePath(!!state.absolute)}${getPolicyEditPath(state.policyName!)}`; - } - case 'policies_list': { - return `${await getAppBasePath(!!state.absolute)}${getPoliciesListPath()}`; - } - } - }, - }; -}; - -export const registerUrlGenerator = ( - coreSetup: CoreSetup, - management: SetupDependencies['management'], - share: SetupDependencies['share'] -) => { - const getAppBasePath = async (absolute = false) => { - const [coreStart] = await coreSetup.getStartServices(); - return coreStart.application.getUrlForApp(MANAGEMENT_APP_ID, { - path: management.sections.section.data.getApp(PLUGIN.ID)!.basePath, - absolute, - }); - }; - - share.urlGenerators.registerUrlGenerator(createIlmUrlGenerator(getAppBasePath)); -}; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts index 93cd772ce6658d..8e114b0596948e 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts @@ -22,6 +22,21 @@ import { const nonBreakingSpace = ' '; +const urlServiceMock = { + locators: { + get: () => ({ + getLocation: async () => ({ + app: '', + path: '', + state: {}, + }), + getUrl: async ({ policyName }: { policyName: string }) => `/test/${policyName}`, + navigate: async () => {}, + useUrl: () => '', + }), + }, +}; + describe('Data Streams tab', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); let testBed: DataStreamsTabTestBed; @@ -38,7 +53,9 @@ describe('Data Streams tab', () => { }); test('displays an empty prompt', async () => { - testBed = await setup(); + testBed = await setup({ + url: urlServiceMock, + }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -54,6 +71,7 @@ describe('Data Streams tab', () => { test('when Ingest Manager is disabled, goes to index templates tab when "Get started" link is clicked', async () => { testBed = await setup({ plugins: {}, + url: urlServiceMock, }); await act(async () => { @@ -73,6 +91,7 @@ describe('Data Streams tab', () => { test('when Fleet is enabled, links to Fleet', async () => { testBed = await setup({ plugins: { isFleetEnabled: true }, + url: urlServiceMock, }); await act(async () => { @@ -95,6 +114,7 @@ describe('Data Streams tab', () => { testBed = await setup({ plugins: {}, + url: urlServiceMock, }); await act(async () => { @@ -345,6 +365,7 @@ describe('Data Streams tab', () => { testBed = await setup({ history: createMemoryHistory(), + url: urlServiceMock, }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -370,13 +391,8 @@ describe('Data Streams tab', () => { }); }); - describe('url generators', () => { - const mockIlmUrlGenerator = { - getUrlGenerator: () => ({ - createUrl: ({ policyName }: { policyName: string }) => `/test/${policyName}`, - }), - }; - test('with an ILM url generator and an ILM policy', async () => { + describe('url locators', () => { + test('with an ILM url locator and an ILM policy', async () => { const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; const dataStreamForDetailPanel = createDataStreamPayload({ @@ -388,7 +404,7 @@ describe('Data Streams tab', () => { testBed = await setup({ history: createMemoryHistory(), - urlGenerators: mockIlmUrlGenerator, + url: urlServiceMock, }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -400,7 +416,7 @@ describe('Data Streams tab', () => { expect(findDetailPanelIlmPolicyLink().prop('href')).toBe('/test/my_ilm_policy'); }); - test('with an ILM url generator and no ILM policy', async () => { + test('with an ILM url locator and no ILM policy', async () => { const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; const dataStreamForDetailPanel = createDataStreamPayload({ name: 'dataStream1' }); @@ -409,7 +425,7 @@ describe('Data Streams tab', () => { testBed = await setup({ history: createMemoryHistory(), - urlGenerators: mockIlmUrlGenerator, + url: urlServiceMock, }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -422,7 +438,7 @@ describe('Data Streams tab', () => { expect(findDetailPanelIlmPolicyName().contains('None')).toBeTruthy(); }); - test('without an ILM url generator and with an ILM policy', async () => { + test('without an ILM url locator and with an ILM policy', async () => { const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; const dataStreamForDetailPanel = createDataStreamPayload({ @@ -434,7 +450,11 @@ describe('Data Streams tab', () => { testBed = await setup({ history: createMemoryHistory(), - urlGenerators: { getUrlGenerator: () => {} }, + url: { + locators: { + get: () => undefined, + }, + }, }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -463,6 +483,7 @@ describe('Data Streams tab', () => { testBed = await setup({ history: createMemoryHistory(), + url: urlServiceMock, }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -506,6 +527,7 @@ describe('Data Streams tab', () => { testBed = await setup({ history: createMemoryHistory(), + url: urlServiceMock, }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -542,7 +564,7 @@ describe('Data Streams tab', () => { beforeEach(async () => { setLoadDataStreamsResponse([dataStreamWithDelete, dataStreamNoDelete]); - testBed = await setup({ history: createMemoryHistory() }); + testBed = await setup({ history: createMemoryHistory(), url: urlServiceMock }); await act(async () => { testBed.actions.goToDataStreamsList(); }); diff --git a/x-pack/plugins/index_management/public/application/app_context.tsx b/x-pack/plugins/index_management/public/application/app_context.tsx index 3b06d76cf7c26b..f8ebfdf7c46b75 100644 --- a/x-pack/plugins/index_management/public/application/app_context.tsx +++ b/x-pack/plugins/index_management/public/application/app_context.tsx @@ -35,7 +35,7 @@ export interface AppDependencies { history: ScopedHistory; setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']; uiSettings: CoreSetup['uiSettings']; - urlGenerators: SharePluginStart['urlGenerators']; + url: SharePluginStart['url']; docLinks: CoreStart['docLinks']; } diff --git a/x-pack/plugins/index_management/public/application/constants/ilm_url_generator.ts b/x-pack/plugins/index_management/public/application/constants/ilm_locator.ts similarity index 83% rename from x-pack/plugins/index_management/public/application/constants/ilm_url_generator.ts rename to x-pack/plugins/index_management/public/application/constants/ilm_locator.ts index ea6cf1756b73cd..3da13727af8de0 100644 --- a/x-pack/plugins/index_management/public/application/constants/ilm_url_generator.ts +++ b/x-pack/plugins/index_management/public/application/constants/ilm_locator.ts @@ -5,5 +5,5 @@ * 2.0. */ -export const ILM_URL_GENERATOR_ID = 'ILM_URL_GENERATOR_ID'; +export const ILM_LOCATOR_ID = 'ILM_LOCATOR_ID'; export const ILM_PAGES_POLICY_EDIT = 'policy_edit'; diff --git a/x-pack/plugins/index_management/public/application/constants/index.ts b/x-pack/plugins/index_management/public/application/constants/index.ts index 3bf30517c11453..7a1caf5e507714 100644 --- a/x-pack/plugins/index_management/public/application/constants/index.ts +++ b/x-pack/plugins/index_management/public/application/constants/index.ts @@ -17,4 +17,4 @@ export { export const REACT_ROOT_ID = 'indexManagementReactRoot'; -export * from './ilm_url_generator'; +export * from './ilm_locator'; diff --git a/x-pack/plugins/index_management/public/application/mount_management_section.ts b/x-pack/plugins/index_management/public/application/mount_management_section.ts index 074334ed87725b..083a8831291dd8 100644 --- a/x-pack/plugins/index_management/public/application/mount_management_section.ts +++ b/x-pack/plugins/index_management/public/application/mount_management_section.ts @@ -62,7 +62,7 @@ export async function mountManagementSection( uiSettings, } = core; - const { urlGenerators } = startDependencies.share; + const { url } = startDependencies.share; docTitle.change(PLUGIN.getI18nName(i18n)); breadcrumbService.setup(setBreadcrumbs); @@ -86,7 +86,7 @@ export async function mountManagementSection( history, setBreadcrumbs, uiSettings, - urlGenerators, + url, docLinks, }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx index 773ccd91a5fb12..a9258c6a3b10be 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -29,11 +29,11 @@ import { SectionLoading, SectionError, Error, DataHealth } from '../../../../com import { useLoadDataStream } from '../../../../services/api'; import { DeleteDataStreamConfirmationModal } from '../delete_data_stream_confirmation_modal'; import { humanizeTimeStamp } from '../humanize_time_stamp'; -import { useUrlGenerator } from '../../../../services/use_url_generator'; import { getIndexListUri, getTemplateDetailsLink } from '../../../../services/routing'; -import { ILM_PAGES_POLICY_EDIT, ILM_URL_GENERATOR_ID } from '../../../../constants'; +import { ILM_PAGES_POLICY_EDIT } from '../../../../constants'; import { useAppContext } from '../../../../app_context'; import { DataStreamsBadges } from '../data_stream_badges'; +import { useIlmLocator } from '../../../../services/use_ilm_locator'; interface DetailsListProps { details: Array<{ @@ -89,13 +89,7 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ const [isDeleting, setIsDeleting] = useState(false); - const ilmPolicyLink = useUrlGenerator({ - urlGeneratorId: ILM_URL_GENERATOR_ID, - urlGeneratorState: { - page: ILM_PAGES_POLICY_EDIT, - policyName: dataStream?.ilmPolicyName, - }, - }); + const ilmPolicyLink = useIlmLocator(ILM_PAGES_POLICY_EDIT, dataStream?.ilmPolicyName); const { history } = useAppContext(); let content; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx index 2dd2c6e30cfcc0..c17ccd9ced9322 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_summary.tsx @@ -21,8 +21,8 @@ import { EuiSpacer, } from '@elastic/eui'; import { TemplateDeserialized } from '../../../../../../../common'; -import { ILM_PAGES_POLICY_EDIT, ILM_URL_GENERATOR_ID } from '../../../../../constants'; -import { useUrlGenerator } from '../../../../../services/use_url_generator'; +import { ILM_PAGES_POLICY_EDIT } from '../../../../../constants'; +import { useIlmLocator } from '../../../../../services/use_ilm_locator'; interface Props { templateDetails: TemplateDeserialized; @@ -54,13 +54,7 @@ export const TabSummary: React.FunctionComponent = ({ templateDetails }) const numIndexPatterns = indexPatterns.length; - const ilmPolicyLink = useUrlGenerator({ - urlGeneratorId: ILM_URL_GENERATOR_ID, - urlGeneratorState: { - page: ILM_PAGES_POLICY_EDIT, - policyName: ilmPolicy?.name, - }, - }); + const ilmPolicyLink = useIlmLocator(ILM_PAGES_POLICY_EDIT, ilmPolicy?.name); return ( <> diff --git a/x-pack/plugins/index_management/public/application/services/use_ilm_locator.ts b/x-pack/plugins/index_management/public/application/services/use_ilm_locator.ts new file mode 100644 index 00000000000000..d60cd1cf8aabf7 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/services/use_ilm_locator.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useLocatorUrl } from '../../../../../../src/plugins/share/public'; +import { useAppContext } from '../app_context'; +import { ILM_LOCATOR_ID } from '../constants'; + +export const useIlmLocator = ( + page: 'policies_list' | 'policy_edit' | 'policy_create', + policyName?: string +): string => { + const ctx = useAppContext(); + const locator = policyName === undefined ? null : ctx.url.locators.get(ILM_LOCATOR_ID)!; + const url = useLocatorUrl(locator, { page, policyName }, {}, [page, policyName]); + + return url; +}; diff --git a/x-pack/plugins/index_management/public/application/services/use_url_generator.ts b/x-pack/plugins/index_management/public/application/services/use_url_generator.ts deleted file mode 100644 index 2d9ab3959d769c..00000000000000 --- a/x-pack/plugins/index_management/public/application/services/use_url_generator.ts +++ /dev/null @@ -1,40 +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 { useEffect, useState } from 'react'; -import { - UrlGeneratorContract, - UrlGeneratorId, - UrlGeneratorStateMapping, -} from '../../../../../../src/plugins/share/public'; -import { useAppContext } from '../app_context'; - -export const useUrlGenerator = ({ - urlGeneratorId, - urlGeneratorState, -}: { - urlGeneratorId: UrlGeneratorId; - urlGeneratorState: UrlGeneratorStateMapping[UrlGeneratorId]['State']; -}) => { - const { urlGenerators } = useAppContext(); - const [link, setLink] = useState(); - useEffect(() => { - const updateLink = async (): Promise => { - let urlGenerator: UrlGeneratorContract; - try { - urlGenerator = urlGenerators.getUrlGenerator(urlGeneratorId); - const url = await urlGenerator.createUrl(urlGeneratorState); - setLink(url); - } catch (e) { - // do nothing - } - }; - - updateLink(); - }, [urlGeneratorId, urlGeneratorState, urlGenerators]); - return link; -}; diff --git a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx index 41867053c3a0fa..c3327dc3fe85dd 100644 --- a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx @@ -36,12 +36,12 @@ export const MetricsAlertDropdown = () => { () => ({ id: 1, title: i18n.translate('xpack.infra.alerting.infrastructureDropdownTitle', { - defaultMessage: 'Infrastructure alerts', + defaultMessage: 'Infrastructure rules', }), items: [ { name: i18n.translate('xpack.infra.alerting.createInventoryAlertButton', { - defaultMessage: 'Create inventory alert', + defaultMessage: 'Create inventory rule', }), onClick: () => setVisibleFlyoutType('inventory'), }, @@ -54,12 +54,12 @@ export const MetricsAlertDropdown = () => { () => ({ id: 2, title: i18n.translate('xpack.infra.alerting.metricsDropdownTitle', { - defaultMessage: 'Metrics alerts', + defaultMessage: 'Metrics rules', }), items: [ { name: i18n.translate('xpack.infra.alerting.createThresholdAlertButton', { - defaultMessage: 'Create threshold alert', + defaultMessage: 'Create threshold rule', }), onClick: () => setVisibleFlyoutType('threshold'), }, @@ -76,7 +76,7 @@ export const MetricsAlertDropdown = () => { const manageAlertsMenuItem = useMemo( () => ({ name: i18n.translate('xpack.infra.alerting.manageAlerts', { - defaultMessage: 'Manage alerts', + defaultMessage: 'Manage rules', }), icon: 'tableOfContents', onClick: manageAlertsLinkProps.onClick, @@ -112,7 +112,7 @@ export const MetricsAlertDropdown = () => { { id: 0, title: i18n.translate('xpack.infra.alerting.alertDropdownTitle', { - defaultMessage: 'Alerts', + defaultMessage: 'Alerts and rules', }), items: firstPanelMenuItems, }, diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx index a6b69a37f780ef..c9b6275264f914 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/manage_alerts_context_menu_item.tsx @@ -17,7 +17,7 @@ export const ManageAlertsContextMenuItem = () => { }); return ( - + ); }; diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx index 66c77fbf875a45..c1733d4af05894 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx @@ -66,13 +66,13 @@ export const AlertDropdown = () => { > , , ]; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx index 94b16448a6b613..ea80bd13e8a4d8 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx @@ -25,6 +25,7 @@ import { SectionSubtitle, SectionLinks, SectionLink, + ActionMenuDivider, } from '../../../../../../../observability/public'; import { useLinkProps } from '../../../../../hooks/use_link_props'; @@ -173,7 +174,10 @@ export const NodeContextMenu: React.FC = withTheme - + + + +
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 74b47b55fdd0e9..dca8e926646f04 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -141,8 +141,11 @@ export function DimensionEditor(props: DimensionEditorProps) { }; const incompleteInfo = (state.layers[layerId].incompleteColumns ?? {})[columnId]; - const incompleteOperation = incompleteInfo?.operationType; - const incompleteField = incompleteInfo?.sourceField ?? null; + const { + operationType: incompleteOperation, + sourceField: incompleteField = null, + ...incompleteParams + } = incompleteInfo || {}; const ParamEditor = selectedOperationDefinition?.paramEditor; @@ -486,6 +489,7 @@ export function DimensionEditor(props: DimensionEditorProps) { field: currentIndexPattern.getFieldByName(choice.field), visualizationGroups: dimensionGroups, targetGroup: props.groupId, + incompleteParams, }) ); }} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/math.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/math.tsx index 52522a18604aa0..7aae35f4969231 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/math.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/math.tsx @@ -48,7 +48,7 @@ export const mathOperation: OperationDefinition { ).toEqual(expect.objectContaining({ columnOrder: ['col1', 'col2', 'col3'] })); }); + it('should inherit filters from the incomplete column when passed', () => { + expect( + insertNewColumn({ + layer: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }, + }, + columnId: 'col2', + indexPattern, + op: 'average', + field: indexPattern.fields[2], + visualizationGroups: [], + incompleteParams: { filter: { language: 'kuery', query: '' }, timeShift: '3d' }, + }) + ).toEqual( + expect.objectContaining({ + columns: expect.objectContaining({ + col2: expect.objectContaining({ + filter: { language: 'kuery', query: '' }, + timeShift: '3d', + }), + }), + }) + ); + }); + describe('inserting a new reference', () => { it('should throw if the required references are impossible to match', () => { // @ts-expect-error this function is not valid diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index f0095b66e2ba69..fd3df9f97cecf4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -7,6 +7,7 @@ import { partition, mapValues, pickBy } from 'lodash'; import { CoreStart } from 'kibana/public'; +import { Query } from 'src/plugins/data/common'; import type { FramePublicAPI, OperationMetadata, @@ -18,6 +19,7 @@ import { OperationType, IndexPatternColumn, RequiredReference, + GenericOperationDefinition, } from './definitions'; import type { IndexPattern, @@ -29,6 +31,13 @@ import { getSortScoreByPriority } from './operations'; import { generateId } from '../../id_generator'; import { ReferenceBasedIndexPatternColumn } from './definitions/column_types'; import { FormulaIndexPatternColumn, regenerateLayerFromAst } from './definitions/formula'; +import { TimeScaleUnit } from '../time_scale'; + +interface ColumnAdvancedParams { + filter?: Query | undefined; + timeShift?: string | undefined; + timeScale?: TimeScaleUnit | undefined; +} interface ColumnChange { op: OperationType; @@ -39,6 +48,7 @@ interface ColumnChange { visualizationGroups: VisualizationDimensionGroupConfig[]; targetGroup?: string; shouldResetLabel?: boolean; + incompleteParams?: ColumnAdvancedParams; } interface ColumnCopy { @@ -141,6 +151,24 @@ export function insertOrReplaceColumn(args: ColumnChange): IndexPatternLayer { return insertNewColumn(args); } +function ensureCompatibleParamsAreMoved( + column: T, + referencedOperation: GenericOperationDefinition, + previousColumn: ColumnAdvancedParams +) { + const newColumn = { ...column }; + if (referencedOperation.filterable) { + newColumn.filter = (previousColumn as ReferenceBasedIndexPatternColumn).filter; + } + if (referencedOperation.shiftable) { + newColumn.timeShift = (previousColumn as ReferenceBasedIndexPatternColumn).timeShift; + } + if (referencedOperation.timeScalingMode !== 'disabled') { + newColumn.timeScale = (previousColumn as ReferenceBasedIndexPatternColumn).timeScale; + } + return newColumn; +} + // Insert a column into an empty ID. The field parameter is required when constructing // a field-based operation, but will cause the function to fail for any other type of operation. export function insertNewColumn({ @@ -152,6 +180,7 @@ export function insertNewColumn({ visualizationGroups, targetGroup, shouldResetLabel, + incompleteParams, }: ColumnChange): IndexPatternLayer { const operationDefinition = operationDefinitionMap[op]; @@ -163,7 +192,10 @@ export function insertNewColumn({ throw new Error(`Can't insert a column with an ID that is already in use`); } - const baseOptions = { indexPattern, previousColumn: layer.columns[columnId] }; + const baseOptions = { + indexPattern, + previousColumn: { ...incompleteParams, ...layer.columns[columnId] }, + }; if (operationDefinition.input === 'none' || operationDefinition.input === 'managedReference') { if (field) { @@ -414,15 +446,13 @@ export function replaceColumn({ indexPattern, }); - const column = copyCustomLabel({ ...referenceColumn }, previousColumn); // do not forget to move over also any filter/shift/anything (if compatible) // from the reference definition to the new operation. - if (referencedOperation.filterable) { - column.filter = (previousColumn as ReferenceBasedIndexPatternColumn).filter; - } - if (referencedOperation.shiftable) { - column.timeShift = (previousColumn as ReferenceBasedIndexPatternColumn).timeShift; - } + const column = ensureCompatibleParamsAreMoved( + copyCustomLabel({ ...referenceColumn }, previousColumn), + referencedOperation, + previousColumn as ReferenceBasedIndexPatternColumn + ); tempLayer = { ...tempLayer, @@ -529,15 +559,30 @@ export function replaceColumn({ } if (!field) { + let incompleteColumn: { + operationType: OperationType; + } & ColumnAdvancedParams = { operationType: op }; // if no field is available perform a full clean of the column from the layer if (previousDefinition.input === 'fullReference') { tempLayer = deleteColumn({ layer: tempLayer, columnId, indexPattern }); + const previousReferenceId = (previousColumn as ReferenceBasedIndexPatternColumn) + .references[0]; + const referenceColumn = layer.columns[previousReferenceId]; + if (referenceColumn) { + const referencedOperation = operationDefinitionMap[referenceColumn.operationType]; + + incompleteColumn = ensureCompatibleParamsAreMoved( + incompleteColumn, + referencedOperation, + previousColumn + ); + } } return { ...tempLayer, incompleteColumns: { ...(tempLayer.incompleteColumns ?? {}), - [columnId]: { operationType: op }, + [columnId]: incompleteColumn, }, }; } diff --git a/x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.tsx b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.tsx new file mode 100644 index 00000000000000..67e57dadd49353 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.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 { LegendActionProps, SeriesIdentifier } from '@elastic/charts'; +import { EuiPopover } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ComponentType, ReactWrapper } from 'enzyme'; +import type { Datatable } from 'src/plugins/expressions/public'; +import { getLegendAction } from './get_legend_action'; +import { LegendActionPopover } from '../shared_components'; + +const table: Datatable = { + type: 'datatable', + columns: [ + { id: 'a', name: 'A', meta: { type: 'string' } }, + { id: 'b', name: 'B', meta: { type: 'number' } }, + ], + rows: [ + { a: 'Hi', b: 2 }, + { a: 'Test', b: 4 }, + { a: 'Foo', b: 6 }, + ], +}; + +describe('getLegendAction', function () { + let wrapperProps: LegendActionProps; + const Component: ComponentType = getLegendAction(table, jest.fn()); + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapperProps = { + color: 'rgb(109, 204, 177)', + label: 'Bar', + series: ([ + { + specId: 'donut', + key: 'Bar', + }, + ] as unknown) as SeriesIdentifier[], + }; + }); + + it('is not rendered if row does not exist', () => { + wrapper = mountWithIntl(); + expect(wrapper).toEqual({}); + expect(wrapper.find(EuiPopover).length).toBe(0); + }); + + it('is rendered if row is detected', () => { + const newProps = { + ...wrapperProps, + label: 'Hi', + series: ([ + { + specId: 'donut', + key: 'Hi', + }, + ] as unknown) as SeriesIdentifier[], + }; + wrapper = mountWithIntl(); + expect(wrapper.find(EuiPopover).length).toBe(1); + expect(wrapper.find(EuiPopover).prop('title')).toEqual('Hi, filter options'); + expect(wrapper.find(LegendActionPopover).prop('context')).toEqual({ + data: [ + { + column: 0, + row: 0, + table, + value: 'Hi', + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx new file mode 100644 index 00000000000000..9f16ad863a4155 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import type { LegendAction } from '@elastic/charts'; +import type { Datatable } from 'src/plugins/expressions/public'; +import type { LensFilterEvent } from '../types'; +import { LegendActionPopover } from '../shared_components'; + +export const getLegendAction = ( + table: Datatable, + onFilter: (data: LensFilterEvent['data']) => void +): LegendAction => + React.memo(({ series: [pieSeries], label }) => { + const data = table.columns.reduce((acc, { id }, column) => { + const value = pieSeries.key; + const row = table.rows.findIndex((r) => r[id] === value); + if (row > -1) { + acc.push({ + table, + column, + row, + value, + }); + } + + return acc; + }, []); + + if (data.length === 0) { + return null; + } + + const context: LensFilterEvent['data'] = { + data, + }; + + return ; + }); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index 6c1cbe63a5a3e3..f329cfe1bb8b9d 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -38,6 +38,7 @@ import { SeriesLayer, } from '../../../../../src/plugins/charts/public'; import { LensIconChartDonut } from '../assets/chart_donut'; +import { getLegendAction } from './get_legend_action'; declare global { interface Window { @@ -281,6 +282,7 @@ export function PieComponent( onElementClick={ props.renderMode !== 'noInteractivity' ? onElementClickHandler : undefined } + legendAction={getLegendAction(firstTable, onClickValue)} theme={{ ...chartTheme, background: { diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index cf8536884acdf8..c200a18a25cafa 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -13,3 +13,4 @@ export { TooltipWrapper } from './tooltip_wrapper'; export * from './coloring'; export { useDebouncedValue } from './debounced_value'; export * from './helpers'; +export { LegendActionPopover } from './legend_action_popover'; diff --git a/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx new file mode 100644 index 00000000000000..e344cb5289f51e --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx @@ -0,0 +1,102 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; +import type { LensFilterEvent } from '../types'; +import { desanitizeFilterContext } from '../utils'; + +export interface LegendActionPopoverProps { + /** + * Determines the panels label + */ + label: string; + /** + * Callback on filter value + */ + onFilter: (data: LensFilterEvent['data']) => void; + /** + * Determines the filter event data + */ + context: LensFilterEvent['data']; +} + +export const LegendActionPopover: React.FunctionComponent = ({ + label, + onFilter, + context, +}) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 'main', + title: label, + items: [ + { + name: i18n.translate('xpack.lens.shared.legend.filterForValueButtonAriaLabel', { + defaultMessage: 'Filter for value', + }), + 'data-test-subj': `legend-${label}-filterIn`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(desanitizeFilterContext(context)); + }, + }, + { + name: i18n.translate('xpack.lens.shared.legend.filterOutValueButtonAriaLabel', { + defaultMessage: 'Filter out value', + }), + 'data-test-subj': `legend-${label}-filterOut`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(desanitizeFilterContext({ ...context, negate: true })); + }, + }, + ], + }, + ]; + + const Button = ( +
setPopoverOpen(!popoverOpen)} + onClick={() => setPopoverOpen(!popoverOpen)} + > + +
+ ); + return ( + setPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="upLeft" + title={i18n.translate('xpack.lens.shared.legend.filterOptionsLegend', { + defaultMessage: '{legendDataLabel}, filter options', + values: { legendDataLabel: label }, + })} + > + + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index f9b4e33072c819..1f647680408d71 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -7,6 +7,13 @@ exports[`xy_expression XYChart component it renders area 1`] = ` = {}; + // This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers const safeXAccessorLabelRenderer = (value: unknown): string => xAxisColumn && layersAlreadyFormatted[xAxisColumn.id] @@ -629,6 +631,13 @@ export function XYChart({ xDomain={xDomain} onBrushEnd={renderMode !== 'noInteractivity' ? brushHandler : undefined} onElementClick={renderMode !== 'noInteractivity' ? clickHandler : undefined} + legendAction={getLegendAction( + filteredLayers, + data.tables, + onClickValue, + formatFactory, + layersAlreadyFormatted + )} showLegendExtra={isHistogramViz && valuesInLegend} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx new file mode 100644 index 00000000000000..e4edfe918a242d --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx @@ -0,0 +1,232 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { LegendActionProps, SeriesIdentifier } from '@elastic/charts'; +import { EuiPopover } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ComponentType, ReactWrapper } from 'enzyme'; +import type { LayerArgs } from './types'; +import type { LensMultiTable } from '../types'; +import { getLegendAction } from './get_legend_action'; +import { LegendActionPopover } from '../shared_components'; + +const sampleLayer = { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'splitAccessorId', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, +} as LayerArgs; + +const tables = { + first: { + type: 'datatable', + rows: [ + { + xAccessorId: 1585758120000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Accessories", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760700000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585761120000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'order_date per minute', + meta: { + type: 'date', + field: 'order_date', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'date_histogram', + params: { + field: 'order_date', + timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: '1m', + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, + }, + }, + params: { id: 'date', params: { pattern: 'HH:mm' } }, + }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'string', + field: 'category.keyword', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'terms', + params: { + field: 'category.keyword', + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + }, + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/jiy/app/kibana', + basePath: '/jiy', + }, + }, + }, + }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'number', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + params: {}, + }, + params: { id: 'number' }, + }, + }, + ], + }, +} as LensMultiTable['tables']; + +describe('getLegendAction', function () { + let wrapperProps: LegendActionProps; + const Component: ComponentType = getLegendAction( + [sampleLayer], + tables, + jest.fn(), + jest.fn(), + {} + ); + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapperProps = { + color: 'rgb(109, 204, 177)', + label: "Women's Accessories", + series: ([ + { + seriesKeys: ["Women's Accessories", 'test'], + }, + ] as unknown) as SeriesIdentifier[], + }; + }); + + it('is not rendered if not layer is detected', () => { + wrapper = mountWithIntl(); + expect(wrapper).toEqual({}); + expect(wrapper.find(EuiPopover).length).toBe(0); + }); + + it('is rendered if row does not exist', () => { + const newProps = { + ...wrapperProps, + series: ([ + { + seriesKeys: ['test', 'b'], + }, + ] as unknown) as SeriesIdentifier[], + }; + wrapper = mountWithIntl(); + expect(wrapper).toEqual({}); + expect(wrapper.find(EuiPopover).length).toBe(0); + }); + + it('is rendered if layer is detected', () => { + const newProps = { + ...wrapperProps, + series: ([ + { + seriesKeys: ["Women's Accessories", 'b'], + }, + ] as unknown) as SeriesIdentifier[], + }; + wrapper = mountWithIntl(); + expect(wrapper.find(EuiPopover).length).toBe(1); + expect(wrapper.find(EuiPopover).prop('title')).toEqual("Women's Accessories, filter options"); + expect(wrapper.find(LegendActionPopover).prop('context')).toEqual({ + data: [ + { + column: 1, + row: 1, + table: tables.first, + value: "Women's Accessories", + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx new file mode 100644 index 00000000000000..c99bf948d6e374 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { LegendAction, XYChartSeriesIdentifier } from '@elastic/charts'; +import type { LayerArgs } from './types'; +import type { LensMultiTable, LensFilterEvent, FormatFactory } from '../types'; +import { LegendActionPopover } from '../shared_components'; + +export const getLegendAction = ( + filteredLayers: LayerArgs[], + tables: LensMultiTable['tables'], + onFilter: (data: LensFilterEvent['data']) => void, + formatFactory: FormatFactory, + layersAlreadyFormatted: Record +): LegendAction => + React.memo(({ series: [xySeries] }) => { + const series = xySeries as XYChartSeriesIdentifier; + const layer = filteredLayers.find((l) => + series.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) + ); + + if (!layer || !layer.splitAccessor) { + return null; + } + + const splitLabel = series.seriesKeys[0] as string; + const accessor = layer.splitAccessor; + + const table = tables[layer.layerId]; + const splitColumn = table.columns.find(({ id }) => id === layer.splitAccessor); + const formatter = formatFactory(splitColumn && splitColumn.meta?.params); + + const rowIndex = table.rows.findIndex((row) => { + if (layersAlreadyFormatted[accessor]) { + // stringify the value to compare with the chart value + return formatter.convert(row[accessor]) === splitLabel; + } + return row[accessor] === splitLabel; + }); + + if (rowIndex < 0) return null; + + const data = [ + { + row: rowIndex, + column: table.columns.findIndex((col) => col.id === accessor), + value: accessor ? table.rows[rowIndex][accessor] : splitLabel, + table, + }, + ]; + + const context: LensFilterEvent['data'] = { + data, + }; + + return ( + + ); + }); diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap index 95921fa61233c8..90a3eb98c64a14 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AddLicense component when license is active should display correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; +exports[`AddLicense component when license is active should display correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; -exports[`AddLicense component when license is expired should display with correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; +exports[`AddLicense component when license is expired should display with correct verbiage 1`] = `"
Update your license

If you already have a new license, upload it now.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/license_page_header.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/license_page_header.test.js.snap new file mode 100644 index 00000000000000..047e311f3d3250 --- /dev/null +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/license_page_header.test.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LicenseStatus component should display display warning is expired 1`] = `"

Your Platinum license has expired

Your license expired on

"`; + +exports[`LicenseStatus component should display normally when license is active 1`] = `"

Your Gold license is active

Your license will expire on October 12, 2099 7:00 PM EST

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap deleted file mode 100644 index 9bd1c878f86791..00000000000000 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LicenseStatus component should display display warning is expired 1`] = `"

Your Platinum license has expired

Your license expired on
"`; - -exports[`LicenseStatus component should display normally when license is active 1`] = `"

Your Gold license is active

Your license will expire on October 12, 2099 7:00 PM EST
"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap index 4d8b653c4b10d2..fda479f2888ce5 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RequestTrialExtension component should display when enterprise license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when enterprise license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when license is active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when license is active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; -exports[`RequestTrialExtension component should display when platinum license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; +exports[`RequestTrialExtension component should display when platinum license is not active and trial has been used 1`] = `"
Extend your trial

If you’d like to continue using machine learning, advanced security, and our other awesome subscription features(opens in a new tab or window), request an extension now.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap index be634a5b4f7489..4fa45c4bec5ce5 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RevertToBasic component should display when license is about to expire 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when license is about to expire 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when license is expired 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; -exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; +exports[`RevertToBasic component should display when trial is active 1`] = `"
Revert to Basic license

You’ll revert to our free features and lose access to machine learning, advanced security, and other subscription features(opens in a new tab or window).

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap index 1cacadb8246307..622bff86ead169 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed display for basic license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired enterprise license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for expired platinum license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; -exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; +exports[`StartTrial component when trial is allowed should display for gold license 1`] = `"
Start a 30-day trial

Experience what machine learning, advanced security, and all our other subscription features(opens in a new tab or window) have to offer.

"`; diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index 9f89179d207e0c..29ec3ddbfdc025 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -262,16 +262,18 @@ exports[`UploadLicense should display a modal when license requires acknowledgem uploadLicenseStatus={[Function]} >
@@ -1301,16 +1303,18 @@ exports[`UploadLicense should display an error when ES says license is expired 1 uploadLicenseStatus={[Function]} >
@@ -2031,16 +2035,18 @@ exports[`UploadLicense should display an error when ES says license is invalid 1 uploadLicenseStatus={[Function]} >
@@ -2761,16 +2767,18 @@ exports[`UploadLicense should display an error when submitting invalid JSON 1`] uploadLicenseStatus={[Function]} >
@@ -3491,16 +3499,18 @@ exports[`UploadLicense should display error when ES returns error 1`] = ` uploadLicenseStatus={[Function]} >
diff --git a/x-pack/plugins/license_management/__jest__/license_status.test.js b/x-pack/plugins/license_management/__jest__/license_page_header.test.js similarity index 83% rename from x-pack/plugins/license_management/__jest__/license_status.test.js rename to x-pack/plugins/license_management/__jest__/license_page_header.test.js index 898667e13a1b36..56a71eb8d252e3 100644 --- a/x-pack/plugins/license_management/__jest__/license_status.test.js +++ b/x-pack/plugins/license_management/__jest__/license_page_header.test.js @@ -5,7 +5,7 @@ * 2.0. */ -import { LicenseStatus } from '../public/application/sections/license_dashboard/license_status'; +import { LicensePageHeader } from '../public/application/sections/license_dashboard/license_page_header'; import { createMockLicense, getComponent } from './util'; describe('LicenseStatus component', () => { @@ -14,7 +14,7 @@ describe('LicenseStatus component', () => { { license: createMockLicense('gold'), }, - LicenseStatus + LicensePageHeader ); expect(rendered.html()).toMatchSnapshot(); }); @@ -23,7 +23,7 @@ describe('LicenseStatus component', () => { { license: createMockLicense('platinum', 0), }, - LicenseStatus + LicensePageHeader ); expect(rendered.html()).toMatchSnapshot(); }); diff --git a/x-pack/plugins/license_management/kibana.json b/x-pack/plugins/license_management/kibana.json index 1f925a453898e2..be2e21c7eb41e0 100644 --- a/x-pack/plugins/license_management/kibana.json +++ b/x-pack/plugins/license_management/kibana.json @@ -9,6 +9,7 @@ "extraPublicDirs": ["common/constants"], "requiredBundles": [ "telemetryManagementSection", + "esUiShared", "kibanaReact" ] } diff --git a/x-pack/plugins/license_management/public/application/app.js b/x-pack/plugins/license_management/public/application/app.js index 3bfa22dd729217..4b5a6144dbdc9e 100644 --- a/x-pack/plugins/license_management/public/application/app.js +++ b/x-pack/plugins/license_management/public/application/app.js @@ -10,7 +10,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { LicenseDashboard, UploadLicense } from './sections'; import { Switch, Route } from 'react-router-dom'; import { APP_PERMISSION } from '../../common/constants'; -import { EuiPageBody, EuiEmptyPrompt, EuiText, EuiLoadingSpinner, EuiCallOut } from '@elastic/eui'; +import { SectionLoading } from '../shared_imports'; +import { EuiPageContent, EuiPageBody, EuiEmptyPrompt } from '@elastic/eui'; export class App extends Component { componentDidMount() { @@ -23,52 +24,50 @@ export class App extends Component { if (permissionsLoading) { return ( - } - body={ - - - - } - data-test-subj="sectionLoading" - /> + + + + + ); } if (permissionsError) { + const error = permissionsError?.data?.message; + return ( - - } - color="danger" - iconType="alert" - > - {permissionsError.data && permissionsError.data.message ? ( -
{permissionsError.data.message}
- ) : null} -
+ + + + + } + body={error ?

{error}

: null} + /> +
); } if (!hasPermission) { return ( - + +

-

+ } body={

@@ -82,7 +81,7 @@ export class App extends Component {

} /> -
+ ); } diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js index 4120b2280a7a63..90de14b167e520 100644 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js @@ -18,6 +18,7 @@ export const AddLicense = ({ uploadPath = `/upload_license` }) => { return ( {} }) => { useEffect(() => { @@ -19,17 +20,19 @@ export const LicenseDashboard = ({ setBreadcrumb, telemetry } = { setBreadcrumb: }); return ( -
- - - - - - - - - - -
+ <> + + + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/index.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/index.js similarity index 80% rename from x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/index.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/index.js index efd4da2770db47..303e30040ab509 100644 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/index.js +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/index.js @@ -5,4 +5,4 @@ * 2.0. */ -export { LicenseStatus } from './license_status.container'; +export { LicensePageHeader } from './license_page_header'; diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/license_page_header.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/license_page_header.js new file mode 100644 index 00000000000000..df41d46ac57899 --- /dev/null +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_page_header/license_page_header.js @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useSelector } from 'react-redux'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiPageHeader, EuiSpacer } from '@elastic/eui'; + +import { getLicenseState } from '../../../store/reducers/license_management'; + +export const ActiveLicensePageHeader = ({ license, ...props }) => { + return ( + + + + } + description={ + + {license.expirationDate ? ( + {license.expirationDate}, + }} + /> + ) : ( + + )} + + } + /> + ); +}; + +export const ExpiredLicensePageHeader = ({ license, ...props }) => { + return ( + + + + } + description={ + + {license.expirationDate}, + }} + /> + + } + /> + ); +}; + +export const LicensePageHeader = () => { + const license = useSelector(getLicenseState); + + return ( + <> + {license.isExpired ? ( + + ) : ( + + )} + + + ); +}; diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.container.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.container.js deleted file mode 100644 index 01577e79fd6ec4..00000000000000 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.container.js +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { LicenseStatus as PresentationComponent } from './license_status'; -import { connect } from 'react-redux'; -import { - getLicense, - getExpirationDateFormatted, - isExpired, -} from '../../../store/reducers/license_management'; -import { i18n } from '@kbn/i18n'; - -const mapStateToProps = (state) => { - const { isActive, type } = getLicense(state); - return { - status: isActive - ? i18n.translate('xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusText', { - defaultMessage: 'Active', - }) - : i18n.translate( - 'xpack.licenseMgmt.licenseDashboard.licenseStatus.inactiveLicenseStatusText', - { - defaultMessage: 'Inactive', - } - ), - type, - isExpired: isExpired(state), - expiryDate: getExpirationDateFormatted(state), - }; -}; - -export const LicenseStatus = connect(mapStateToProps)(PresentationComponent); diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.js deleted file mode 100644 index 5f7e59bf1ceba3..00000000000000 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.js +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Fragment } from 'react'; - -import { - EuiIcon, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiTitle, - EuiSpacer, - EuiTextAlign, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export class LicenseStatus extends React.PureComponent { - render() { - const { isExpired, status, type, expiryDate } = this.props; - const typeTitleCase = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase(); - let icon; - let title; - let message; - if (isExpired) { - icon = ; - message = ( - - {expiryDate}, - }} - /> - - ); - title = ( - - ); - } else { - icon = ; - message = expiryDate ? ( - - {expiryDate}, - }} - /> - - ) : ( - - - - ); - title = ( - - ); - } - return ( - - - {icon} - - -

{title}

-
-
-
- - - - {message} -
- ); - } -} diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js index 8c694cf27765a3..e578c372b9c9f4 100644 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js @@ -37,6 +37,7 @@ export const RequestTrialExtension = ({ shouldShowRequestTrialExtension }) => { return ( {this.acknowledgeModal()} { {this.acknowledgeModal(dependencies!.docLinks)} - - - -

- -

-
+ + + +

+ +

+
- + - {this.acknowledgeModal()} + {this.acknowledgeModal()} - -

- -

-

- {currentLicenseType.toUpperCase()}, - }} - /> -

-
- - - - - - - } - onChange={this.handleFile} + +

+ +

+

+ {currentLicenseType.toUpperCase()}, + }} + /> +

+
+ + + + + + + } + onChange={this.handleFile} + /> + + + + + {shouldShowTelemetryOptIn(telemetry) && ( + + )} + + + + + + + + + + {applying ? ( + -
-
-
- - {shouldShowTelemetryOptIn(telemetry) && ( - - )} - - - - + ) : ( - - - - - {applying ? ( - - ) : ( - - )} - - - -
-
-
- + )} + +
+ + +
+ ); } } diff --git a/x-pack/plugins/license_management/public/application/store/reducers/license_management.js b/x-pack/plugins/license_management/public/application/store/reducers/license_management.js index 20e31cf89da728..1a985cd8ee623e 100644 --- a/x-pack/plugins/license_management/public/application/store/reducers/license_management.js +++ b/x-pack/plugins/license_management/public/application/store/reducers/license_management.js @@ -6,6 +6,10 @@ */ import { combineReducers } from 'redux'; +import { i18n } from '@kbn/i18n'; +import { capitalize } from 'lodash'; +import { createSelector } from 'reselect'; + import { license } from './license'; import { uploadStatus } from './upload_status'; import { startBasicStatus } from './start_basic_license_status'; @@ -135,3 +139,31 @@ export const startBasicLicenseNeedsAcknowledgement = (state) => { export const getStartBasicMessages = (state) => { return state.startBasicStatus.messages; }; + +export const getLicenseState = createSelector( + getLicense, + getExpirationDateFormatted, + isExpired, + (license, expirationDate, isExpired) => { + const { isActive, type } = license; + + return { + type: capitalize(type), + isExpired, + expirationDate, + status: isActive + ? i18n.translate( + 'xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusText', + { + defaultMessage: 'active', + } + ) + : i18n.translate( + 'xpack.licenseMgmt.licenseDashboard.licenseStatus.inactiveLicenseStatusText', + { + defaultMessage: 'inactive', + } + ), + }; + } +); diff --git a/x-pack/plugins/license_management/public/shared_imports.ts b/x-pack/plugins/license_management/public/shared_imports.ts new file mode 100644 index 00000000000000..695432684a660e --- /dev/null +++ b/x-pack/plugins/license_management/public/shared_imports.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 { SectionLoading } from '../../../../src/plugins/es_ui_shared/public/'; diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts index ec46038c397e5c..212db40f3168cc 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts @@ -1697,9 +1697,9 @@ describe('Exception builder helpers', () => { namespaceType: 'single', ruleName: 'rule name', }); - const exceptions = filterExceptionItems([{ ...rest, meta }]); + const exceptions = filterExceptionItems([{ ...rest, entries: [getEntryMatchMock()], meta }]); - expect(exceptions).toEqual([{ ...rest, entries: [], meta: undefined }]); + expect(exceptions).toEqual([{ ...rest, entries: [getEntryMatchMock()], meta: undefined }]); }); }); diff --git a/x-pack/plugins/monitoring/public/alerts/configuration.tsx b/x-pack/plugins/monitoring/public/alerts/configuration.tsx index 5416095671d718..7825fe8e206174 100644 --- a/x-pack/plugins/monitoring/public/alerts/configuration.tsx +++ b/x-pack/plugins/monitoring/public/alerts/configuration.tsx @@ -32,7 +32,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { } catch (err) { Legacy.shims.toastNotifications.addDanger({ title: i18n.translate('xpack.monitoring.alerts.panel.disableAlert.errorTitle', { - defaultMessage: `Unable to disable alert`, + defaultMessage: `Unable to disable rule`, }), text: err.message, }); @@ -46,7 +46,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { } catch (err) { Legacy.shims.toastNotifications.addDanger({ title: i18n.translate('xpack.monitoring.alerts.panel.enableAlert.errorTitle', { - defaultMessage: `Unable to enable alert`, + defaultMessage: `Unable to enable rule`, }), text: err.message, }); @@ -60,7 +60,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { } catch (err) { Legacy.shims.toastNotifications.addDanger({ title: i18n.translate('xpack.monitoring.alerts.panel.muteAlert.errorTitle', { - defaultMessage: `Unable to mute alert`, + defaultMessage: `Unable to mute rule`, }), text: err.message, }); @@ -74,7 +74,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { } catch (err) { Legacy.shims.toastNotifications.addDanger({ title: i18n.translate('xpack.monitoring.alerts.panel.ummuteAlert.errorTitle', { - defaultMessage: `Unable to unmute alert`, + defaultMessage: `Unable to unmute rule`, }), text: err.message, }); @@ -112,7 +112,7 @@ export const AlertConfiguration: React.FC = (props: Props) => { }} > {i18n.translate('xpack.monitoring.alerts.panel.editAlert', { - defaultMessage: `Edit alert`, + defaultMessage: `Edit rule`, })} diff --git a/x-pack/plugins/observability/public/pages/overview/empty_section.ts b/x-pack/plugins/observability/public/pages/overview/empty_section.ts index 40b1157b29e35c..2747b2ecdebc99 100644 --- a/x-pack/plugins/observability/public/pages/overview/empty_section.ts +++ b/x-pack/plugins/observability/public/pages/overview/empty_section.ts @@ -97,7 +97,7 @@ export const getEmptySections = ({ core }: { core: CoreStart }): ISection[] => { 'Are 503 errors stacking up? Are services responding? Is CPU and RAM utilization jumping? See warnings as they happen—not as part of the post-mortem.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.alert.link', { - defaultMessage: 'Create alert', + defaultMessage: 'Create rule', }), href: core.http.basePath.prepend( '/app/management/insightsAndAlerting/triggersActions/alerts' diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/attach_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/attach_timeline.spec.ts index 7f0016e39ff885..3f3209b52120eb 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/attach_timeline.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/attach_timeline.spec.ts @@ -19,8 +19,7 @@ import { createTimeline } from '../../tasks/api_calls/timelines'; import { cleanKibana } from '../../tasks/common'; import { createCase } from '../../tasks/api_calls/cases'; -// TODO: enable once attach timeline to cases is re-enabled -describe.skip('attach timeline to case', () => { +describe('attach timeline to case', () => { context('without cases created', () => { beforeEach(() => { cleanKibana(); diff --git a/x-pack/plugins/security_solution/cypress/integration/overview/overview.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview/overview.spec.ts index dc5b247e3ec430..78ee3fdcdcdd50 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview/overview.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview/overview.spec.ts @@ -15,6 +15,8 @@ import { OVERVIEW_URL } from '../../urls/navigation'; import overviewFixture from '../../fixtures/overview_search_strategy.json'; import emptyInstance from '../../fixtures/empty_instance.json'; import { cleanKibana } from '../../tasks/common'; +import { createTimeline, favoriteTimeline } from '../../tasks/api_calls/timelines'; +import { timeline } from '../../objects/timeline'; describe('Overview Page', () => { before(() => { @@ -48,4 +50,21 @@ describe('Overview Page', () => { cy.get(OVERVIEW_EMPTY_PAGE).should('be.visible'); }); }); + + describe('Favorite Timelines', () => { + it('should appear on overview page', () => { + createTimeline(timeline) + .then((response) => response.body.data.persistTimeline.timeline.savedObjectId) + .then((timelineId: string) => { + favoriteTimeline({ timelineId, timelineType: 'default' }).then(() => { + cy.stubSearchStrategyApi(overviewFixture, 'overviewNetwork'); + loginAndWaitForPage(OVERVIEW_URL); + cy.get('[data-test-subj="overview-recent-timelines"]').should( + 'contain', + timeline.title + ); + }); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_templates/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_templates/creation.spec.ts index a600b5edfd632e..e2c1d7eef38c38 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_templates/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_templates/creation.spec.ts @@ -16,6 +16,7 @@ import { NOTES_TEXT_AREA, PIN_EVENT, TIMELINE_DESCRIPTION, + TIMELINE_FLYOUT_WRAPPER, TIMELINE_QUERY, TIMELINE_TITLE, } from '../../screens/timeline'; @@ -25,34 +26,38 @@ import { TIMELINES_NOTES_COUNT, TIMELINES_FAVORITE, } from '../../screens/timelines'; +import { createTimeline } from '../../tasks/api_calls/timelines'; import { cleanKibana } from '../../tasks/common'; -import { loginAndWaitForPage } from '../../tasks/login'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { openTimelineUsingToggle } from '../../tasks/security_main'; import { addDescriptionToTimeline, addFilter, addNameToTimeline, addNotesToTimeline, + clickingOnCreateTemplateFromTimelineBtn, closeTimeline, createNewTimelineTemplate, + expandEventAction, markAsFavorite, openTimelineTemplateFromSettings, populateTimeline, waitForTimelineChanges, } from '../../tasks/timeline'; -import { openTimeline } from '../../tasks/timelines'; +import { openTimeline, waitForTimelinesPanelToBeLoaded } from '../../tasks/timelines'; -import { OVERVIEW_URL } from '../../urls/navigation'; +import { TIMELINES_URL } from '../../urls/navigation'; describe('Timeline Templates', () => { beforeEach(() => { cleanKibana(); + loginAndWaitForPageWithoutDateRange(TIMELINES_URL); + cy.intercept('PATCH', '/api/timeline').as('timeline'); }); it('Creates a timeline template', async () => { - loginAndWaitForPage(OVERVIEW_URL); openTimelineUsingToggle(); createNewTimelineTemplate(); populateTimeline(); @@ -97,4 +102,22 @@ describe('Timeline Templates', () => { cy.get(NOTES).should('have.text', timeline.notes); }); }); + + it('Create template from timeline', () => { + waitForTimelinesPanelToBeLoaded(); + + createTimeline(timeline).then(() => { + expandEventAction(); + clickingOnCreateTemplateFromTimelineBtn(); + cy.wait('@timeline', { timeout: 100000 }).then(({ request }) => { + expect(request.body.timeline).to.haveOwnProperty('templateTimelineId'); + expect(request.body.timeline).to.haveOwnProperty('description', timeline.description); + expect(request.body.timeline.kqlQuery.filterQuery.kuery).to.haveOwnProperty( + 'expression', + timeline.query + ); + cy.get(TIMELINE_FLYOUT_WRAPPER).should('have.css', 'visibility', 'visible'); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts index b08bae26bf7edf..8a90b67682cb2d 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts @@ -8,32 +8,37 @@ import { timeline } from '../../objects/timeline'; import { - FAVORITE_TIMELINE, LOCKED_ICON, NOTES_TEXT, PIN_EVENT, + SERVER_SIDE_EVENT_COUNT, TIMELINE_FILTER, + TIMELINE_FLYOUT_WRAPPER, TIMELINE_PANEL, + TIMELINE_TAB_CONTENT_EQL, } from '../../screens/timeline'; +import { createTimelineTemplate } from '../../tasks/api_calls/timelines'; import { cleanKibana } from '../../tasks/common'; -import { loginAndWaitForPage } from '../../tasks/login'; +import { loginAndWaitForPage, loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { openTimelineUsingToggle } from '../../tasks/security_main'; import { + addEqlToTimeline, addFilter, addNameAndDescriptionToTimeline, addNotesToTimeline, + clickingOnCreateTimelineFormTemplateBtn, closeTimeline, createNewTimeline, + expandEventAction, goToQueryTab, - markAsFavorite, pinFirstEvent, populateTimeline, - waitForTimelineChanges, } from '../../tasks/timeline'; -import { OVERVIEW_URL } from '../../urls/navigation'; +import { OVERVIEW_URL, TIMELINE_TEMPLATES_URL } from '../../urls/navigation'; +import { waitForTimelinesPanelToBeLoaded } from '../../tasks/timelines'; describe('Timelines', (): void => { before(() => { @@ -88,10 +93,44 @@ describe('Timelines', (): void => { cy.get(NOTES_TEXT).should('have.text', timeline.notes); }); - it('can be marked as favorite', () => { - markAsFavorite(); - waitForTimelineChanges(); - cy.get(FAVORITE_TIMELINE).should('have.text', 'Remove from favorites'); + it('should update timeline after adding eql', () => { + cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); + const eql = 'any where process.name == "which"'; + addEqlToTimeline(eql); + + cy.wait('@updateTimeline', { timeout: 10000 }).its('response.statusCode').should('eq', 200); + + cy.get(`${TIMELINE_TAB_CONTENT_EQL} ${SERVER_SIDE_EVENT_COUNT}`) + .invoke('text') + .then(parseInt) + .should('be.gt', 0); + }); + }); +}); + +describe('Create a timeline from a template', () => { + before(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(TIMELINE_TEMPLATES_URL); + waitForTimelinesPanelToBeLoaded(); + }); + + it('Should have the same query and open the timeline modal', () => { + createTimelineTemplate(timeline).then(() => { + expandEventAction(); + cy.intercept('/api/timeline').as('timeline'); + + clickingOnCreateTimelineFormTemplateBtn(); + cy.wait('@timeline', { timeout: 100000 }).then(({ request }) => { + if (request.body && request.body.timeline) { + expect(request.body.timeline).to.haveOwnProperty('description', timeline.description); + expect(request.body.timeline.kqlQuery.filterQuery.kuery).to.haveOwnProperty( + 'expression', + timeline.query + ); + cy.get(TIMELINE_FLYOUT_WRAPPER).should('have.css', 'visibility', 'visible'); + } + }); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts index c7ec17d027e800..38c6f41f1049c2 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/flyout_button.spec.ts @@ -61,8 +61,10 @@ describe('timeline flyout button', () => { it('the `(+)` button popover menu owns focus', () => { cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').click({ force: true }); - cy.get(CREATE_NEW_TIMELINE).should('have.focus'); - cy.get('body').type('{esc}'); + cy.get(`${CREATE_NEW_TIMELINE}`) + .pipe(($el) => $el.trigger('focus')) + .should('have.focus'); + cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').type('{esc}'); cy.get(CREATE_NEW_TIMELINE).should('not.be.visible'); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/full_screen.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/full_screen.spec.ts new file mode 100644 index 00000000000000..9cd3b22fc2bb49 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/full_screen.spec.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TIMELINE_HEADER, TIMELINE_TABS } from '../../screens/timeline'; +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; +import { + openTimelineUsingToggle, + enterFullScreenMode, + exitFullScreenMode, +} from '../../tasks/security_main'; +import { populateTimeline } from '../../tasks/timeline'; + +import { HOSTS_URL } from '../../urls/navigation'; + +describe('Toggle full screen', () => { + before(() => { + cleanKibana(); + loginAndWaitForPage(HOSTS_URL); + openTimelineUsingToggle(); + populateTimeline(); + }); + + it('Should hide timeline header and tab list area', () => { + enterFullScreenMode(); + + cy.get(TIMELINE_TABS).should('not.exist'); + cy.get(TIMELINE_HEADER).should('not.be.visible'); + }); + + it('Should show timeline header and tab list area', () => { + exitFullScreenMode(); + cy.get(TIMELINE_TABS).should('exist'); + cy.get(TIMELINE_HEADER).should('be.visible'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/notes_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/notes_tab.spec.ts index 2505930f72f828..24309b8fda0849 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/notes_tab.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/notes_tab.spec.ts @@ -7,7 +7,13 @@ import { timelineNonValidQuery } from '../../objects/timeline'; -import { NOTES_TEXT, NOTES_TEXT_AREA } from '../../screens/timeline'; +import { + NOTES_AUTHOR, + NOTES_CODE_BLOCK, + NOTES_LINK, + NOTES_TEXT, + NOTES_TEXT_AREA, +} from '../../screens/timeline'; import { createTimeline } from '../../tasks/api_calls/timelines'; import { cleanKibana } from '../../tasks/common'; @@ -16,6 +22,7 @@ import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { addNotesToTimeline, closeTimeline, + goToNotesTab, openTimelineById, refreshTimelinesUntilTimeLinePresent, } from '../../tasks/timeline'; @@ -23,8 +30,11 @@ import { waitForTimelinesPanelToBeLoaded } from '../../tasks/timelines'; import { TIMELINES_URL } from '../../urls/navigation'; +const text = 'elastic'; +const link = 'https://www.elastic.co/'; + describe('Timeline notes tab', () => { - before(() => { + beforeEach(() => { cleanKibana(); loginAndWaitForPageWithoutDateRange(TIMELINES_URL); waitForTimelinesPanelToBeLoaded(); @@ -37,19 +47,62 @@ describe('Timeline notes tab', () => { // request responses and indeterminism since on clicks to activates URL's. .then(() => cy.wait(1000)) .then(() => openTimelineById(timelineId)) - .then(() => addNotesToTimeline(timelineNonValidQuery.notes)) + .then(() => goToNotesTab()) ); }); after(() => { closeTimeline(); }); + it('should render mockdown', () => { + cy.intercept('/api/note').as(`updateNote`); + addNotesToTimeline(timelineNonValidQuery.notes); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(NOTES_TEXT_AREA).should('exist'); + }); it('should contain notes', () => { - cy.get(NOTES_TEXT).should('have.text', timelineNonValidQuery.notes); + cy.intercept('/api/note').as(`updateNote`); + addNotesToTimeline(timelineNonValidQuery.notes); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(NOTES_TEXT).first().should('have.text', timelineNonValidQuery.notes); }); - it('should render mockdown', () => { - cy.get(NOTES_TEXT_AREA).should('exist'); + it('should be able to render font in bold', () => { + cy.intercept('/api/note').as(`updateNote`); + addNotesToTimeline(`**bold**`); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(`${NOTES_TEXT} strong`).last().should('have.text', `bold`); + }); + + it('should be able to render font in italics', () => { + cy.intercept('/api/note').as(`updateNote`); + addNotesToTimeline(`_italics_`); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(`${NOTES_TEXT} em`).last().should('have.text', `italics`); + }); + + it('should be able to render code blocks', () => { + cy.intercept('/api/note').as(`updateNote`); + addNotesToTimeline(`\`code\``); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(NOTES_CODE_BLOCK).should('exist'); + }); + + it('should render the right author', () => { + cy.intercept('/api/note').as(`updateNote`); + addNotesToTimeline(timelineNonValidQuery.notes); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(NOTES_AUTHOR).first().should('have.text', text); + }); + + it('should be able to render a link', () => { + cy.intercept('/api/note').as(`updateNote`); + cy.intercept(link).as(`link`); + addNotesToTimeline(`[${text}](${link})`); + cy.wait('@updateNote').its('response.statusCode').should('eq', 200); + cy.get(NOTES_LINK).last().should('have.text', `${text}(opens in a new tab or window)`); + cy.get(NOTES_LINK).last().click(); + cy.wait('@link').its('response.statusCode').should('eq', 200); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts new file mode 100644 index 00000000000000..568fb90568fb33 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/pagination.spec.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + TIMELINE_EVENT, + TIMELINE_EVENTS_COUNT_NEXT_PAGE, + TIMELINE_EVENTS_COUNT_PER_PAGE, + TIMELINE_EVENTS_COUNT_PER_PAGE_BTN, + TIMELINE_EVENTS_COUNT_PER_PAGE_OPTION, + TIMELINE_EVENTS_COUNT_PREV_PAGE, +} from '../../screens/timeline'; +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; +import { openTimelineUsingToggle } from '../../tasks/security_main'; +import { populateTimeline } from '../../tasks/timeline'; + +import { HOSTS_URL } from '../../urls/navigation'; + +const defaultPageSize = 25; +describe('Pagination', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPage(HOSTS_URL); + openTimelineUsingToggle(); + populateTimeline(); + }); + + it(`should have ${defaultPageSize} events in the page by default`, () => { + cy.get(TIMELINE_EVENT).should('have.length', defaultPageSize); + }); + + it(`should select ${defaultPageSize} items per page by default`, () => { + cy.get(TIMELINE_EVENTS_COUNT_PER_PAGE).should('contain.text', defaultPageSize); + }); + + it('should be able to change items count per page with the dropdown', () => { + const itemsPerPage = 100; + cy.intercept('POST', '/internal/bsearch').as('refetch'); + + cy.get(TIMELINE_EVENTS_COUNT_PER_PAGE_BTN).first().click(); + cy.get(TIMELINE_EVENTS_COUNT_PER_PAGE_OPTION(itemsPerPage)).click(); + cy.wait('@refetch').its('response.statusCode').should('eq', 200); + cy.get(TIMELINE_EVENTS_COUNT_PER_PAGE).should('contain.text', itemsPerPage); + }); + + it('should be able to go to next / previous page', () => { + cy.intercept('POST', '/internal/bsearch').as('refetch'); + cy.get(TIMELINE_EVENTS_COUNT_NEXT_PAGE).first().click(); + cy.wait('@refetch').its('response.statusCode').should('eq', 200); + + cy.get(TIMELINE_EVENTS_COUNT_PREV_PAGE).first().click(); + cy.wait('@refetch').its('response.statusCode').should('eq', 200); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/query_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/query_tab.spec.ts index 672e930bc50725..f37a66ac048fb1 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/query_tab.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/query_tab.spec.ts @@ -7,7 +7,13 @@ import { timeline } from '../../objects/timeline'; -import { UNLOCKED_ICON, PIN_EVENT, TIMELINE_FILTER, TIMELINE_QUERY } from '../../screens/timeline'; +import { + UNLOCKED_ICON, + PIN_EVENT, + TIMELINE_FILTER, + TIMELINE_QUERY, + NOTE_CARD_CONTENT, +} from '../../screens/timeline'; import { addNoteToTimeline } from '../../tasks/api_calls/notes'; import { createTimeline } from '../../tasks/api_calls/timelines'; @@ -18,6 +24,7 @@ import { addFilter, closeTimeline, openTimelineById, + persistNoteToFirstEvent, pinFirstEvent, refreshTimelinesUntilTimeLinePresent, } from '../../tasks/timeline'; @@ -45,6 +52,7 @@ describe('Timeline query tab', () => { ) .then(() => openTimelineById(timelineId)) .then(() => pinFirstEvent()) + .then(() => persistNoteToFirstEvent('event note')) .then(() => addFilter(timeline.filter)); }); }); @@ -58,6 +66,10 @@ describe('Timeline query tab', () => { cy.get(TIMELINE_QUERY).should('have.text', `${timeline.query}`); }); + it('should be able to add event note', () => { + cy.get(NOTE_CARD_CONTENT).should('contain', 'event note'); + }); + it('should display timeline filter', () => { cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts new file mode 100644 index 00000000000000..ed9a7db4702d02 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + TIMELINE_ROW_RENDERERS_DISABLE_ALL_BTN, + TIMELINE_ROW_RENDERERS_MODAL_CLOSE_BUTTON, + TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX, + TIMELINE_ROW_RENDERERS_SEARCHBOX, + TIMELINE_SHOW_ROW_RENDERERS_GEAR, +} from '../../screens/timeline'; +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; +import { openTimelineUsingToggle } from '../../tasks/security_main'; +import { populateTimeline } from '../../tasks/timeline'; + +import { HOSTS_URL } from '../../urls/navigation'; + +const RowRenderersId = [ + 'alerts', + 'auditd', + 'auditd_file', + 'library', + 'netflow', + 'plain', + 'registry', + 'suricata', + 'system', + 'system_dns', + 'system_endgame_process', + 'system_file', + 'system_fim', + 'system_security_event', + 'system_socket', + 'threat_match', + 'zeek', +]; + +describe('Row renderers', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPage(HOSTS_URL); + openTimelineUsingToggle(); + populateTimeline(); + cy.get(TIMELINE_SHOW_ROW_RENDERERS_GEAR).first().click({ force: true }); + }); + + afterEach(() => { + cy.get(TIMELINE_ROW_RENDERERS_MODAL_CLOSE_BUTTON).click({ force: true }); + }); + + it('Row renderers should be enabled by default', () => { + cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).should('exist'); + cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).should('be.checked'); + }); + + it('Selected renderer can be disabled and enabled', () => { + cy.get(TIMELINE_ROW_RENDERERS_SEARCHBOX).type('flow'); + + cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).first().uncheck(); + cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); + + cy.wait('@updateTimeline').then((interception) => { + expect(interception.request.body.timeline.excludedRowRendererIds).to.contain('netflow'); + }); + + cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).first().check(); + + cy.wait('@updateTimeline').then((interception) => { + expect(interception.request.body.timeline.excludedRowRendererIds).not.to.contain('netflow'); + }); + }); + + it('Selected renderer can be disabled with one click', () => { + cy.get(TIMELINE_ROW_RENDERERS_DISABLE_ALL_BTN).click({ force: true }); + + cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); + cy.wait('@updateTimeline').its('response.statusCode').should('eq', 200); + + cy.wait('@updateTimeline').then((interception) => { + expect(interception.request.body.timeline.excludedRowRendererIds).to.eql(RowRenderersId); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/search_or_filter.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/search_or_filter.spec.ts index 48b00f8afd4eb3..9d019cf23ebb10 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/search_or_filter.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/search_or_filter.spec.ts @@ -5,14 +5,21 @@ * 2.0. */ -import { SERVER_SIDE_EVENT_COUNT } from '../../screens/timeline'; +import { + ADD_FILTER, + SERVER_SIDE_EVENT_COUNT, + TIMELINE_KQLMODE_FILTER, + TIMELINE_KQLMODE_SEARCH, + TIMELINE_SEARCH_OR_FILTER, +} from '../../screens/timeline'; import { cleanKibana } from '../../tasks/common'; -import { loginAndWaitForPage } from '../../tasks/login'; +import { loginAndWaitForPage, loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { openTimelineUsingToggle } from '../../tasks/security_main'; import { executeTimelineKQL } from '../../tasks/timeline'; +import { waitForTimelinesPanelToBeLoaded } from '../../tasks/timelines'; -import { HOSTS_URL } from '../../urls/navigation'; +import { HOSTS_URL, TIMELINES_URL } from '../../urls/navigation'; describe('timeline search or filter KQL bar', () => { beforeEach(() => { @@ -28,3 +35,37 @@ describe('timeline search or filter KQL bar', () => { cy.get(SERVER_SIDE_EVENT_COUNT).should(($count) => expect(+$count.text()).to.be.gt(0)); }); }); + +describe('Update kqlMode for timeline', () => { + before(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(TIMELINES_URL); + waitForTimelinesPanelToBeLoaded(); + openTimelineUsingToggle(); + }); + + beforeEach(() => { + cy.intercept('PATCH', '/api/timeline').as('update'); + cy.get(TIMELINE_SEARCH_OR_FILTER) + .pipe(($el) => $el.trigger('click')) + .should('exist'); + }); + + it('should be able to update timeline kqlMode with filter', () => { + cy.get(TIMELINE_KQLMODE_FILTER).click(); + cy.wait('@update').then(({ response }) => { + cy.wrap(response!.statusCode).should('eql', 200); + cy.wrap(response!.body.data.persistTimeline.timeline.kqlMode).should('eql', 'filter'); + cy.get(ADD_FILTER).should('exist'); + }); + }); + + it('should be able to update timeline kqlMode with search', () => { + cy.get(TIMELINE_KQLMODE_SEARCH).click(); + cy.wait('@update').then(({ response }) => { + cy.wrap(response!.statusCode).should('eql', 200); + cy.wrap(response!.body.data.persistTimeline.timeline.kqlMode).should('eql', 'search'); + cy.get(ADD_FILTER).should('not.exist'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/overview.ts b/x-pack/plugins/security_solution/cypress/screens/overview.ts index 1c519b21149a81..ce6c5662ecb9e3 100644 --- a/x-pack/plugins/security_solution/cypress/screens/overview.ts +++ b/x-pack/plugins/security_solution/cypress/screens/overview.ts @@ -145,3 +145,5 @@ export const OVERVIEW_HOST_STATS = '[data-test-subj="overview-hosts-stats"]'; export const OVERVIEW_NETWORK_STATS = '[data-test-subj="overview-network-stats"]'; export const OVERVIEW_EMPTY_PAGE = '[data-test-subj="empty-page"]'; + +export const OVERVIEW_REVENT_TIMELINES = '[data-test-subj="overview-recent-timelines"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/security_header.ts b/x-pack/plugins/security_solution/cypress/screens/security_header.ts index cb8502ef96029e..a3d5b714cdb3f5 100644 --- a/x-pack/plugins/security_solution/cypress/screens/security_header.ts +++ b/x-pack/plugins/security_solution/cypress/screens/security_header.ts @@ -24,3 +24,5 @@ export const OVERVIEW = '[data-test-subj="navigation-overview"]'; export const REFRESH_BUTTON = '[data-test-subj="querySubmitButton"]'; export const TIMELINES = '[data-test-subj="navigation-timelines"]'; + +export const LOADING_INDICATOR = '[data-test-subj="globalLoadingIndicator"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 88e207fcea339b..0a9e5b44feb1f6 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -58,6 +58,10 @@ export const UNLOCKED_ICON = '[data-test-subj="timeline-date-picker-unlock-butto export const NOTES = '[data-test-subj="note-card-body"]'; +export const NOTE_CARD_CONTENT = '[data-test-subj="notes"]'; + +export const EVENT_NOTE = '[data-test-subj="timeline-notes-button-small"]'; + export const NOTE_BY_NOTE_ID = (noteId: string) => `[data-test-subj="note-preview-${noteId}"] .euiMarkdownFormat`; @@ -69,6 +73,12 @@ export const NOTES_TAB_BUTTON = '[data-test-subj="timelineTabs-notes"]'; export const NOTES_TEXT = '.euiMarkdownFormat'; +export const NOTES_CODE_BLOCK = '.euiCodeBlock__code'; + +export const NOTES_AUTHOR = '.euiCommentEvent__headerUsername'; + +export const NOTES_LINK = '[data-test-subj="markdown-link"]'; + export const NOTES_COUNT = '[data-test-subj="timeline-notes-count"]'; export const OPEN_TIMELINE_ICON = '[data-test-subj="open-timeline-button"]'; @@ -110,6 +120,8 @@ export const PINNED_TAB_EVENTS_BODY = '[data-test-subj="pinned-tab-flyout-body"] export const PINNED_TAB_EVENTS_FOOTER = '[data-test-subj="pinned-tab-flyout-footer"]'; +export const QUERY_TAB_BUTTON = '[data-test-subj="timelineTabs-query"]'; + export const SERVER_SIDE_EVENT_COUNT = '[data-test-subj="server-side-event-count"]'; export const STAR_ICON = '[data-test-subj="timeline-favorite-empty-star"]'; @@ -118,6 +130,17 @@ export const TIMELINE_CHANGES_IN_PROGRESS = '[data-test-subj="timeline"] .euiPro export const TIMELINE_COLUMN_SPINNER = '[data-test-subj="timeline-loading-spinner"]'; +export const TIMELINE_COLLAPSED_ITEMS_BTN = '[data-test-subj="euiCollapsedItemActionsButton"]'; + +export const TIMELINE_CREATE_TEMPLATE_FROM_TIMELINE_BTN = + '[data-test-subj="create-template-from-timeline"]'; + +export const TIMELINE_CREATE_TIMELINE_FROM_TEMPLATE_BTN = '[data-test-subj="create-from-template"]'; + +export const TIMELINE_CORRELATION_INPUT = '[data-test-subj="eqlQueryBarTextInput"]'; + +export const TIMELINE_CORRELATION_TAB = '[data-test-subj="timelineTabs-eql"]'; + export const IS_DRAGGING_DATA_PROVIDERS = '.is-dragging'; export const TIMELINE_DATA_PROVIDERS = '[data-test-subj="dataProviders"]'; @@ -143,6 +166,19 @@ export const TIMELINE_DESCRIPTION_INPUT = '[data-test-subj="save-timeline-descri export const TIMELINE_DROPPED_DATA_PROVIDERS = '[data-test-subj="providerContainer"]'; +export const TIMELINE_EVENT = '[data-test-subj="event"]'; + +export const TIMELINE_EVENTS_COUNT_PER_PAGE = '[data-test-subj="local-events-count"]'; + +export const TIMELINE_EVENTS_COUNT_PER_PAGE_BTN = '[data-test-subj="local-events-count-button"]'; + +export const TIMELINE_EVENTS_COUNT_PER_PAGE_OPTION = (itemsPerPage: number) => + `[data-test-subj="items-per-page-option-${itemsPerPage}"]`; + +export const TIMELINE_EVENTS_COUNT_NEXT_PAGE = '[data-test-subj="pagination-button-next"]'; + +export const TIMELINE_EVENTS_COUNT_PREV_PAGE = '[data-test-subj="pagination-button-previous"]'; + export const TIMELINE_FIELDS_BUTTON = '[data-test-subj="timeline"] [data-test-subj="show-field-browser"]'; @@ -164,6 +200,8 @@ export const TIMELINE_FLYOUT_HEADER = '[data-test-subj="query-tab-flyout-header" export const TIMELINE_FLYOUT_BODY = '[data-test-subj="query-tab-flyout-body"]'; +export const TIMELINE_HEADER = '[data-test-subj="timeline-hide-show-container"]'; + export const TIMELINE_INSPECT_BUTTON = `${TIMELINE_FLYOUT} [data-test-subj="inspect-icon-button"]`; export const TIMELINE_PANEL = `[data-test-subj="timeline-flyout-header-panel"]`; @@ -172,6 +210,14 @@ export const TIMELINE_QUERY = '[data-test-subj="timelineQueryInput"]'; export const TIMELINE_SETTINGS_ICON = '[data-test-subj="settings-plus-in-circle"]'; +export const TIMELINE_SEARCH_OR_FILTER = '[data-test-subj="timeline-select-search-or-filter"]'; + +export const TIMELINE_SEARCH_OR_FILTER_CONTENT = '.searchOrFilterPopover'; + +export const TIMELINE_KQLMODE_SEARCH = '[data-test-subj="kqlModePopoverSearch"]'; + +export const TIMELINE_KQLMODE_FILTER = '[data-test-subj="kqlModePopoverFilter"]'; + export const TIMELINE_TITLE = '[data-test-subj="timeline-title"]'; export const TIMELINE_TITLE_INPUT = '[data-test-subj="save-timeline-title"]'; @@ -186,4 +232,33 @@ export const TIMELINE_EDIT_MODAL_OPEN_BUTTON = '[data-test-subj="save-timeline-b export const TIMELINE_EDIT_MODAL_SAVE_BUTTON = '[data-test-subj="save-button"]'; -export const QUERY_TAB_BUTTON = '[data-test-subj="timelineTabs-query"]'; +export const TIMELINE_EXIT_FULL_SCREEN_BUTTON = '[data-test-subj="exit-full-screen"]'; + +export const TIMELINE_FLYOUT_WRAPPER = '[data-test-subj="flyout-pane-wrapper"]'; + +export const TIMELINE_FULL_SCREEN_BUTTON = '[data-test-subj="full-screen-active"]'; + +export const TIMELINE_ROW_RENDERERS_MODAL = '[data-test-subj="row-renderers-modal"]'; + +export const TIMELINE_ROW_RENDERERS_DISABLE_ALL_BTN = `[data-test-subj="disable-all"]`; + +export const TIMELINE_ROW_RENDERERS_ENABLE_ALL_BTN = `button[data-test-subj="enable-alll"]`; + +export const TIMELINE_ROW_RENDERERS_MODAL_CLOSE_BUTTON = `${TIMELINE_ROW_RENDERERS_MODAL} .euiModal__closeIcon`; + +export const TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX = `${TIMELINE_ROW_RENDERERS_MODAL} .euiCheckbox__input`; + +export const TIMELINE_ROW_RENDERERS_SEARCHBOX = `${TIMELINE_ROW_RENDERERS_MODAL} input[type="search"]`; + +export const TIMELINE_SHOW_ROW_RENDERERS_GEAR = '[data-test-subj="show-row-renderers-gear"]'; + +export const TIMELINE_TABS = '[data-test-subj="timeline"] .euiTabs'; + +export const TIMELINE_TAB_CONTENT_EQL = '[data-test-subj="timeline-tab-content-eql"]'; + +export const TIMELINE_TAB_CONTENT_QUERY = '[data-test-subj="timeline-tab-content-query"]'; + +export const TIMELINE_TAB_CONTENT_PINNED = '[data-test-subj="timeline-tab-content-pinned"]'; + +export const TIMELINE_TAB_CONTENT_GRAPHS_NOTES = + '[data-test-subj="timeline-tab-content-graph-notes"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/timelines.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/timelines.ts index 18359574633e9a..8274d19f77a25a 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/timelines.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/timelines.ts @@ -119,3 +119,26 @@ export const loadPrepackagedTimelineTemplates = () => url: 'api/timeline/_prepackaged', headers: { 'kbn-xsrf': 'cypress-creds' }, }); + +export const favoriteTimeline = ({ + timelineId, + timelineType, + templateTimelineId, + templateTimelineVersion, +}: { + timelineId: string; + timelineType: string; + templateTimelineId?: string; + templateTimelineVersion?: number; +}) => + cy.request({ + method: 'PATCH', + url: 'api/timeline/_favorite', + body: { + timelineId, + timelineType, + templateTimelineId: templateTimelineId || null, + templateTimelineVersion: templateTimelineVersion || null, + }, + headers: { 'kbn-xsrf': 'cypress-creds' }, + }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts index 189ef1e46e4bcc..01651b7b943d00 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts @@ -11,6 +11,7 @@ import { TIMELINE_TOGGLE_BUTTON, TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON, } from '../screens/security_main'; +import { TIMELINE_EXIT_FULL_SCREEN_BUTTON, TIMELINE_FULL_SCREEN_BUTTON } from '../screens/timeline'; export const openTimelineUsingToggle = () => { cy.get(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).click(); @@ -30,3 +31,11 @@ export const openTimelineIfClosed = () => openTimelineUsingToggle(); } }); + +export const enterFullScreenMode = () => { + cy.get(TIMELINE_FULL_SCREEN_BUTTON).first().click({ force: true }); +}; + +export const exitFullScreenMode = () => { + cy.get(TIMELINE_EXIT_FULL_SCREEN_BUTTON).first().click({ force: true }); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 587e4ec45b8c7a..af7a7bb5d4c710 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -8,6 +8,7 @@ import { Timeline, TimelineFilter } from '../objects/timeline'; import { ALL_CASES_CREATE_NEW_CASE_TABLE_BTN } from '../screens/all_cases'; +import { LOADING_INDICATOR } from '../screens/security_header'; import { ADD_FILTER, @@ -56,6 +57,13 @@ import { TIMELINE_DATA_PROVIDER_OPERATOR, TIMELINE_DATA_PROVIDER_VALUE, SAVE_DATA_PROVIDER_BTN, + EVENT_NOTE, + TIMELINE_CORRELATION_INPUT, + TIMELINE_CORRELATION_TAB, + TIMELINE_CREATE_TIMELINE_FROM_TEMPLATE_BTN, + TIMELINE_CREATE_TEMPLATE_FROM_TIMELINE_BTN, + TIMELINE_COLLAPSED_ITEMS_BTN, + TIMELINE_TAB_CONTENT_EQL, } from '../screens/timeline'; import { REFRESH_BUTTON, TIMELINE } from '../screens/timelines'; @@ -99,6 +107,16 @@ export const goToNotesTab = (): Cypress.Chainable> => { return cy.root().find(NOTES_TAB_BUTTON); }; +export const goToCorrelationTab = () => { + cy.root() + .pipe(($el) => { + $el.find(TIMELINE_CORRELATION_TAB).trigger('click'); + return $el.find(`${TIMELINE_TAB_CONTENT_EQL} ${TIMELINE_CORRELATION_INPUT}`); + }) + .should('be.visible'); + return cy.root().find(TIMELINE_CORRELATION_TAB); +}; + export const getNotePreviewByNoteId = (noteId: string) => { return cy.get(`[data-test-subj="note-preview-${noteId}"]`); }; @@ -127,6 +145,12 @@ export const addNotesToTimeline = (notes: string) => { goToNotesTab(); }; +export const addEqlToTimeline = (eql: string) => { + goToCorrelationTab().then(() => { + cy.get(TIMELINE_CORRELATION_INPUT).type(eql); + }); +}; + export const addFilter = (filter: TimelineFilter): Cypress.Chainable> => { cy.get(ADD_FILTER).click(); cy.get(TIMELINE_FILTER_FIELD).type(`${filter.field}{downarrow}{enter}`); @@ -140,7 +164,8 @@ export const addFilter = (filter: TimelineFilter): Cypress.Chainable> => { cy.get(TIMELINE_ADD_FIELD_BUTTON).click(); - cy.wait(300); + cy.get(TIMELINE_DATA_PROVIDER_VALUE).should('have.focus'); // make sure the focus is ready before start typing + cy.get(TIMELINE_DATA_PROVIDER_FIELD).type(`${filter.field}{downarrow}{enter}`); cy.get(TIMELINE_DATA_PROVIDER_OPERATOR).type(filter.operator); cy.get(COMBO_BOX).contains(filter.operator).click(); @@ -209,8 +234,10 @@ export const expandFirstTimelineEventDetails = () => { cy.get(TOGGLE_TIMELINE_EXPAND_EVENT).first().click({ force: true }); }; -export const markAsFavorite = (): Cypress.Chainable> => { - return cy.get(STAR_ICON).click(); +export const markAsFavorite = () => { + const click = ($el: Cypress.ObjectLike) => cy.wrap($el).click(); + cy.get(STAR_ICON).should('be.visible').pipe(click); + cy.get(LOADING_INDICATOR).should('not.exist'); }; export const openTimelineFieldsBrowser = () => { @@ -249,6 +276,15 @@ export const pinFirstEvent = (): Cypress.Chainable> => { return cy.get(PIN_EVENT).first().click({ force: true }); }; +export const persistNoteToFirstEvent = (notes: string) => { + cy.get(EVENT_NOTE).first().click({ force: true }); + cy.get(NOTES_TEXT_AREA).type(notes); + cy.root().pipe(($el) => { + $el.find(ADD_NOTE_BUTTON).trigger('click'); + return $el.find(NOTES_TAB_BUTTON).find('.euiBadge'); + }); +}; + export const populateTimeline = () => { executeTimelineKQL(hostExistsQuery); cy.get(SERVER_SIDE_EVENT_COUNT).should('not.have.text', '0'); @@ -325,3 +361,15 @@ export const refreshTimelinesUntilTimeLinePresent = ( }) .should('be.visible'); }; + +export const clickingOnCreateTimelineFormTemplateBtn = () => { + cy.get(TIMELINE_CREATE_TIMELINE_FROM_TEMPLATE_BTN).click({ force: true }); +}; + +export const clickingOnCreateTemplateFromTimelineBtn = () => { + cy.get(TIMELINE_CREATE_TEMPLATE_FROM_TIMELINE_BTN).click({ force: true }); +}; + +export const expandEventAction = () => { + cy.get(TIMELINE_COLLAPSED_ITEMS_BTN).first().click(); +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/recent_timelines.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/recent_timelines.tsx index ace78cec1a52fa..ee12c12536af58 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/recent_timelines.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/recent_timelines.tsx @@ -45,7 +45,11 @@ const RecentTimelinesItem = React.memo( const render = useCallback( (showHoverContent) => ( - + diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx index 2602ca3f3cc7cc..ec46985450d891 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx @@ -124,7 +124,7 @@ const FlyoutComponent: React.FC = ({ timelineId, onAppLeave }) => { <> - + diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx index 4dcc799d79111b..04237bfa43dc6c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx @@ -115,7 +115,7 @@ const StatefulRowRenderersBrowserComponent: React.FC {show && ( - + = ({ {i18n.TIMELINE_TEMPLATE} )} - + diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/helpers.tsx index d087b24239a66b..9479c3209ad85f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/helpers.tsx @@ -65,6 +65,7 @@ export const options = [ ), + 'data-test-subj': 'kqlModePopoverFilter', }, { value: modes.search.mode, @@ -84,6 +85,7 @@ export const options = [ ), + 'data-test-subj': 'kqlModePopoverSearch', }, ]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index 76a2ad0960322b..adaa5f98c88c4c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -146,14 +146,20 @@ const ActiveTimelineTab = memo( */ return ( <> - + - + ( /> {timelineType === TimelineType.default && ( - + ( /> )} - + {isGraphOrNotesTabs && getTab(activeTimelineTab)} diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 27108a03f34033..f2d1d3660d78e3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -380,7 +380,6 @@ export class ManifestManager { for (const result of results) { await iterateArtifactsBuildResult(result, async (artifact, policyId) => { const artifactToAdd = baselineManifest.getArtifact(getArtifactId(artifact)) || artifact; - artifactToAdd.compressionAlgorithm = 'none'; if (!internalArtifactCompleteSchema.is(artifactToAdd)) { throw new Error(`Incomplete artifact detected: ${getArtifactId(artifactToAdd)}`); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b6e22dc4a519b0..9520c1ad0d9c1d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5490,13 +5490,9 @@ "xpack.apm.header.badge.readOnly.text": "読み取り専用", "xpack.apm.header.badge.readOnly.tooltip": "を保存できませんでした", "xpack.apm.helpMenu.upgradeAssistantLink": "アップグレードアシスタント", - "xpack.apm.home.alertsMenu.alerts": "アラート", "xpack.apm.home.alertsMenu.createAnomalyAlert": "異常アラートを作成", - "xpack.apm.home.alertsMenu.createThresholdAlert": "しきい値アラートを作成", "xpack.apm.home.alertsMenu.errorCount": "エラー数", - "xpack.apm.home.alertsMenu.transactionDuration": "レイテンシ", "xpack.apm.home.alertsMenu.transactionErrorRate": "トランザクションエラー率", - "xpack.apm.home.alertsMenu.viewActiveAlerts": "アクティブアラートを表示", "xpack.apm.home.serviceMapTabLabel": "サービスマップ", "xpack.apm.instancesLatencyDistributionChartLegend": "インスタンス", "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "前の期間", @@ -10876,20 +10872,10 @@ "xpack.indexLifecycleMgmt.timeline.title": "ポリシー概要", "xpack.indexLifecycleMgmt.timeline.warmPhaseSectionTitle": "ウォームフェーズ", "xpack.indexLifecycleMgmt.warmPhase.dataTier.description": "頻度が低い読み取り専用アクセス用に最適化されたノードにデータを移動します。", - "xpack.infra.alerting.alertDropdownTitle": "アラート", "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "なし (グループなし) ", "xpack.infra.alerting.alertFlyout.groupByLabel": "グループ分けの条件", - "xpack.infra.alerting.alertsButton": "アラート", - "xpack.infra.alerting.createInventoryAlertButton": "インベントリアラートの作成", - "xpack.infra.alerting.createThresholdAlertButton": "しきい値アラートを作成", "xpack.infra.alerting.infrastructureDropdownMenu": "インフラストラクチャー", - "xpack.infra.alerting.infrastructureDropdownTitle": "インフラストラクチャーアラート", - "xpack.infra.alerting.logs.alertsButton": "アラート", - "xpack.infra.alerting.logs.createAlertButton": "アラートの作成", - "xpack.infra.alerting.logs.manageAlerts": "アラートを管理", - "xpack.infra.alerting.manageAlerts": "アラートを管理", "xpack.infra.alerting.metricsDropdownMenu": "メトリック", - "xpack.infra.alerting.metricsDropdownTitle": "メトリックアラート", "xpack.infra.alerts.charts.errorMessage": "問題が発生しました", "xpack.infra.alerts.charts.loadingMessage": "読み込み中", "xpack.infra.alerts.charts.noDataMessage": "グラフデータがありません", @@ -12969,11 +12955,7 @@ "xpack.licenseMgmt.licenseDashboard.addLicense.updateLicenseButtonLabel": "ライセンスを更新", "xpack.licenseMgmt.licenseDashboard.addLicense.updateLicenseTitle": "ライセンスの更新", "xpack.licenseMgmt.licenseDashboard.addLicense.useAvailableLicenseDescription": "すでに新しいライセンスがある場合は、今すぐアップロードしてください。", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusDescription": "ライセンスは{expiryDate}に期限切れになります", "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusText": "アクティブ", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusTitle": "ご使用の{typeTitleCase}ライセンスは{status}です", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.expiredLicenseStatusDescription": "ご使用のライセンスは{expiryDate}に期限切れになりました", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.expiredLicenseStatusTitle": "ご使用の{typeTitleCase}ライセンスは期限切れです", "xpack.licenseMgmt.licenseDashboard.licenseStatus.inactiveLicenseStatusText": "非アクティブ", "xpack.licenseMgmt.licenseDashboard.licenseStatus.permanentActiveLicenseStatusDescription": "ご使用のライセンスには有効期限がありません。", "xpack.licenseMgmt.licenseDashboard.requestTrialExtension.extendTrialButtonLabel": "トライアルを延長", @@ -15906,13 +15888,8 @@ "xpack.monitoring.alerts.nodesChanged.ui.removedFiringMessage": "Elasticsearchノード「{removed}」がこのクラスターから削除されました。", "xpack.monitoring.alerts.nodesChanged.ui.resolvedMessage": "このクラスターのElasticsearchノードは変更されていません。", "xpack.monitoring.alerts.nodesChanged.ui.restartedFiringMessage": "このクラスターでElasticsearchノード「{restarted}」が再起動しました。", - "xpack.monitoring.alerts.panel.disableAlert.errorTitle": "アラートを無効にできません", "xpack.monitoring.alerts.panel.disableTitle": "無効にする", - "xpack.monitoring.alerts.panel.editAlert": "アラートを編集", - "xpack.monitoring.alerts.panel.enableAlert.errorTitle": "アラートを有効にできません", - "xpack.monitoring.alerts.panel.muteAlert.errorTitle": "アラートをミュートできません", "xpack.monitoring.alerts.panel.muteTitle": "ミュート", - "xpack.monitoring.alerts.panel.ummuteAlert.errorTitle": "アラートをミュート解除できません", "xpack.monitoring.alerts.rejection.paramDetails.duration.label": "最後の", "xpack.monitoring.alerts.rejection.paramDetails.threshold.label": "{type} 拒否カウントが超過するときに通知", "xpack.monitoring.alerts.searchThreadPoolRejections.description": "検索スレッドプールの拒否数がしきい値を超過するときにアラートを発行します。", @@ -17239,7 +17216,6 @@ "xpack.observability.alertsTable.viewInAppButtonLabel": "アプリで表示", "xpack.observability.alertsTitle": "アラート", "xpack.observability.emptySection.apps.alert.description": "503 エラーが累積していますか?サービスは応答していますか?CPUとRAMの使用量が跳ね上がっていますか?このような警告を、事後にではなく、発生と同時に把握しましょう。", - "xpack.observability.emptySection.apps.alert.link": "アラートの作成", "xpack.observability.emptySection.apps.alert.title": "アラートが見つかりません。", "xpack.observability.emptySection.apps.apm.description": "分散アーキテクチャ全体でトランザクションを追跡し、サービスの通信をマップ化して、簡単にパフォーマンスボトルネックを特定できます。", "xpack.observability.emptySection.apps.apm.link": "エージェントのインストール", @@ -23530,8 +23506,6 @@ "xpack.uptime.alerts.tls.validAfterExpiringString": "{relativeDate}日以内、{date}に期限切れになります。", "xpack.uptime.alerts.tls.validBeforeExpiredString": "{relativeDate}日前、{date}以降有効です。", "xpack.uptime.alerts.tls.validBeforeExpiringString": "今から{relativeDate}日間、{date}まで無効です。", - "xpack.uptime.alerts.toggleAlertFlyoutButtonText": "アラート", - "xpack.uptime.alertsPopover.toggleButton.ariaLabel": "アラートコンテキストメニューを開く", "xpack.uptime.apmIntegrationAction.description": "このモニターの検索 APM", "xpack.uptime.apmIntegrationAction.text": "APMデータを表示", "xpack.uptime.availabilityLabelText": "{value} %", @@ -23750,15 +23724,11 @@ "xpack.uptime.monitorDetails.title.pingType.tcp": "TCP ping", "xpack.uptime.monitorList.anomalyColumn.label": "レスポンス異常スコア", "xpack.uptime.monitorList.defineConnector.description": "アラートを有効にするには、デフォルトのアラートアクションコネクターを定義してください。", - "xpack.uptime.monitorList.disableDownAlert": "ステータスアラートを無効にする", "xpack.uptime.monitorList.downLineSeries.downLabel": "ダウン", "xpack.uptime.monitorList.drawer.missingLocation": "一部の Heartbeat インスタンスには位置情報が定義されていません。Heartbeat 構成への{link}。", "xpack.uptime.monitorList.drawer.mostRecentRun": "直近のテスト実行", "xpack.uptime.monitorList.drawer.statusRowLocationList": "前回の確認時に\"{status}\"ステータスだった場所のリスト。", "xpack.uptime.monitorList.drawer.url": "Url", - "xpack.uptime.monitorList.enabledAlerts.noAlert": "このモニターではアラートが有効ではありません。", - "xpack.uptime.monitorList.enabledAlerts.title": "有効なアラート", - "xpack.uptime.monitorList.enableDownAlert": "ステータスアラートを有効にする", "xpack.uptime.monitorList.expandDrawerButton.ariaLabel": "ID {id}のモニターの行を展開", "xpack.uptime.monitorList.geoName.helpLinkAnnotation": "場所を追加", "xpack.uptime.monitorList.infraIntegrationAction.container.message": "コンテナーメトリックを表示", @@ -23832,15 +23802,7 @@ "xpack.uptime.monitorStatusBar.timestampFromNowTextAriaLabel": "最終確認からの経過時間", "xpack.uptime.monitorStatusBar.type.ariaLabel": "モニタータイプ", "xpack.uptime.monitorStatusBar.type.label": "型", - "xpack.uptime.navigateToAlertingButton.content": "アラートを管理", - "xpack.uptime.navigateToAlertingUi": "Uptime を離れてアラート管理ページに移動します", "xpack.uptime.notFountPage.homeLinkText": "ホームへ戻る", - "xpack.uptime.openAlertContextPanel.ariaLabel": "アラートコンテキストパネルを開くと、アラートタイプを選択できます", - "xpack.uptime.openAlertContextPanel.label": "アラートの作成", - "xpack.uptime.overview.alerts.disabled.failed": "アラートを無効にできません。", - "xpack.uptime.overview.alerts.disabled.success": "アラートが正常に無効にされました。", - "xpack.uptime.overview.alerts.enabled.failed": "アラートを有効にできません。", - "xpack.uptime.overview.alerts.enabled.success": "アラートが正常に有効にされました。 ", "xpack.uptime.overview.alerts.enabled.success.description": "この監視が停止しているときには、メッセージが {actionConnectors} に送信されます。", "xpack.uptime.overview.filterButton.label": "{title}フィルターのフィルターグループを展開", "xpack.uptime.overview.pageHeader.syntheticsCallout.announcementLink": "お知らせを読む", @@ -24008,10 +23970,6 @@ "xpack.uptime.synthetics.waterfallChart.labels.timings.ssl": "TLS", "xpack.uptime.synthetics.waterfallChart.labels.timings.wait": "待機中 (TTFB) ", "xpack.uptime.title": "アップタイム", - "xpack.uptime.toggleAlertButton.content": "ステータスアラートを監視", - "xpack.uptime.toggleAlertFlyout.ariaLabel": "アラートの追加ポップアップを開く", - "xpack.uptime.toggleTlsAlertButton.ariaLabel": "TLSアラートの追加ポップアップを開く", - "xpack.uptime.toggleTlsAlertButton.content": "TLSアラート", "xpack.uptime.uptimeFeatureCatalogueTitle": "アップタイム", "xpack.urlDrilldown.click.event.key.documentation": "クリックしたデータポイントの後ろのフィールド名。", "xpack.urlDrilldown.click.event.key.title": "クリックしたフィールドの名前。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6ad4e7da082932..f74d27eb8b2142 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5520,13 +5520,9 @@ "xpack.apm.header.badge.readOnly.text": "只读", "xpack.apm.header.badge.readOnly.tooltip": "无法保存", "xpack.apm.helpMenu.upgradeAssistantLink": "升级助手", - "xpack.apm.home.alertsMenu.alerts": "告警", "xpack.apm.home.alertsMenu.createAnomalyAlert": "创建异常告警", - "xpack.apm.home.alertsMenu.createThresholdAlert": "创建阈值告警", "xpack.apm.home.alertsMenu.errorCount": "错误计数", - "xpack.apm.home.alertsMenu.transactionDuration": "延迟", "xpack.apm.home.alertsMenu.transactionErrorRate": "事务错误率", - "xpack.apm.home.alertsMenu.viewActiveAlerts": "查看活动告警", "xpack.apm.home.serviceMapTabLabel": "服务地图", "xpack.apm.instancesLatencyDistributionChartLegend": "实例", "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "上一时段", @@ -11015,20 +11011,10 @@ "xpack.indexLifecycleMgmt.timeline.title": "策略摘要", "xpack.indexLifecycleMgmt.timeline.warmPhaseSectionTitle": "温阶段", "xpack.indexLifecycleMgmt.warmPhase.dataTier.description": "将数据移到针对不太频繁的只读访问优化的节点。", - "xpack.infra.alerting.alertDropdownTitle": "告警", "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "无内容 (未分组) ", "xpack.infra.alerting.alertFlyout.groupByLabel": "分组依据", - "xpack.infra.alerting.alertsButton": "告警", - "xpack.infra.alerting.createInventoryAlertButton": "创建库存告警", - "xpack.infra.alerting.createThresholdAlertButton": "创建阈值告警", "xpack.infra.alerting.infrastructureDropdownMenu": "基础设施", - "xpack.infra.alerting.infrastructureDropdownTitle": "基础架构告警", - "xpack.infra.alerting.logs.alertsButton": "告警", - "xpack.infra.alerting.logs.createAlertButton": "创建告警", - "xpack.infra.alerting.logs.manageAlerts": "管理告警", - "xpack.infra.alerting.manageAlerts": "管理告警", "xpack.infra.alerting.metricsDropdownMenu": "指标", - "xpack.infra.alerting.metricsDropdownTitle": "指标告警", "xpack.infra.alerts.charts.errorMessage": "哇哦,出问题了", "xpack.infra.alerts.charts.loadingMessage": "正在加载", "xpack.infra.alerts.charts.noDataMessage": "没有可用图表数据", @@ -13143,11 +13129,7 @@ "xpack.licenseMgmt.licenseDashboard.addLicense.updateLicenseButtonLabel": "更新许可证", "xpack.licenseMgmt.licenseDashboard.addLicense.updateLicenseTitle": "更新您的许可证", "xpack.licenseMgmt.licenseDashboard.addLicense.useAvailableLicenseDescription": "如果已有新的许可证,请立即上传。", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusDescription": "您的许可证将于 {expiryDate}过期", "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusText": "活动", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.activeLicenseStatusTitle": "您的{typeTitleCase}许可证{status}", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.expiredLicenseStatusDescription": "您的许可证已于 {expiryDate}过期", - "xpack.licenseMgmt.licenseDashboard.licenseStatus.expiredLicenseStatusTitle": "您的{typeTitleCase}许可证已过期", "xpack.licenseMgmt.licenseDashboard.licenseStatus.inactiveLicenseStatusText": "非活动", "xpack.licenseMgmt.licenseDashboard.licenseStatus.permanentActiveLicenseStatusDescription": "您的许可证永不会过期。", "xpack.licenseMgmt.licenseDashboard.requestTrialExtension.extendTrialButtonLabel": "延期试用", @@ -16142,13 +16124,8 @@ "xpack.monitoring.alerts.nodesChanged.ui.removedFiringMessage": "Elasticsearch 节点“{removed}”已从此集群中移除。", "xpack.monitoring.alerts.nodesChanged.ui.resolvedMessage": "此集群的 Elasticsearch 节点中没有更改。", "xpack.monitoring.alerts.nodesChanged.ui.restartedFiringMessage": "此集群中 Elasticsearch 节点“{restarted}”已重新启动。", - "xpack.monitoring.alerts.panel.disableAlert.errorTitle": "无法禁用告警", "xpack.monitoring.alerts.panel.disableTitle": "禁用", - "xpack.monitoring.alerts.panel.editAlert": "编辑告警", - "xpack.monitoring.alerts.panel.enableAlert.errorTitle": "无法启用告警", - "xpack.monitoring.alerts.panel.muteAlert.errorTitle": "无法静音告警", "xpack.monitoring.alerts.panel.muteTitle": "静音", - "xpack.monitoring.alerts.panel.ummuteAlert.errorTitle": "无法取消告警静音", "xpack.monitoring.alerts.rejection.paramDetails.duration.label": "过去", "xpack.monitoring.alerts.rejection.paramDetails.threshold.label": "当 {type} 拒绝计数超过以下阈值时通知:", "xpack.monitoring.alerts.searchThreadPoolRejections.description": "当搜索线程池中的拒绝数目超过阈值时告警。", @@ -17475,7 +17452,6 @@ "xpack.observability.alertsTable.viewInAppButtonLabel": "在应用中查看", "xpack.observability.alertsTitle": "告警", "xpack.observability.emptySection.apps.alert.description": "503 错误是否越来越多?服务是否响应?CPU 和 RAM 利用率是否激增?实时查看警告,而不是事后再进行剖析。", - "xpack.observability.emptySection.apps.alert.link": "创建告警", "xpack.observability.emptySection.apps.alert.title": "未找到告警。", "xpack.observability.emptySection.apps.apm.description": "通过分布式体系结构跟踪事务并映射服务的交互以轻松发现性能瓶颈。", "xpack.observability.emptySection.apps.apm.link": "安装代理", @@ -23896,8 +23872,6 @@ "xpack.uptime.alerts.tls.validAfterExpiringString": "将在{relativeDate} 天后,即 {date}到期。", "xpack.uptime.alerts.tls.validBeforeExpiredString": "自 {relativeDate} 天前,即 {date}开始生效。", "xpack.uptime.alerts.tls.validBeforeExpiringString": "从现在到 {date}的 {relativeDate} 天里无效。", - "xpack.uptime.alerts.toggleAlertFlyoutButtonText": "告警", - "xpack.uptime.alertsPopover.toggleButton.ariaLabel": "打开告警上下文菜单", "xpack.uptime.apmIntegrationAction.description": "在 APM 中搜索此监测", "xpack.uptime.apmIntegrationAction.text": "显示 APM 数据", "xpack.uptime.availabilityLabelText": "{value} %", @@ -24116,15 +24090,11 @@ "xpack.uptime.monitorDetails.title.pingType.tcp": "TCP ping", "xpack.uptime.monitorList.anomalyColumn.label": "响应异常分数", "xpack.uptime.monitorList.defineConnector.description": "要开始启用告警,请在以下位置定义默认告警操作连接器", - "xpack.uptime.monitorList.disableDownAlert": "禁用状态告警", "xpack.uptime.monitorList.downLineSeries.downLabel": "关闭检查", "xpack.uptime.monitorList.drawer.missingLocation": "某些 Heartbeat 实例未定义位置。{link}到您的 Heartbeat 配置。", "xpack.uptime.monitorList.drawer.mostRecentRun": "最新测试运行", "xpack.uptime.monitorList.drawer.statusRowLocationList": "上次检查时状态为“{status}”的位置列表。", "xpack.uptime.monitorList.drawer.url": "URL", - "xpack.uptime.monitorList.enabledAlerts.noAlert": "没有为此监测启用告警。", - "xpack.uptime.monitorList.enabledAlerts.title": "已启用的告警", - "xpack.uptime.monitorList.enableDownAlert": "启用状态告警", "xpack.uptime.monitorList.expandDrawerButton.ariaLabel": "展开 ID {id} 的监测行", "xpack.uptime.monitorList.geoName.helpLinkAnnotation": "添加位置", "xpack.uptime.monitorList.infraIntegrationAction.container.message": "显示容器指标", @@ -24198,15 +24168,7 @@ "xpack.uptime.monitorStatusBar.timestampFromNowTextAriaLabel": "自上次检查以来经过的时间", "xpack.uptime.monitorStatusBar.type.ariaLabel": "监测类型", "xpack.uptime.monitorStatusBar.type.label": "类型", - "xpack.uptime.navigateToAlertingButton.content": "管理告警", - "xpack.uptime.navigateToAlertingUi": "离开 Uptime 并前往“Alerting 管理”页面", "xpack.uptime.notFountPage.homeLinkText": "返回主页", - "xpack.uptime.openAlertContextPanel.ariaLabel": "打开告警上下文面板,以便可以选择告警类型", - "xpack.uptime.openAlertContextPanel.label": "创建告警", - "xpack.uptime.overview.alerts.disabled.failed": "无法禁用告警!", - "xpack.uptime.overview.alerts.disabled.success": "已成功禁用告警!", - "xpack.uptime.overview.alerts.enabled.failed": "无法启用告警!", - "xpack.uptime.overview.alerts.enabled.success": "已成功启用告警 ", "xpack.uptime.overview.alerts.enabled.success.description": "此监测关闭时,将有消息发送到 {actionConnectors}。", "xpack.uptime.overview.filterButton.label": "展开筛选 {title} 的筛选组", "xpack.uptime.overview.pageHeader.syntheticsCallout.announcementLink": "阅读公告", @@ -24374,10 +24336,6 @@ "xpack.uptime.synthetics.waterfallChart.labels.timings.ssl": "TLS", "xpack.uptime.synthetics.waterfallChart.labels.timings.wait": "等待中 (TTFB)", "xpack.uptime.title": "运行时间", - "xpack.uptime.toggleAlertButton.content": "监测状态告警", - "xpack.uptime.toggleAlertFlyout.ariaLabel": "打开添加告警浮出控件", - "xpack.uptime.toggleTlsAlertButton.ariaLabel": "打开 TLS 告警浮出控件", - "xpack.uptime.toggleTlsAlertButton.content": "TLS 告警", "xpack.uptime.uptimeFeatureCatalogueTitle": "运行时间", "xpack.urlDrilldown.click.event.key.documentation": "已点击数据点背后的字段名称。", "xpack.urlDrilldown.click.event.key.title": "已点击字段的名称。", diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx index 89d8f38b1e3b3b..0265588c3fdeb9 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx @@ -14,12 +14,12 @@ describe('ActionMenuContent', () => { it('renders alerts dropdown', async () => { const { getByLabelText, getByText } = render(); - const alertsDropdown = getByLabelText('Open alert context menu'); + const alertsDropdown = getByLabelText('Open alerts and rules context menu'); fireEvent.click(alertsDropdown); await waitFor(() => { - expect(getByText('Create alert')); - expect(getByText('Manage alerts')); + expect(getByText('Create rule')); + expect(getByText('Manage rules')); }); }); diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx index a1b745d07924ef..278958bd1987bb 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/toggle_alert_flyout_button.tsx @@ -67,7 +67,7 @@ export const ToggleAlertFlyoutButtonComponent: React.FC = ({ > ), @@ -114,7 +114,7 @@ export const ToggleAlertFlyoutButtonComponent: React.FC = ({ }, { id: ALERT_CONTEXT_SELECT_TYPE_PANEL_ID, - title: 'create alerts', + title: ToggleFlyoutTranslations.toggleAlertFlyoutButtonLabel, items: selectionItems, }, ]; @@ -134,7 +134,7 @@ export const ToggleAlertFlyoutButtonComponent: React.FC = ({ > } diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts b/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts index 00a00a4664cd87..7cfcdabe5562bc 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts +++ b/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts @@ -283,30 +283,33 @@ export const TlsTranslations = { export const ToggleFlyoutTranslations = { toggleButtonAriaLabel: i18n.translate('xpack.uptime.alertsPopover.toggleButton.ariaLabel', { - defaultMessage: 'Open alert context menu', + defaultMessage: 'Open alerts and rules context menu', }), openAlertContextPanelAriaLabel: i18n.translate('xpack.uptime.openAlertContextPanel.ariaLabel', { - defaultMessage: 'Open the alert context panel so you can choose an alert type', + defaultMessage: 'Open the rule context panel so you can choose a rule type', }), openAlertContextPanelLabel: i18n.translate('xpack.uptime.openAlertContextPanel.label', { - defaultMessage: 'Create alert', + defaultMessage: 'Create rule', }), toggleTlsAriaLabel: i18n.translate('xpack.uptime.toggleTlsAlertButton.ariaLabel', { - defaultMessage: 'Open TLS alert flyout', + defaultMessage: 'Open TLS rule flyout', }), toggleTlsContent: i18n.translate('xpack.uptime.toggleTlsAlertButton.content', { - defaultMessage: 'TLS alert', + defaultMessage: 'TLS rule', }), toggleMonitorStatusAriaLabel: i18n.translate('xpack.uptime.toggleAlertFlyout.ariaLabel', { - defaultMessage: 'Open add alert flyout', + defaultMessage: 'Open add rule flyout', }), toggleMonitorStatusContent: i18n.translate('xpack.uptime.toggleAlertButton.content', { - defaultMessage: 'Monitor status alert', + defaultMessage: 'Monitor status rule', }), navigateToAlertingUIAriaLabel: i18n.translate('xpack.uptime.navigateToAlertingUi', { defaultMessage: 'Leave Uptime and go to Alerting Management page', }), navigateToAlertingButtonContent: i18n.translate('xpack.uptime.navigateToAlertingButton.content', { - defaultMessage: 'Manage alerts', + defaultMessage: 'Manage rules', + }), + toggleAlertFlyoutButtonLabel: i18n.translate('xpack.uptime.alerts.createRulesPanel.title', { + defaultMessage: 'Create rules', }), }; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap index 115dab1095dc11..cfdf7afba4e85e 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap @@ -1303,7 +1303,7 @@ exports[`MonitorList component renders the monitor list 1`] = ` >