diff --git a/.ci/end2end.groovy b/.ci/end2end.groovy index 0cd64dcfd41fd2..ed642f22cfeb4c 100644 --- a/.ci/end2end.groovy +++ b/.ci/end2end.groovy @@ -76,7 +76,7 @@ pipeline { } } steps{ - notifyStatus('Running smoke tests', 'PENDING') + notifyTestStatus('Running smoke tests', 'PENDING') dir("${BASE_DIR}"){ sh "${E2E_DIR}/ci/run-e2e.sh" } @@ -95,10 +95,10 @@ pipeline { } } unsuccessful { - notifyStatus('Test failures', 'FAILURE') + notifyTestStatus('Test failures', 'FAILURE') } success { - notifyStatus('Tests passed', 'SUCCESS') + notifyTestStatus('Tests passed', 'SUCCESS') } } } @@ -113,5 +113,9 @@ pipeline { } def notifyStatus(String description, String status) { - withGithubNotify.notify('end2end-for-apm-ui', description, status, getBlueoceanDisplayURL()) + withGithubNotify.notify('end2end-for-apm-ui', description, status, getBlueoceanTabURL('pipeline')) +} + +def notifyTestStatus(String description, String status) { + withGithubNotify.notify('end2end-for-apm-ui', description, status, getBlueoceanTabURL('tests')) } diff --git a/.eslintrc.js b/.eslintrc.js index 9657719f0f526f..8d5b4525d51ba5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -472,6 +472,7 @@ module.exports = { { files: [ 'test/functional/services/lib/web_element_wrapper/scroll_into_view_if_necessary.js', + 'src/legacy/ui/ui_render/bootstrap/kbn_bundles_loader_source.js', '**/browser_exec_scripts/**/*.js', ], rules: { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 07546fa54ce4f4..00573e04396b49 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -168,15 +168,15 @@ /src/core/public/i18n/ @elastic/kibana-localization /packages/kbn-i18n/ @elastic/kibana-localization -# Pulse -/packages/kbn-analytics/ @elastic/pulse -/src/plugins/kibana_usage_collection/ @elastic/pulse -/src/plugins/newsfeed/ @elastic/pulse -/src/plugins/telemetry/ @elastic/pulse -/src/plugins/telemetry_collection_manager/ @elastic/pulse -/src/plugins/telemetry_management_section/ @elastic/pulse -/src/plugins/usage_collection/ @elastic/pulse -/x-pack/plugins/telemetry_collection_xpack/ @elastic/pulse +# Kibana Telemetry +/packages/kbn-analytics/ @elastic/kibana-telemetry +/src/plugins/kibana_usage_collection/ @elastic/kibana-telemetry +/src/plugins/newsfeed/ @elastic/kibana-telemetry +/src/plugins/telemetry/ @elastic/kibana-telemetry +/src/plugins/telemetry_collection_manager/ @elastic/kibana-telemetry +/src/plugins/telemetry_management_section/ @elastic/kibana-telemetry +/src/plugins/usage_collection/ @elastic/kibana-telemetry +/x-pack/plugins/telemetry_collection_xpack/ @elastic/kibana-telemetry # Kibana Alerting Services /x-pack/plugins/alerts/ @elastic/kibana-alerting-services diff --git a/.gitignore b/.gitignore index c7c80fc48264d1..32377ec0f1ffe8 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ npm-debug.log* # apm plugin /x-pack/plugins/apm/tsconfig.json apm.tsconfig.json + +# release notes script output +report.csv +report.asciidoc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 959c12af904631..a7345f4b2897b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -657,8 +657,8 @@ Distributable packages can be found in `target/` after the build completes. Kibana documentation is written in [asciidoc](http://asciidoc.org/) format in the `docs/` directory. -To build the docs, you must clone the [elastic/docs](https://github.com/elastic/docs) -repo as a sibling of your kibana repo. Follow the instructions in that project's +To build the docs, clone the [elastic/docs](https://github.com/elastic/docs) +repo as a sibling of your Kibana repo. Follow the instructions in that project's README for getting the docs tooling set up. **To build the Kibana docs and open them in your browser:** @@ -676,14 +676,26 @@ node scripts/docs.js --open Part of this process only applies to maintainers, since it requires access to GitHub labels. -Kibana publishes [Release Notes](https://www.elastic.co/guide/en/kibana/current/release-notes.html) for major and minor releases. To generate the Release Notes, the writers run a script against this repo to collect the merged PRs against the release. -To include your PRs in the Release Notes: +Kibana publishes [Release Notes](https://www.elastic.co/guide/en/kibana/current/release-notes.html) for major and minor releases. The Release Notes summarize what the PRs accomplish in language that is meaningful to users. To generate the Release Notes, the team runs a script against this repo to collect the merged PRs against the release. -1. In the title, summarize what the PR accomplishes in language that is meaningful to the user. In general, use present tense (for example, Adds, Fixes) in sentence case. -2. Label the PR with the targeted version (ex: `v7.3.0`). -3. Label the PR with the appropriate GitHub labels: +#### Create the Release Notes text +The text that appears in the Release Notes is pulled directly from your PR title, or a single paragraph of text that you specify in the PR description. + +To use a single paragraph of text, enter `Release note:` or a `## Release note` header in the PR description, followed by your text. For example, refer to this [PR](https://github.com/elastic/kibana/pull/65796) that uses the `## Release note` header. + +When you create the Release Notes text, use the following best practices: +* Use present tense. +* Use sentence case. +* When you create a feature PR, start with `Adds`. +* When you create an enhancement PR, start with `Improves`. +* When you create a bug fix PR, start with `Fixes`. +* When you create a deprecation PR, start with `Deprecates`. + +#### Add your labels +1. Label the PR with the targeted version (ex: `v7.3.0`). +2. Label the PR with the appropriate GitHub labels: * For a new feature or functionality, use `release_note:enhancement`. - * For an external-facing fix, use `release_note:fix`. Exception: docs, build, and test fixes do not go in the Release Notes. Neither fixes for issues that were only on `master` and never have been released. + * For an external-facing fix, use `release_note:fix`. We do not include docs, build, and test fixes in the Release Notes, or unreleased issues that are only on `master`. * For a deprecated feature, use `release_note:deprecation`. * For a breaking change, use `release_note:breaking`. * To **NOT** include your changes in the Release Notes, use `release_note:skip`. @@ -695,7 +707,7 @@ We also produce a blog post that details more important breaking API changes in ## Name the feature with the break (ex: Visualize Loader) -Summary of the change. Anything Under `#Dev Docs` will be used in the blog. +Summary of the change. Anything Under `#Dev Docs` is used in the blog. ``` ## Signing the contributor license agreement diff --git a/NOTICE.txt b/NOTICE.txt index 33c1d535d7df32..946b328b8766c1 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -21,6 +21,11 @@ used. Logarithmic ticks are places at powers of ten and at half those values if there are not to many ticks already (e.g. [1, 5, 10, 50, 100]). For details, see https://github.com/flot/flot/pull/1328 +--- +This module was heavily inspired by the externals plugin that ships with webpack@97d58d31 +MIT License http://www.opensource.org/licenses/mit-license.php +Author Tobias Koppers @sokra + --- This product has relied on ASTExplorer that is licensed under MIT. diff --git a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.extrapublicdirs.md b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.extrapublicdirs.md new file mode 100644 index 00000000000000..c46e60f2ecf6d2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.extrapublicdirs.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [PluginManifest](./kibana-plugin-core-server.pluginmanifest.md) > [extraPublicDirs](./kibana-plugin-core-server.pluginmanifest.extrapublicdirs.md) + +## PluginManifest.extraPublicDirs property + +> Warning: This API is now obsolete. +> +> + +Specifies directory names that can be imported by other ui-plugins built using the same instance of the @kbn/optimizer. A temporary measure we plan to replace with better mechanisms for sharing static code between plugins + +Signature: + +```typescript +readonly extraPublicDirs?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md index fe0ca476bbcb27..5edee51d6c523d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md +++ b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md @@ -21,6 +21,7 @@ Should never be used in code outside of Core but is exported for documentation p | Property | Type | Description | | --- | --- | --- | | [configPath](./kibana-plugin-core-server.pluginmanifest.configpath.md) | ConfigPath | Root [configuration path](./kibana-plugin-core-server.configpath.md) used by the plugin, defaults to "id" in snake\_case format. | +| [extraPublicDirs](./kibana-plugin-core-server.pluginmanifest.extrapublicdirs.md) | string[] | Specifies directory names that can be imported by other ui-plugins built using the same instance of the @kbn/optimizer. A temporary measure we plan to replace with better mechanisms for sharing static code between plugins | | [id](./kibana-plugin-core-server.pluginmanifest.id.md) | PluginName | Identifier of the plugin. Must be a string in camelCase. Part of a plugin public contract. Other plugins leverage it to access plugin API, navigate to the plugin, etc. | | [kibanaVersion](./kibana-plugin-core-server.pluginmanifest.kibanaversion.md) | string | The version of Kibana the plugin is compatible with, defaults to "version". | | [optionalPlugins](./kibana-plugin-core-server.pluginmanifest.optionalplugins.md) | readonly PluginName[] | An optional list of the other plugins that if installed and enabled \*\*may be\*\* leveraged by this plugin for some additional functionality but otherwise are not required for this plugin to work properly. | diff --git a/examples/demo_search/kibana.json b/examples/demo_search/kibana.json index cdf74121ea2db1..f909ca47fcd55b 100644 --- a/examples/demo_search/kibana.json +++ b/examples/demo_search/kibana.json @@ -5,5 +5,6 @@ "server": true, "ui": true, "requiredPlugins": ["data"], - "optionalPlugins": [] + "optionalPlugins": [], + "extraPublicDirs": ["common"] } diff --git a/examples/embeddable_examples/kibana.json b/examples/embeddable_examples/kibana.json index 489f768552b281..b3ee0de096989e 100644 --- a/examples/embeddable_examples/kibana.json +++ b/examples/embeddable_examples/kibana.json @@ -5,5 +5,6 @@ "server": true, "ui": true, "requiredPlugins": ["embeddable"], - "optionalPlugins": [] + "optionalPlugins": [], + "extraPublicDirs": ["public/todo", "public/hello_world"] } diff --git a/examples/url_generators_examples/kibana.json b/examples/url_generators_examples/kibana.json index cdb2127fdd26f5..9658f5c7300aac 100644 --- a/examples/url_generators_examples/kibana.json +++ b/examples/url_generators_examples/kibana.json @@ -5,5 +5,8 @@ "server": false, "ui": true, "requiredPlugins": ["share"], - "optionalPlugins": [] + "optionalPlugins": [], + "extraPublicDirs": [ + "public/url_generator" + ] } diff --git a/package.json b/package.json index 887ffae755e37f..e8b07ae4abba25 100644 --- a/package.json +++ b/package.json @@ -306,6 +306,7 @@ "@kbn/expect": "1.0.0", "@kbn/optimizer": "1.0.0", "@kbn/plugin-generator": "1.0.0", + "@kbn/release-notes": "1.0.0", "@kbn/test": "1.0.0", "@kbn/utility-types": "1.0.0", "@microsoft/api-documenter": "7.7.2", diff --git a/packages/kbn-optimizer/README.md b/packages/kbn-optimizer/README.md index c7f50c6af8dfde..9ff0f563442749 100644 --- a/packages/kbn-optimizer/README.md +++ b/packages/kbn-optimizer/README.md @@ -30,6 +30,18 @@ Bundles built by the the optimizer include a cache file which describes the info When a bundle is determined to be up-to-date a worker is not started for the bundle. If running the optimizer with the `--dev/--watch` flag, then all the files referenced by cached bundles are watched for changes. Once a change is detected in any of the files referenced by the built bundle a worker is started. If a file is changed that is referenced by several bundles then workers will be started for each bundle, combining workers together to respect the worker limit. +## Bundle Refs + +In order to dramatically reduce the size of our bundles, and the time it takes to build them, bundles will "ref" other bundles being built at the same time. When the optimizer starts it creates a list of "refs" that could be had from the list of bundles being built. Each worker uses that list to determine which import statements in a bundle should be replaced with a runtime reference to the output of another bundle. + +At runtime the bundles share a set of entry points via the `__kbnBundles__` global. By default a plugin shares `public` so that other code can use relative imports to access that directory. To expose additional directories they must be listed in the plugin's kibana.json "extraPublicDirs" field. The directories listed there will **also** be exported from the plugins bundle so that any other plugin can import that directory. "common" is commonly in the list of "extraPublicDirs". + +> NOTE: We plan to replace the `extraPublicDirs` functionality soon with better mechanisms for statically sharing code between bundles. + +When a directory is listed in the "extraPublicDirs" it will always be included in the bundle so that other plugins have access to it. The worker building the bundle has no way of knowing whether another plugin is using the directory, so be careful of adding test code or unnecessary directories to that list. + +Any import in a bundle which resolves into another bundles "context" directory, ie `src/plugins/*`, must map explicitly to a "public dir" exported by that plugin. If the resolved import is not in the list of public dirs an error will be thrown and the optimizer will fail to build that bundle until the error is fixed. + ## API To run the optimizer from code, you can import the [`OptimizerConfig`][OptimizerConfig] class and [`runOptimizer`][Optimizer] function. Create an [`OptimizerConfig`][OptimizerConfig] instance by calling it's static `create()` method with some options, then pass it to the [`runOptimizer`][Optimizer] function. `runOptimizer()` returns an observable of update objects, which are summaries of the optimizer state plus an optional `event` property which describes the internal events occuring and may be of use. You can use the [`logOptimizerState()`][LogOptimizerState] helper to write the relevant bits of state to a tooling log or checkout it's implementation to see how the internal events like [`WorkerStdio`][ObserveWorker] and [`WorkerStarted`][ObserveWorker] are used. diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index a372b9e394b9a9..c7bf1dd60985d3 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -45,6 +45,7 @@ "terser-webpack-plugin": "^2.1.2", "tinymath": "1.2.1", "url-loader": "^2.2.0", + "val-loader": "^1.1.1", "watchpack": "^1.6.0", "webpack": "^4.41.5", "webpack-merge": "^4.2.2" diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/index.ts b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/index.ts index 7ddd10f4a388fb..c881a15eac5b53 100644 --- a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/index.ts +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/index.ts @@ -19,6 +19,6 @@ import './legacy/styles.scss'; import './index.scss'; -import { fooLibFn } from '../../foo/public/index'; +import { fooLibFn } from '../../foo/public'; export * from './lib'; export { fooLibFn }; diff --git a/packages/kbn-optimizer/src/common/bundle.test.ts b/packages/kbn-optimizer/src/common/bundle.test.ts index ec78a1bdf020ec..b209bbca25ac4d 100644 --- a/packages/kbn-optimizer/src/common/bundle.test.ts +++ b/packages/kbn-optimizer/src/common/bundle.test.ts @@ -23,7 +23,7 @@ jest.mock('fs'); const SPEC: BundleSpec = { contextDir: '/foo/bar', - entry: 'entry', + publicDirNames: ['public'], id: 'bar', outputDir: '/foo/bar/target', sourceRoot: '/foo', @@ -49,9 +49,11 @@ it('creates cache keys', () => { }, "spec": Object { "contextDir": "/foo/bar", - "entry": "entry", "id": "bar", "outputDir": "/foo/bar/target", + "publicDirNames": Array [ + "public", + ], "sourceRoot": "/foo", "type": "plugin", }, @@ -82,9 +84,11 @@ it('parses bundles from JSON specs', () => { "state": undefined, }, "contextDir": "/foo/bar", - "entry": "entry", "id": "bar", "outputDir": "/foo/bar/target", + "publicDirNames": Array [ + "public", + ], "sourceRoot": "/foo", "type": "plugin", }, diff --git a/packages/kbn-optimizer/src/common/bundle.ts b/packages/kbn-optimizer/src/common/bundle.ts index 9e2ad186ba40c7..80af94c30f8da4 100644 --- a/packages/kbn-optimizer/src/common/bundle.ts +++ b/packages/kbn-optimizer/src/common/bundle.ts @@ -29,8 +29,8 @@ export interface BundleSpec { readonly type: typeof VALID_BUNDLE_TYPES[0]; /** Unique id for this bundle */ readonly id: string; - /** Webpack entry request for this plugin, relative to the contextDir */ - readonly entry: string; + /** directory names relative to the contextDir that can be imported from */ + readonly publicDirNames: string[]; /** Absolute path to the plugin source directory */ readonly contextDir: string; /** Absolute path to the root of the repository */ @@ -44,8 +44,8 @@ export class Bundle { public readonly type: BundleSpec['type']; /** Unique identifier for this bundle */ public readonly id: BundleSpec['id']; - /** Path, relative to `contextDir`, to the entry file for the Webpack bundle */ - public readonly entry: BundleSpec['entry']; + /** directory names relative to the contextDir that can be imported from */ + public readonly publicDirNames: BundleSpec['publicDirNames']; /** * Absolute path to the root of the bundle context (plugin directory) * where the entry is resolved relative to and the default output paths @@ -62,7 +62,7 @@ export class Bundle { constructor(spec: BundleSpec) { this.type = spec.type; this.id = spec.id; - this.entry = spec.entry; + this.publicDirNames = spec.publicDirNames; this.contextDir = spec.contextDir; this.sourceRoot = spec.sourceRoot; this.outputDir = spec.outputDir; @@ -73,8 +73,6 @@ export class Bundle { /** * Calculate the cache key for this bundle based from current * mtime values. - * - * @param mtimes pre-fetched mtimes (ms || undefined) for all referenced files */ createCacheKey(files: string[], mtimes: Map): unknown { return { @@ -94,7 +92,7 @@ export class Bundle { return { type: this.type, id: this.id, - entry: this.entry, + publicDirNames: this.publicDirNames, contextDir: this.contextDir, sourceRoot: this.sourceRoot, outputDir: this.outputDir, @@ -134,9 +132,9 @@ export function parseBundles(json: string) { throw new Error('`bundles[]` must have a string `id` property'); } - const { entry } = spec; - if (!(typeof entry === 'string')) { - throw new Error('`bundles[]` must have a string `entry` property'); + const { publicDirNames } = spec; + if (!Array.isArray(publicDirNames) || !publicDirNames.every((d) => typeof d === 'string')) { + throw new Error('`bundles[]` must have an array of strings `publicDirNames` property'); } const { contextDir } = spec; @@ -157,7 +155,7 @@ export function parseBundles(json: string) { return new Bundle({ type, id, - entry, + publicDirNames, contextDir, sourceRoot, outputDir, diff --git a/packages/kbn-optimizer/src/common/bundle_cache.ts b/packages/kbn-optimizer/src/common/bundle_cache.ts index 1dbc7f1d1b6b0c..5ae3e4c28a2018 100644 --- a/packages/kbn-optimizer/src/common/bundle_cache.ts +++ b/packages/kbn-optimizer/src/common/bundle_cache.ts @@ -25,6 +25,7 @@ export interface State { cacheKey?: unknown; moduleCount?: number; files?: string[]; + bundleRefExportIds?: string[]; } const DEFAULT_STATE: State = {}; @@ -87,6 +88,10 @@ export class BundleCache { return this.get().files; } + public getBundleRefExportIds() { + return this.get().bundleRefExportIds; + } + public getCacheKey() { return this.get().cacheKey; } diff --git a/packages/kbn-optimizer/src/common/bundle_refs.ts b/packages/kbn-optimizer/src/common/bundle_refs.ts new file mode 100644 index 00000000000000..a5c60f2031c0b6 --- /dev/null +++ b/packages/kbn-optimizer/src/common/bundle_refs.ts @@ -0,0 +1,130 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Path from 'path'; + +import { Bundle } from './bundle'; +import { UnknownVals } from './ts_helpers'; + +export interface BundleRef { + bundleId: string; + contextDir: string; + contextPrefix: string; + entry: string; + exportId: string; +} + +export class BundleRefs { + static fromBundles(bundles: Bundle[]) { + return new BundleRefs( + bundles.reduce( + (acc: BundleRef[], b) => [ + ...acc, + ...b.publicDirNames.map( + (name): BundleRef => ({ + bundleId: b.id, + contextDir: b.contextDir, + // Path.resolve converts separators and strips the final separator + contextPrefix: Path.resolve(b.contextDir) + Path.sep, + entry: name, + exportId: `${b.type}/${b.id}/${name}`, + }) + ), + ], + [] + ) + ); + } + + static parseSpec(json: unknown) { + if (typeof json !== 'string') { + throw new Error('expected `bundleRefs` spec to be a JSON string'); + } + + let spec; + try { + spec = JSON.parse(json); + } catch (error) { + throw new Error('`bundleRefs` spec must be valid JSON'); + } + + if (!Array.isArray(spec)) { + throw new Error('`bundleRefs` spec must be an array'); + } + + return new BundleRefs( + spec.map( + (refSpec: UnknownVals): BundleRef => { + if (typeof refSpec !== 'object' || !refSpec) { + throw new Error('`bundleRefs[]` must be an object'); + } + + const { bundleId } = refSpec; + if (typeof bundleId !== 'string') { + throw new Error('`bundleRefs[].bundleId` must be a string'); + } + + const { contextDir } = refSpec; + if (typeof contextDir !== 'string' || !Path.isAbsolute(contextDir)) { + throw new Error('`bundleRefs[].contextDir` must be an absolute directory'); + } + + const { contextPrefix } = refSpec; + if (typeof contextPrefix !== 'string' || !Path.isAbsolute(contextPrefix)) { + throw new Error('`bundleRefs[].contextPrefix` must be an absolute directory'); + } + + const { entry } = refSpec; + if (typeof entry !== 'string') { + throw new Error('`bundleRefs[].entry` must be a string'); + } + + const { exportId } = refSpec; + if (typeof exportId !== 'string') { + throw new Error('`bundleRefs[].exportId` must be a string'); + } + + return { + bundleId, + contextDir, + contextPrefix, + entry, + exportId, + }; + } + ) + ); + } + + constructor(private readonly refs: BundleRef[]) {} + + public filterByExportIds(exportIds: string[]) { + return this.refs.filter((r) => exportIds.includes(r.exportId)); + } + + public filterByContextPrefix(bundle: Bundle, absolutePath: string) { + return this.refs.filter( + (ref) => ref.bundleId !== bundle.id && absolutePath.startsWith(ref.contextPrefix) + ); + } + + public toSpecJson() { + return JSON.stringify(this.refs); + } +} diff --git a/packages/kbn-optimizer/src/common/index.ts b/packages/kbn-optimizer/src/common/index.ts index c51905be045653..7d021a5ee78475 100644 --- a/packages/kbn-optimizer/src/common/index.ts +++ b/packages/kbn-optimizer/src/common/index.ts @@ -19,6 +19,7 @@ export * from './bundle'; export * from './bundle_cache'; +export * from './bundle_refs'; export * from './worker_config'; export * from './worker_messages'; export * from './compiler_messages'; diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index 2814ab32017d29..2265bad9f6afaf 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -9,9 +9,11 @@ OptimizerConfig { "state": undefined, }, "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar, - "entry": "./public/index", "id": "bar", "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public, + "publicDirNames": Array [ + "public", + ], "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, "type": "plugin", }, @@ -21,9 +23,11 @@ OptimizerConfig { "state": undefined, }, "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo, - "entry": "./public/index", "id": "foo", "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public, + "publicDirNames": Array [ + "public", + ], "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, "type": "plugin", }, @@ -35,16 +39,19 @@ OptimizerConfig { "plugins": Array [ Object { "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar, + "extraPublicDirs": Array [], "id": "bar", "isUiPlugin": true, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/baz, + "extraPublicDirs": Array [], "id": "baz", "isUiPlugin": false, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo, + "extraPublicDirs": Array [], "id": "foo", "isUiPlugin": true, }, @@ -55,8 +62,8 @@ OptimizerConfig { } `; -exports[`prepares assets for distribution: bar bundle 1`] = `"var __kbnBundles__=typeof __kbnBundles__===\\"object\\"?__kbnBundles__:{};__kbnBundles__[\\"plugin/bar\\"]=function(modules){function webpackJsonpCallback(data){var chunkIds=data[0];var moreModules=data[1];var moduleId,chunkId,i=0,resolves=[];for(;i { const foo = config.bundles.find((b) => b.id === 'foo')!; expect(foo).toBeTruthy(); foo.cache.refresh(); - expect(foo.cache.getModuleCount()).toBe(5); + expect(foo.cache.getModuleCount()).toBe(6); expect(foo.cache.getReferencedFiles()).toMatchInlineSnapshot(` Array [ /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/async_import.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/lib.ts, + /packages/kbn-optimizer/target/worker/entry_point_creator.js, /packages/kbn-ui-shared-deps/public_path_module_creator.js, ] `); @@ -148,7 +149,7 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { bar.cache.refresh(); expect(bar.cache.getModuleCount()).toBe( // code + styles + style/css-loader runtimes + public path updater - 21 + 18 ); expect(bar.cache.getReferencedFiles()).toMatchInlineSnapshot(` @@ -159,11 +160,8 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/legacy/styles.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/lib.ts, - /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/async_import.ts, - /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, - /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts, - /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/lib.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/legacy/ui/public/icon.svg, + /packages/kbn-optimizer/target/worker/entry_point_creator.js, /packages/kbn-ui-shared-deps/public_path_module_creator.js, ] `); diff --git a/packages/kbn-optimizer/src/integration_tests/bundle_cache.test.ts b/packages/kbn-optimizer/src/integration_tests/bundle_cache.test.ts index 39064c64062e82..48cab508954a08 100644 --- a/packages/kbn-optimizer/src/integration_tests/bundle_cache.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/bundle_cache.test.ts @@ -75,6 +75,7 @@ it('emits "bundle cached" event when everything is updated', async () => { optimizerCacheKey, files, moduleCount: files.length, + bundleRefExportIds: [], }); const cacheEvents = await getBundleCacheEvent$(config, optimizerCacheKey) @@ -115,6 +116,7 @@ it('emits "bundle not cached" event when cacheKey is up to date but caching is d optimizerCacheKey, files, moduleCount: files.length, + bundleRefExportIds: [], }); const cacheEvents = await getBundleCacheEvent$(config, optimizerCacheKey) @@ -155,6 +157,7 @@ it('emits "bundle not cached" event when optimizerCacheKey is missing', async () optimizerCacheKey: undefined, files, moduleCount: files.length, + bundleRefExportIds: [], }); const cacheEvents = await getBundleCacheEvent$(config, optimizerCacheKey) @@ -195,6 +198,7 @@ it('emits "bundle not cached" event when optimizerCacheKey is outdated, includes optimizerCacheKey: 'old', files, moduleCount: files.length, + bundleRefExportIds: [], }); const cacheEvents = await getBundleCacheEvent$(config, optimizerCacheKey) @@ -217,6 +221,53 @@ it('emits "bundle not cached" event when optimizerCacheKey is outdated, includes `); }); +it('emits "bundle not cached" event when bundleRefExportIds is outdated, includes diff', async () => { + const config = OptimizerConfig.create({ + repoRoot: MOCK_REPO_DIR, + pluginScanDirs: [], + pluginPaths: [Path.resolve(MOCK_REPO_DIR, 'plugins/foo')], + maxWorkerCount: 1, + }); + const [bundle] = config.bundles; + + const optimizerCacheKey = 'optimizerCacheKey'; + const files = [ + Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/ext.ts'), + Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), + Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), + ]; + const mtimes = await getMtimes(files); + const cacheKey = bundle.createCacheKey(files, mtimes); + + bundle.cache.set({ + cacheKey, + optimizerCacheKey, + files, + moduleCount: files.length, + bundleRefExportIds: ['plugin/bar/public'], + }); + + const cacheEvents = await getBundleCacheEvent$(config, optimizerCacheKey) + .pipe(toArray()) + .toPromise(); + + expect(cacheEvents).toMatchInlineSnapshot(` + Array [ + Object { + "bundle": , + "diff": "- Expected + + Received + +  [ + + \\"plugin/bar/public\\" +  ]", + "reason": "bundle references outdated", + "type": "bundle not cached", + }, + ] + `); +}); + it('emits "bundle not cached" event when cacheKey is missing', async () => { const config = OptimizerConfig.create({ repoRoot: MOCK_REPO_DIR, @@ -238,6 +289,7 @@ it('emits "bundle not cached" event when cacheKey is missing', async () => { optimizerCacheKey, files, moduleCount: files.length, + bundleRefExportIds: [], }); const cacheEvents = await getBundleCacheEvent$(config, optimizerCacheKey) @@ -276,6 +328,7 @@ it('emits "bundle not cached" event when cacheKey is outdated', async () => { optimizerCacheKey, files, moduleCount: files.length, + bundleRefExportIds: [], }); jest.spyOn(bundle, 'createCacheKey').mockImplementation(() => 'new'); diff --git a/packages/kbn-optimizer/src/integration_tests/watch_bundles_for_changes.test.ts b/packages/kbn-optimizer/src/integration_tests/watch_bundles_for_changes.test.ts index 91d0f308e0ef6f..176b17c979da94 100644 --- a/packages/kbn-optimizer/src/integration_tests/watch_bundles_for_changes.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/watch_bundles_for_changes.test.ts @@ -29,14 +29,14 @@ jest.mock('fs'); jest.mock('watchpack'); const MockWatchPack: jest.MockedClass = jest.requireMock('watchpack'); -const bundleEntryPath = (bundle: Bundle) => `${bundle.contextDir}/${bundle.entry}`; +const bundleEntryPath = (bundle: Bundle) => `${bundle.contextDir}/public/index.ts`; const makeTestBundle = (id: string) => { const bundle = new Bundle({ type: 'plugin', id, contextDir: `/repo/plugins/${id}/public`, - entry: 'index.ts', + publicDirNames: ['public'], outputDir: `/repo/plugins/${id}/target/public`, sourceRoot: `/repo`, }); diff --git a/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.test.ts b/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.test.ts index 4671276797049c..ca50a49e269139 100644 --- a/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.test.ts +++ b/packages/kbn-optimizer/src/optimizer/assign_bundles_to_workers.test.ts @@ -57,7 +57,7 @@ const assertReturnVal = (workers: Assignments[]) => { const testBundle = (id: string) => new Bundle({ contextDir: `/repo/plugin/${id}/public`, - entry: 'index.ts', + publicDirNames: ['public'], id, outputDir: `/repo/plugins/${id}/target/public`, sourceRoot: `/repo`, diff --git a/packages/kbn-optimizer/src/optimizer/bundle_cache.ts b/packages/kbn-optimizer/src/optimizer/bundle_cache.ts index 55e8e1d3fd0842..83db8570bd4085 100644 --- a/packages/kbn-optimizer/src/optimizer/bundle_cache.ts +++ b/packages/kbn-optimizer/src/optimizer/bundle_cache.ts @@ -20,7 +20,7 @@ import * as Rx from 'rxjs'; import { mergeAll } from 'rxjs/operators'; -import { Bundle } from '../common'; +import { Bundle, BundleRefs } from '../common'; import { OptimizerConfig } from './optimizer_config'; import { getMtimes } from './get_mtimes'; @@ -35,7 +35,9 @@ export interface BundleNotCachedEvent { | 'optimizer cache key mismatch' | 'missing cache key' | 'cache key mismatch' - | 'cache disabled'; + | 'cache disabled' + | 'bundle references missing' + | 'bundle references outdated'; diff?: string; bundle: Bundle; } @@ -52,6 +54,7 @@ export function getBundleCacheEvent$( return Rx.defer(async () => { const events: BundleCacheEvent[] = []; const eligibleBundles: Bundle[] = []; + const bundleRefs = BundleRefs.fromBundles(config.bundles); for (const bundle of config.bundles) { if (!config.cache) { @@ -93,6 +96,32 @@ export function getBundleCacheEvent$( continue; } + const bundleRefExportIds = bundle.cache.getBundleRefExportIds(); + if (!bundleRefExportIds) { + events.push({ + type: 'bundle not cached', + reason: 'bundle references missing', + bundle, + }); + continue; + } + + const refs = bundleRefs.filterByExportIds(bundleRefExportIds); + + const bundleRefsDiff = diffCacheKey( + refs.map((r) => r.exportId).sort((a, b) => a.localeCompare(b)), + bundleRefExportIds + ); + if (bundleRefsDiff) { + events.push({ + type: 'bundle not cached', + reason: 'bundle references outdated', + diff: bundleRefsDiff, + bundle, + }); + continue; + } + eligibleBundles.push(bundle); } diff --git a/packages/kbn-optimizer/src/optimizer/cache_keys.ts b/packages/kbn-optimizer/src/optimizer/cache_keys.ts index 2766f6d63702b7..d0aaad979485d2 100644 --- a/packages/kbn-optimizer/src/optimizer/cache_keys.ts +++ b/packages/kbn-optimizer/src/optimizer/cache_keys.ts @@ -37,16 +37,6 @@ import { OptimizerConfig } from './optimizer_config'; const OPTIMIZER_DIR = Path.dirname(require.resolve('../../package.json')); const RELATIVE_DIR = Path.relative(REPO_ROOT, OPTIMIZER_DIR); -function omit(obj: T, keys: K[]): Omit { - const result: any = {}; - for (const [key, value] of Object.entries(obj) as any) { - if (!keys.includes(key)) { - result[key] = value; - } - } - return result as Omit; -} - export function diffCacheKey(expected?: unknown, actual?: unknown) { const expectedJson = jsonStable(expected, { space: ' ', @@ -185,7 +175,7 @@ export async function getOptimizerCacheKey(config: OptimizerConfig) { bootstrap, deletedPaths, modifiedTimes: {} as Record, - workerConfig: omit(config.getWorkerConfig('♻'), ['watch', 'profileWebpack', 'cache']), + workerConfig: config.getCacheableWorkerConfig(), }; const mtimes = await getMtimes(modifiedPaths); diff --git a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts index 2174c488ad6cc4..bbd3ddc11f448d 100644 --- a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts +++ b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts @@ -31,16 +31,19 @@ it('returns a bundle for core and each plugin', () => { directory: '/repo/plugins/foo', id: 'foo', isUiPlugin: true, + extraPublicDirs: [], }, { directory: '/repo/plugins/bar', id: 'bar', isUiPlugin: false, + extraPublicDirs: [], }, { directory: '/outside/of/repo/plugins/baz', id: 'baz', isUiPlugin: true, + extraPublicDirs: [], }, ], '/repo' @@ -49,17 +52,21 @@ it('returns a bundle for core and each plugin', () => { Array [ Object { "contextDir": /plugins/foo, - "entry": "./public/index", "id": "foo", "outputDir": /plugins/foo/target/public, + "publicDirNames": Array [ + "public", + ], "sourceRoot": , "type": "plugin", }, Object { "contextDir": "/outside/of/repo/plugins/baz", - "entry": "./public/index", "id": "baz", "outputDir": "/outside/of/repo/plugins/baz/target/public", + "publicDirNames": Array [ + "public", + ], "sourceRoot": , "type": "plugin", }, diff --git a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts index b75a8a6edc2647..2635289088725f 100644 --- a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts +++ b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts @@ -31,7 +31,7 @@ export function getPluginBundles(plugins: KibanaPlatformPlugin[], repoRoot: stri new Bundle({ type: 'plugin', id: p.id, - entry: './public/index', + publicDirNames: ['public', ...p.extraPublicDirs], sourceRoot: repoRoot, contextDir: p.directory, outputDir: Path.resolve(p.directory, 'target/public'), diff --git a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.test.ts b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.test.ts index e047b6d1e44cf4..0961881df461c5 100644 --- a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.test.ts +++ b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.test.ts @@ -37,21 +37,25 @@ it('parses kibana.json files of plugins found in pluginDirs', () => { Array [ Object { "directory": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar, + "extraPublicDirs": Array [], "id": "bar", "isUiPlugin": true, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/baz, + "extraPublicDirs": Array [], "id": "baz", "isUiPlugin": false, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo, + "extraPublicDirs": Array [], "id": "foo", "isUiPlugin": true, }, Object { "directory": /packages/kbn-optimizer/src/__fixtures__/mock_repo/test_plugins/test_baz, + "extraPublicDirs": Array [], "id": "test_baz", "isUiPlugin": false, }, diff --git a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts index 992feab6cd364c..bfc60a29efa27d 100644 --- a/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts +++ b/packages/kbn-optimizer/src/optimizer/kibana_platform_plugins.ts @@ -26,6 +26,7 @@ export interface KibanaPlatformPlugin { readonly directory: string; readonly id: string; readonly isUiPlugin: boolean; + readonly extraPublicDirs: string[]; } /** @@ -64,9 +65,24 @@ function readKibanaPlatformPlugin(manifestPath: string): KibanaPlatformPlugin { throw new TypeError('expected new platform plugin manifest to have a string id'); } + let extraPublicDirs: string[] | undefined; + if (manifest.extraPublicDirs) { + if ( + !Array.isArray(manifest.extraPublicDirs) || + !manifest.extraPublicDirs.every((p) => typeof p === 'string') + ) { + throw new TypeError( + 'expected new platform plugin manifest to have an array of strings `extraPublicDirs` property' + ); + } + + extraPublicDirs = manifest.extraPublicDirs as string[]; + } + return { directory: Path.dirname(manifestPath), id: manifest.id, isUiPlugin: !!manifest.ui, + extraPublicDirs: extraPublicDirs || [], }; } diff --git a/packages/kbn-optimizer/src/optimizer/observe_worker.ts b/packages/kbn-optimizer/src/optimizer/observe_worker.ts index c929cf62d1bb0a..4527052fa821a4 100644 --- a/packages/kbn-optimizer/src/optimizer/observe_worker.ts +++ b/packages/kbn-optimizer/src/optimizer/observe_worker.ts @@ -24,7 +24,7 @@ import execa from 'execa'; import * as Rx from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; -import { isWorkerMsg, WorkerConfig, WorkerMsg, Bundle } from '../common'; +import { isWorkerMsg, WorkerConfig, WorkerMsg, Bundle, BundleRefs } from '../common'; import { OptimizerConfig } from './optimizer_config'; @@ -74,7 +74,11 @@ function usingWorkerProc( ) { return Rx.using( (): ProcResource => { - const args = [JSON.stringify(workerConfig), JSON.stringify(bundles.map((b) => b.toSpec()))]; + const args = [ + JSON.stringify(workerConfig), + JSON.stringify(bundles.map((b) => b.toSpec())), + BundleRefs.fromBundles(config.bundles).toSpecJson(), + ]; const proc = execa.node(require.resolve('../worker/run_worker'), args, { nodeOptions: [ diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_config.ts b/packages/kbn-optimizer/src/optimizer/optimizer_config.ts index 37d8a4f5eb8aea..c9e9b3ad01ccc6 100644 --- a/packages/kbn-optimizer/src/optimizer/optimizer_config.ts +++ b/packages/kbn-optimizer/src/optimizer/optimizer_config.ts @@ -20,7 +20,7 @@ import Path from 'path'; import Os from 'os'; -import { Bundle, WorkerConfig } from '../common'; +import { Bundle, WorkerConfig, CacheableWorkerConfig } from '../common'; import { findKibanaPlatformPlugins, KibanaPlatformPlugin } from './kibana_platform_plugins'; import { getPluginBundles } from './get_plugin_bundles'; @@ -34,6 +34,16 @@ function pickMaxWorkerCount(dist: boolean) { return Math.max(maxWorkers, 2); } +function omit(obj: T, keys: K[]): Omit { + const result: any = {}; + for (const [key, value] of Object.entries(obj) as any) { + if (!keys.includes(key)) { + result[key] = value; + } + } + return result as Omit; +} + interface Options { /** absolute path to root of the repo/build */ repoRoot: string; @@ -152,7 +162,7 @@ export class OptimizerConfig { new Bundle({ type: 'entry', id: 'core', - entry: './public/index', + publicDirNames: ['public', 'public/utils'], sourceRoot: options.repoRoot, contextDir: Path.resolve(options.repoRoot, 'src/core'), outputDir: Path.resolve(options.repoRoot, 'src/core/target/public'), @@ -198,4 +208,14 @@ export class OptimizerConfig { browserslistEnv: this.dist ? 'production' : process.env.BROWSERSLIST_ENV || 'dev', }; } + + getCacheableWorkerConfig(): CacheableWorkerConfig { + return omit(this.getWorkerConfig('♻'), [ + // these config options don't change the output of the bundles, so + // should not invalidate caches when they change + 'watch', + 'profileWebpack', + 'cache', + ]); + } } diff --git a/packages/kbn-optimizer/src/worker/bundle_ref_module.ts b/packages/kbn-optimizer/src/worker/bundle_ref_module.ts new file mode 100644 index 00000000000000..cde25564cf5282 --- /dev/null +++ b/packages/kbn-optimizer/src/worker/bundle_ref_module.ts @@ -0,0 +1,67 @@ +/* eslint-disable @kbn/eslint/require-license-header */ + +/** + * @notice + * + * This module was heavily inspired by the externals plugin that ships with webpack@97d58d31 + * MIT License http://www.opensource.org/licenses/mit-license.php + * Author Tobias Koppers @sokra + */ + +// @ts-ignore not typed by @types/webpack +import Module from 'webpack/lib/Module'; + +export class BundleRefModule extends Module { + public built = false; + public buildMeta?: any; + public buildInfo?: any; + public exportsArgument = '__webpack_exports__'; + + constructor(public readonly exportId: string) { + super('kbn/bundleRef', null); + } + + libIdent() { + return this.exportId; + } + + chunkCondition(chunk: any) { + return chunk.hasEntryModule(); + } + + identifier() { + return '@kbn/bundleRef ' + JSON.stringify(this.exportId); + } + + readableIdentifier() { + return this.identifier(); + } + + needRebuild() { + return false; + } + + build(_: any, __: any, ___: any, ____: any, callback: () => void) { + this.built = true; + this.buildMeta = {}; + this.buildInfo = {}; + callback(); + } + + source() { + return ` + __webpack_require__.r(__webpack_exports__); + var ns = __kbnBundles__.get('${this.exportId}'); + Object.defineProperties(__webpack_exports__, Object.getOwnPropertyDescriptors(ns)) + `; + } + + size() { + return 42; + } + + updateHash(hash: any) { + hash.update(this.identifier()); + super.updateHash(hash); + } +} diff --git a/packages/kbn-optimizer/src/worker/bundle_refs_plugin.ts b/packages/kbn-optimizer/src/worker/bundle_refs_plugin.ts new file mode 100644 index 00000000000000..6defcaa787b7d7 --- /dev/null +++ b/packages/kbn-optimizer/src/worker/bundle_refs_plugin.ts @@ -0,0 +1,165 @@ +/* eslint-disable @kbn/eslint/require-license-header */ + +/** + * @notice + * + * This module was heavily inspired by the externals plugin that ships with webpack@97d58d31 + * MIT License http://www.opensource.org/licenses/mit-license.php + * Author Tobias Koppers @sokra + */ + +import Path from 'path'; +import Fs from 'fs'; + +import webpack from 'webpack'; + +import { Bundle, BundleRefs, BundleRef } from '../common'; +import { BundleRefModule } from './bundle_ref_module'; + +const RESOLVE_EXTENSIONS = ['.js', '.ts', '.tsx']; + +function safeStat(path: string): Promise { + return new Promise((resolve, reject) => { + Fs.stat(path, (error, stat) => { + if (error?.code === 'ENOENT') { + resolve(undefined); + } else if (error) { + reject(error); + } else { + resolve(stat); + } + }); + }); +} + +interface RequestData { + context: string; + dependencies: Array<{ request: string }>; +} + +type Callback = (error?: any, result?: T) => void; +type ModuleFactory = (data: RequestData, callback: Callback) => void; + +/** + * Isolate the weired type juggling we have to do to add a hook to the webpack compiler + */ +function hookIntoCompiler( + compiler: webpack.Compiler, + handler: (context: string, request: string) => Promise +) { + compiler.hooks.compile.tap('BundleRefsPlugin', (compilationParams: any) => { + compilationParams.normalModuleFactory.hooks.factory.tap( + 'BundleRefsPlugin/normalModuleFactory/factory', + (wrappedFactory: ModuleFactory): ModuleFactory => (data, callback) => { + const context = data.context; + const dep = data.dependencies[0]; + + handler(context, dep.request).then( + (result) => { + if (!result) { + wrappedFactory(data, callback); + } else { + callback(undefined, result); + } + }, + (error) => callback(error) + ); + } + ); + }); +} + +export class BundleRefsPlugin { + private resolvedRequestCache = new Map>(); + + constructor(private readonly bundle: Bundle, public readonly bundleRefs: BundleRefs) {} + + apply(compiler: webpack.Compiler) { + hookIntoCompiler(compiler, async (context, request) => { + const ref = await this.resolveRef(context, request); + if (ref) { + return new BundleRefModule(ref.exportId); + } + }); + } + + private cachedResolveRequest(context: string, request: string) { + const absoluteRequest = Path.resolve(context, request); + const cached = this.resolvedRequestCache.get(absoluteRequest); + + if (cached) { + return cached; + } + + const promise = this.resolveRequest(absoluteRequest); + this.resolvedRequestCache.set(absoluteRequest, promise); + return promise; + } + + private async resolveRequest(absoluteRequest: string) { + const stats = await safeStat(absoluteRequest); + if (stats && stats.isFile()) { + return absoluteRequest; + } + + if (stats?.isDirectory()) { + for (const ext of RESOLVE_EXTENSIONS) { + const indexPath = Path.resolve(absoluteRequest, `index${ext}`); + const indexStats = await safeStat(indexPath); + if (indexStats?.isFile()) { + return indexPath; + } + } + } + + return; + } + + /** + * Determine if an import request resolves to a bundleRef export id. If the + * request resolves to a bundle ref context but none of the exported directories + * then an error is thrown. If the request does not resolve to a bundleRef then + * undefined is returned. Otherwise it returns the referenced bundleRef. + */ + private async resolveRef(context: string, request: string) { + // ignore imports that have loaders defined or are not relative seeming + if (request.includes('!') || !request.startsWith('.')) { + return; + } + + const requestExt = Path.extname(request); + if (requestExt && !RESOLVE_EXTENSIONS.includes(requestExt)) { + return; + } + + const resolved = await this.cachedResolveRequest(context, request); + if (!resolved) { + return; + } + + const eligibleRefs = this.bundleRefs.filterByContextPrefix(this.bundle, resolved); + if (!eligibleRefs.length) { + // import doesn't match a bundle context + return; + } + + let matchingRef: BundleRef | undefined; + for (const ref of eligibleRefs) { + const resolvedEntry = await this.cachedResolveRequest(ref.contextDir, ref.entry); + if (resolved === resolvedEntry) { + matchingRef = ref; + break; + } + } + + if (!matchingRef) { + const bundleId = Array.from(new Set(eligibleRefs.map((r) => r.bundleId))).join(', '); + const publicDir = eligibleRefs.map((r) => r.entry).join(', '); + throw new Error( + `import [${request}] references a non-public export of the [${bundleId}] bundle and must point to one of the public directories: [${publicDir}]` + ); + } + + return matchingRef; + } +} diff --git a/packages/kbn-optimizer/src/worker/entry_point_creator.ts b/packages/kbn-optimizer/src/worker/entry_point_creator.ts new file mode 100644 index 00000000000000..a613e3e8925a4b --- /dev/null +++ b/packages/kbn-optimizer/src/worker/entry_point_creator.ts @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +module.exports = function ({ entries }: { entries: Array<{ importId: string; relPath: string }> }) { + const lines = entries.map(({ importId, relPath }) => [ + `__kbnBundles__.define('${importId}', __webpack_require__, require.resolve('./${relPath}'))`, + ]); + + return { + code: lines.join('\n'), + }; +}; diff --git a/packages/kbn-optimizer/src/worker/run_compilers.ts b/packages/kbn-optimizer/src/worker/run_compilers.ts index 4ab289d031d721..de5e9372e9e7ac 100644 --- a/packages/kbn-optimizer/src/worker/run_compilers.ts +++ b/packages/kbn-optimizer/src/worker/run_compilers.ts @@ -35,7 +35,9 @@ import { WorkerConfig, ascending, parseFilePath, + BundleRefs, } from '../common'; +import { BundleRefModule } from './bundle_ref_module'; import { getWebpackConfig } from './webpack.config'; import { isFailureStats, failedStatsToErrorMessage } from './webpack_helpers'; import { @@ -43,7 +45,6 @@ import { isNormalModule, isIgnoredModule, isConcatenatedModule, - WebpackNormalModule, getModulePath, } from './webpack_helpers'; @@ -98,40 +99,43 @@ const observeCompiler = ( }); } - const normalModules = stats.compilation.modules.filter( - (module): module is WebpackNormalModule => { - if (isNormalModule(module)) { - return true; - } + const bundleRefExportIds: string[] = []; + const referencedFiles = new Set(); + let normalModuleCount = 0; + + for (const module of stats.compilation.modules) { + if (isNormalModule(module)) { + normalModuleCount += 1; + const path = getModulePath(module); + const parsedPath = parseFilePath(path); - if (isExternalModule(module) || isIgnoredModule(module) || isConcatenatedModule(module)) { - return false; + if (!parsedPath.dirs.includes('node_modules')) { + referencedFiles.add(path); + continue; } - throw new Error(`Unexpected module type: ${inspect(module)}`); + const nmIndex = parsedPath.dirs.lastIndexOf('node_modules'); + const isScoped = parsedPath.dirs[nmIndex + 1].startsWith('@'); + referencedFiles.add( + Path.join( + parsedPath.root, + ...parsedPath.dirs.slice(0, nmIndex + 1 + (isScoped ? 2 : 1)), + 'package.json' + ) + ); + continue; } - ); - - const referencedFiles = new Set(); - for (const module of normalModules) { - const path = getModulePath(module); - const parsedPath = parseFilePath(path); + if (module instanceof BundleRefModule) { + bundleRefExportIds.push(module.exportId); + continue; + } - if (!parsedPath.dirs.includes('node_modules')) { - referencedFiles.add(path); + if (isExternalModule(module) || isIgnoredModule(module) || isConcatenatedModule(module)) { continue; } - const nmIndex = parsedPath.dirs.lastIndexOf('node_modules'); - const isScoped = parsedPath.dirs[nmIndex + 1].startsWith('@'); - referencedFiles.add( - Path.join( - parsedPath.root, - ...parsedPath.dirs.slice(0, nmIndex + 1 + (isScoped ? 2 : 1)), - 'package.json' - ) - ); + throw new Error(`Unexpected module type: ${inspect(module)}`); } const files = Array.from(referencedFiles).sort(ascending((p) => p)); @@ -150,14 +154,15 @@ const observeCompiler = ( ); bundle.cache.set({ + bundleRefExportIds, optimizerCacheKey: workerConfig.optimizerCacheKey, cacheKey: bundle.createCacheKey(files, mtimes), - moduleCount: normalModules.length, + moduleCount: normalModuleCount, files, }); return compilerMsgs.compilerSuccess({ - moduleCount: normalModules.length, + moduleCount: normalModuleCount, }); }) ); @@ -185,8 +190,14 @@ const observeCompiler = ( /** * Run webpack compilers */ -export const runCompilers = (workerConfig: WorkerConfig, bundles: Bundle[]) => { - const multiCompiler = webpack(bundles.map((def) => getWebpackConfig(def, workerConfig))); +export const runCompilers = ( + workerConfig: WorkerConfig, + bundles: Bundle[], + bundleRefs: BundleRefs +) => { + const multiCompiler = webpack( + bundles.map((def) => getWebpackConfig(def, bundleRefs, workerConfig)) + ); return Rx.merge( /** diff --git a/packages/kbn-optimizer/src/worker/run_worker.ts b/packages/kbn-optimizer/src/worker/run_worker.ts index f83c69477f4718..178637d39ab003 100644 --- a/packages/kbn-optimizer/src/worker/run_worker.ts +++ b/packages/kbn-optimizer/src/worker/run_worker.ts @@ -19,7 +19,14 @@ import * as Rx from 'rxjs'; -import { parseBundles, parseWorkerConfig, WorkerMsg, isWorkerMsg, WorkerMsgs } from '../common'; +import { + parseBundles, + parseWorkerConfig, + WorkerMsg, + isWorkerMsg, + WorkerMsgs, + BundleRefs, +} from '../common'; import { runCompilers } from './run_compilers'; @@ -76,11 +83,12 @@ setInterval(() => { Rx.defer(() => { const workerConfig = parseWorkerConfig(process.argv[2]); const bundles = parseBundles(process.argv[3]); + const bundleRefs = BundleRefs.parseSpec(process.argv[4]); // set BROWSERSLIST_ENV so that style/babel loaders see it before running compilers process.env.BROWSERSLIST_ENV = workerConfig.browserslistEnv; - return runCompilers(workerConfig, bundles); + return runCompilers(workerConfig, bundles, bundleRefs); }).subscribe( (msg) => { send(msg); diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index d31c098ca1f2eb..3daf21cdc38ccb 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -17,10 +17,8 @@ * under the License. */ -import Fs from 'fs'; import Path from 'path'; -import normalizePath from 'normalize-path'; import { stringifyRequest } from 'loader-utils'; import webpack from 'webpack'; // @ts-ignore @@ -32,88 +30,22 @@ import { CleanWebpackPlugin } from 'clean-webpack-plugin'; import CompressionPlugin from 'compression-webpack-plugin'; import * as UiSharedDeps from '@kbn/ui-shared-deps'; -import { Bundle, WorkerConfig, parseDirPath, DisallowedSyntaxPlugin } from '../common'; +import { Bundle, BundleRefs, WorkerConfig, parseDirPath, DisallowedSyntaxPlugin } from '../common'; +import { BundleRefsPlugin } from './bundle_refs_plugin'; const IS_CODE_COVERAGE = !!process.env.CODE_COVERAGE; const ISTANBUL_PRESET_PATH = require.resolve('@kbn/babel-preset/istanbul_preset'); const BABEL_PRESET_PATH = require.resolve('@kbn/babel-preset/webpack_preset'); -const SHARED_BUNDLES = [ - { - type: 'entry', - id: 'core', - rootRelativeDir: 'src/core/public', - }, - { - type: 'plugin', - id: 'data', - rootRelativeDir: 'src/plugins/data/public', - }, - { - type: 'plugin', - id: 'kibanaReact', - rootRelativeDir: 'src/plugins/kibana_react/public', - }, - { - type: 'plugin', - id: 'kibanaUtils', - rootRelativeDir: 'src/plugins/kibana_utils/public', - }, - { - type: 'plugin', - id: 'esUiShared', - rootRelativeDir: 'src/plugins/es_ui_shared/public', - }, -]; - -/** - * Determine externals statements for require/import statements by looking - * for requests resolving to the primary public export of the data, kibanaReact, - * amd kibanaUtils plugins. If this module is being imported then rewrite - * the import to access the global `__kbnBundles__` variables and access - * the relavent properties from that global object. - * - * @param bundle - * @param context the directory containing the module which made `request` - * @param request the request for a module from a commonjs require() call or import statement - */ -function dynamicExternals(bundle: Bundle, context: string, request: string) { - // ignore imports that have loaders defined or are not relative seeming - if (request.includes('!') || !request.startsWith('.')) { - return; - } - - // determine the most acurate resolution string we can without running full resolution - const rootRelative = normalizePath( - Path.relative(bundle.sourceRoot, Path.resolve(context, request)) - ); - for (const sharedBundle of SHARED_BUNDLES) { - if ( - rootRelative !== sharedBundle.rootRelativeDir || - `${bundle.type}/${bundle.id}` === `${sharedBundle.type}/${sharedBundle.id}` - ) { - continue; - } - - return `__kbnBundles__['${sharedBundle.type}/${sharedBundle.id}']`; - } - - // import doesn't match a root public import - return undefined; -} - -export function getWebpackConfig(bundle: Bundle, worker: WorkerConfig) { - const extensions = ['.js', '.ts', '.tsx', '.json']; - const entryExtension = extensions.find((ext) => - Fs.existsSync(Path.resolve(bundle.contextDir, bundle.entry) + ext) - ); +export function getWebpackConfig(bundle: Bundle, bundleRefs: BundleRefs, worker: WorkerConfig) { + const ENTRY_CREATOR = require.resolve('./entry_point_creator'); const commonConfig: webpack.Configuration = { node: { fs: 'empty' }, context: bundle.contextDir, cache: true, entry: { - [bundle.id]: `${bundle.entry}${entryExtension}`, + [bundle.id]: ENTRY_CREATOR, }, devtool: worker.dist ? false : '#cheap-source-map', @@ -128,27 +60,19 @@ export function getWebpackConfig(bundle: Bundle, worker: WorkerConfig) { info.absoluteResourcePath )}${info.query}`, jsonpFunction: `${bundle.id}_bundle_jsonpfunction`, - // When the entry point is loaded, assign it's default export - // to a key on the global `__kbnBundles__` object. - library: ['__kbnBundles__', `${bundle.type}/${bundle.id}`], }, optimization: { noEmitOnErrors: true, }, - externals: [ - UiSharedDeps.externals, - function (context, request, cb) { - try { - cb(undefined, dynamicExternals(bundle, context, request)); - } catch (error) { - cb(error, undefined); - } - }, - ], + externals: [UiSharedDeps.externals], - plugins: [new CleanWebpackPlugin(), new DisallowedSyntaxPlugin()], + plugins: [ + new CleanWebpackPlugin(), + new DisallowedSyntaxPlugin(), + new BundleRefsPlugin(bundle, bundleRefs), + ], module: { // no parse rules for a few known large packages which have no require() statements @@ -162,11 +86,28 @@ export function getWebpackConfig(bundle: Bundle, worker: WorkerConfig) { rules: [ { - include: [`${Path.resolve(bundle.contextDir, bundle.entry)}${entryExtension}`], - loader: UiSharedDeps.publicPathLoader, - options: { - key: bundle.id, - }, + include: [ENTRY_CREATOR], + use: [ + { + loader: UiSharedDeps.publicPathLoader, + options: { + key: bundle.id, + }, + }, + { + loader: require.resolve('val-loader'), + options: { + entries: bundle.publicDirNames.map((name) => { + const absolute = Path.resolve(bundle.contextDir, name); + const newContext = Path.dirname(ENTRY_CREATOR); + return { + importId: `${bundle.type}/${bundle.id}/${name}`, + relPath: Path.relative(newContext, absolute), + }; + }), + }, + }, + ], }, { test: /\.css$/, @@ -310,7 +251,7 @@ export function getWebpackConfig(bundle: Bundle, worker: WorkerConfig) { }, resolve: { - extensions, + extensions: ['.js', '.ts', '.tsx', 'json'], mainFields: ['browser', 'main'], alias: { tinymath: require.resolve('tinymath/lib/tinymath.es5.js'), diff --git a/packages/kbn-plugin-helpers/src/tasks/build/create_package.ts b/packages/kbn-plugin-helpers/src/tasks/build/create_package.ts index 9fa2305a94eabc..02c20049003471 100644 --- a/packages/kbn-plugin-helpers/src/tasks/build/create_package.ts +++ b/packages/kbn-plugin-helpers/src/tasks/build/create_package.ts @@ -36,7 +36,7 @@ export async function createPackage( // zip up the package await pipeline( - vfs.src(buildFiles, { cwd: buildTarget, base: buildTarget }), + vfs.src(buildFiles, { cwd: buildTarget, base: buildTarget, dot: true }), zip(`${buildId}.zip`), vfs.dest(buildTarget) ); diff --git a/packages/kbn-release-notes/package.json b/packages/kbn-release-notes/package.json new file mode 100644 index 00000000000000..25e1816b6cc1ed --- /dev/null +++ b/packages/kbn-release-notes/package.json @@ -0,0 +1,23 @@ +{ + "name": "@kbn/release-notes", + "version": "1.0.0", + "license": "Apache-2.0", + "main": "target/index.js", + "scripts": { + "kbn:bootstrap": "tsc", + "kbn:watch": "tsc --watch" + }, + "dependencies": { + "@kbn/dev-utils": "1.0.0", + "axios": "^0.19.2", + "cheerio": "0.22.0", + "dedent": "^0.7.0", + "graphql": "^14.0.0", + "graphql-tag": "^2.10.3", + "terminal-link": "^2.1.1" + }, + "devDependencies": { + "markdown-it": "^10.0.0", + "typescript": "3.9.5" + } +} \ No newline at end of file diff --git a/packages/kbn-release-notes/src/cli.ts b/packages/kbn-release-notes/src/cli.ts new file mode 100644 index 00000000000000..44b4a7a0282d20 --- /dev/null +++ b/packages/kbn-release-notes/src/cli.ts @@ -0,0 +1,162 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Fs from 'fs'; +import Path from 'path'; +import { inspect } from 'util'; + +import { run, createFlagError, createFailError, REPO_ROOT } from '@kbn/dev-utils'; + +import { FORMATS, SomeFormat } from './formats'; +import { + iterRelevantPullRequests, + getPr, + Version, + ClassifiedPr, + streamFromIterable, + asyncPipeline, + IrrelevantPrSummary, + isPrRelevant, + classifyPr, +} from './lib'; + +const rootPackageJson = JSON.parse( + Fs.readFileSync(Path.resolve(REPO_ROOT, 'package.json'), 'utf8') +); +const extensions = FORMATS.map((f) => f.extension); + +export function runReleaseNotesCli() { + run( + async ({ flags, log }) => { + const token = flags.token; + if (!token || typeof token !== 'string') { + throw createFlagError('--token must be defined'); + } + + const version = Version.fromFlag(flags.version); + if (!version) { + throw createFlagError('unable to parse --version, use format "v{major}.{minor}.{patch}"'); + } + + const includeVersions = Version.fromFlags(flags.include || []); + if (!includeVersions) { + throw createFlagError('unable to parse --include, use format "v{major}.{minor}.{patch}"'); + } + + const Formats: SomeFormat[] = []; + for (const flag of Array.isArray(flags.format) ? flags.format : [flags.format]) { + const Format = FORMATS.find((F) => F.extension === flag); + if (!Format) { + throw createFlagError(`--format must be one of "${extensions.join('", "')}"`); + } + Formats.push(Format); + } + + const filename = flags.filename; + if (!filename || typeof filename !== 'string') { + throw createFlagError('--filename must be a string'); + } + + if (flags['debug-pr']) { + const number = parseInt(String(flags['debug-pr']), 10); + if (Number.isNaN(number)) { + throw createFlagError('--debug-pr must be a pr number when specified'); + } + + const summary = new IrrelevantPrSummary(log); + const pr = await getPr(token, number); + log.success( + inspect( + { + version: version.label, + includeVersions: includeVersions.map((v) => v.label), + isPrRelevant: isPrRelevant(pr, version, includeVersions, summary), + ...classifyPr(pr, log), + pr, + }, + { depth: 100 } + ) + ); + summary.logStats(); + return; + } + + log.info(`Loading all PRs with label [${version.label}] to build release notes...`); + + const summary = new IrrelevantPrSummary(log); + const prsToReport: ClassifiedPr[] = []; + const prIterable = iterRelevantPullRequests(token, version, log); + for await (const pr of prIterable) { + if (!isPrRelevant(pr, version, includeVersions, summary)) { + continue; + } + prsToReport.push(classifyPr(pr, log)); + } + summary.logStats(); + + if (!prsToReport.length) { + throw createFailError( + `All PRs with label [${version.label}] were filtered out by the config. Run again with --debug for more info.` + ); + } + + log.info(`Found ${prsToReport.length} prs to report on`); + + for (const Format of Formats) { + const format = new Format(version, prsToReport, log); + const outputPath = Path.resolve(`${filename}.${Format.extension}`); + await asyncPipeline(streamFromIterable(format.print()), Fs.createWriteStream(outputPath)); + log.success(`[${Format.extension}] report written to ${outputPath}`); + } + }, + { + usage: `node scripts/release_notes --token {token} --version {version}`, + flags: { + alias: { + version: 'v', + include: 'i', + }, + string: ['token', 'version', 'format', 'filename', 'include', 'debug-pr'], + default: { + filename: 'report', + version: rootPackageJson.version, + format: extensions, + }, + help: ` + --token (required) The Github access token to use for requests + --version, -v The version to fetch PRs by, PRs with version labels prior to + this one will be ignored (see --include-version) (default ${ + rootPackageJson.version + }) + --include, -i A version that is before --version but shouldn't be considered + "released" and cause PRs with a matching label to be excluded from + release notes. Use this when PRs are labeled with a version that + is less that --version and is expected to be released after + --version, can be specified multiple times. + --format Only produce a certain format, options: "${extensions.join('", "')}" + --filename Output filename, defaults to "report" + --debug-pr Fetch and print the details for a single PR, disabling reporting + `, + }, + description: ` + Fetch details from Github PRs for generating release notes + `, + } + ); +} diff --git a/packages/kbn-release-notes/src/formats/asciidoc.ts b/packages/kbn-release-notes/src/formats/asciidoc.ts new file mode 100644 index 00000000000000..d6c707f009f323 --- /dev/null +++ b/packages/kbn-release-notes/src/formats/asciidoc.ts @@ -0,0 +1,84 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import dedent from 'dedent'; + +import { Format } from './format'; +import { + ASCIIDOC_SECTIONS, + UNKNOWN_ASCIIDOC_SECTION, + AREAS, + UNKNOWN_AREA, +} from '../release_notes_config'; + +function* lines(body: string) { + for (const line of dedent(body).split('\n')) { + yield `${line}\n`; + } +} + +export class AsciidocFormat extends Format { + static extension = 'asciidoc'; + + *print() { + const sortedAreas = [ + ...AREAS.slice().sort((a, b) => a.title.localeCompare(b.title)), + UNKNOWN_AREA, + ]; + + yield* lines(` + [[release-notes-${this.version.label}]] + == ${this.version.label} Release Notes + + Also see <>. + `); + + for (const section of [...ASCIIDOC_SECTIONS, UNKNOWN_ASCIIDOC_SECTION]) { + const prsInSection = this.prs.filter((pr) => pr.asciidocSection === section); + if (!prsInSection.length) { + continue; + } + + yield '\n'; + yield* lines(` + [float] + [[${section.id}-${this.version.label}]] + === ${section.title} + `); + + for (const area of sortedAreas) { + const prsInArea = prsInSection.filter((pr) => pr.area === area); + + if (!prsInArea.length) { + continue; + } + + yield `${area.title}::\n`; + for (const pr of prsInArea) { + const fixes = pr.fixes.length ? `[Fixes ${pr.fixes.join(', ')}] ` : ''; + const strippedTitle = pr.title.replace(/^\s*\[[^\]]+\]\s*/, ''); + yield `* ${fixes}${strippedTitle} {pull}${pr.number}[#${pr.number}]\n`; + if (pr.note) { + yield ` - ${pr.note}\n`; + } + } + } + } + } +} diff --git a/packages/kbn-release-notes/src/formats/csv.ts b/packages/kbn-release-notes/src/formats/csv.ts new file mode 100644 index 00000000000000..0cf99edada696e --- /dev/null +++ b/packages/kbn-release-notes/src/formats/csv.ts @@ -0,0 +1,74 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Format } from './format'; + +/** + * Escape a value to conform to field and header encoding defined at https://tools.ietf.org/html/rfc4180 + */ +function esc(value: string | number) { + if (typeof value === 'number') { + return String(value); + } + + if (!value.includes(',') && !value.includes('\n') && !value.includes('"')) { + return value; + } + + return `"${value.split('"').join('""')}"`; +} + +function row(...fields: Array) { + return fields.map(esc).join(',') + '\r\n'; +} + +export class CsvFormat extends Format { + static extension = 'csv'; + + *print() { + // columns + yield row( + 'areas', + 'versions', + 'user', + 'title', + 'number', + 'url', + 'date', + 'fixes', + 'labels', + 'state' + ); + + for (const pr of this.prs) { + yield row( + pr.area.title, + pr.versions.map((v) => v.label).join(', '), + pr.user.name || pr.user.login, + pr.title, + pr.number, + pr.url, + pr.mergedAt, + pr.fixes.join(', '), + pr.labels.join(', '), + pr.state + ); + } + } +} diff --git a/packages/kbn-release-notes/src/formats/format.ts b/packages/kbn-release-notes/src/formats/format.ts new file mode 100644 index 00000000000000..41b769ab05de77 --- /dev/null +++ b/packages/kbn-release-notes/src/formats/format.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ToolingLog } from '@kbn/dev-utils'; + +import { Version, ClassifiedPr } from '../lib'; + +export abstract class Format { + static extension: string; + + constructor( + protected readonly version: Version, + protected readonly prs: ClassifiedPr[], + protected readonly log: ToolingLog + ) {} + + abstract print(): Iterator; +} diff --git a/packages/kbn-release-notes/src/formats/index.ts b/packages/kbn-release-notes/src/formats/index.ts new file mode 100644 index 00000000000000..3403e445a84ac7 --- /dev/null +++ b/packages/kbn-release-notes/src/formats/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ArrayItem } from '../lib'; +import { AsciidocFormat } from './asciidoc'; +import { CsvFormat } from './csv'; + +export const FORMATS = [CsvFormat, AsciidocFormat] as const; +export type SomeFormat = ArrayItem; diff --git a/packages/kbn-release-notes/src/index.ts b/packages/kbn-release-notes/src/index.ts new file mode 100644 index 00000000000000..a05bc698bde174 --- /dev/null +++ b/packages/kbn-release-notes/src/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './cli'; diff --git a/packages/kbn-release-notes/src/lib/classify_pr.ts b/packages/kbn-release-notes/src/lib/classify_pr.ts new file mode 100644 index 00000000000000..c567935ab7e480 --- /dev/null +++ b/packages/kbn-release-notes/src/lib/classify_pr.ts @@ -0,0 +1,66 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ToolingLog } from '@kbn/dev-utils'; + +import { + Area, + AREAS, + UNKNOWN_AREA, + AsciidocSection, + ASCIIDOC_SECTIONS, + UNKNOWN_ASCIIDOC_SECTION, +} from '../release_notes_config'; +import { PullRequest } from './pull_request'; + +export interface ClassifiedPr extends PullRequest { + area: Area; + asciidocSection: AsciidocSection; +} + +export function classifyPr(pr: PullRequest, log: ToolingLog): ClassifiedPr { + const filter = (a: Area | AsciidocSection) => + a.labels.some((test) => + typeof test === 'string' ? pr.labels.includes(test) : pr.labels.some((l) => l.match(test)) + ); + + const areas = AREAS.filter(filter); + const asciidocSections = ASCIIDOC_SECTIONS.filter(filter); + + const pickOne = (name: string, options: T[]) => { + if (options.length > 1) { + const matches = options.map((o) => o.title).join(', '); + log.warning(`[${pr.terminalLink}] ambiguous ${name}, mulitple match [${matches}]`); + return options[0]; + } + + if (options.length === 0) { + log.error(`[${pr.terminalLink}] unable to determine ${name} because none match`); + return; + } + + return options[0]; + }; + + return { + ...pr, + area: pickOne('area', areas) || UNKNOWN_AREA, + asciidocSection: pickOne('asciidoc section', asciidocSections) || UNKNOWN_ASCIIDOC_SECTION, + }; +} diff --git a/packages/kbn-release-notes/src/lib/get_fix_references.test.ts b/packages/kbn-release-notes/src/lib/get_fix_references.test.ts new file mode 100644 index 00000000000000..bdac66f6cc02f0 --- /dev/null +++ b/packages/kbn-release-notes/src/lib/get_fix_references.test.ts @@ -0,0 +1,68 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getFixReferences } from './get_fix_references'; + +it('returns all fixed issue mentions in the PR text', () => { + expect( + getFixReferences(` + clOses #1 + closes: #2 + clOse #3 + close: #4 + clOsed #5 + closed: #6 + fiX #7 + fix: #8 + fiXes #9 + fixes: #10 + fiXed #11 + fixed: #12 + reSolve #13 + resolve: #14 + reSolves #15 + resolves: #16 + reSolved #17 + resolved: #18 + fixed + #19 + `) + ).toMatchInlineSnapshot(` + Array [ + "#1", + "#2", + "#3", + "#4", + "#5", + "#6", + "#7", + "#8", + "#9", + "#10", + "#11", + "#12", + "#13", + "#14", + "#15", + "#16", + "#17", + "#18", + ] + `); +}); diff --git a/packages/kbn-release-notes/src/lib/get_fix_references.ts b/packages/kbn-release-notes/src/lib/get_fix_references.ts new file mode 100644 index 00000000000000..f45994e90ae899 --- /dev/null +++ b/packages/kbn-release-notes/src/lib/get_fix_references.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const FIXES_RE = /(?:closes|close|closed|fix|fixes|fixed|resolve|resolves|resolved)[ :]*(#\d*)/gi; + +export function getFixReferences(prText: string) { + const fixes: string[] = []; + let match; + while ((match = FIXES_RE.exec(prText))) { + fixes.push(match[1]); + } + return fixes; +} diff --git a/packages/kbn-release-notes/src/lib/get_note_from_description.test.ts b/packages/kbn-release-notes/src/lib/get_note_from_description.test.ts new file mode 100644 index 00000000000000..23dcb302f090d2 --- /dev/null +++ b/packages/kbn-release-notes/src/lib/get_note_from_description.test.ts @@ -0,0 +1,79 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import MarkdownIt from 'markdown-it'; +import dedent from 'dedent'; + +import { getNoteFromDescription } from './get_note_from_description'; + +it('extracts expected components from html', () => { + const mk = new MarkdownIt(); + + expect( + getNoteFromDescription( + mk.render(dedent` + My PR description + + Fixes: #1234 + + ## Release Note: + + Checkout this feature + `) + ) + ).toMatchInlineSnapshot(`"Checkout this feature"`); + + expect( + getNoteFromDescription( + mk.render(dedent` + My PR description + + Fixes: #1234 + + #### Release Note: + + We fixed an issue + `) + ) + ).toMatchInlineSnapshot(`"We fixed an issue"`); + + expect( + getNoteFromDescription( + mk.render(dedent` + My PR description + + Fixes: #1234 + + Release note: Checkout feature foo + `) + ) + ).toMatchInlineSnapshot(`"Checkout feature foo"`); + + expect( + getNoteFromDescription( + mk.render(dedent` + # Summary + + My PR description + + release note : bar + `) + ) + ).toMatchInlineSnapshot(`"bar"`); +}); diff --git a/packages/kbn-release-notes/src/lib/get_note_from_description.ts b/packages/kbn-release-notes/src/lib/get_note_from_description.ts new file mode 100644 index 00000000000000..57df203470a5a4 --- /dev/null +++ b/packages/kbn-release-notes/src/lib/get_note_from_description.ts @@ -0,0 +1,35 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import cheerio from 'cheerio'; + +export function getNoteFromDescription(descriptionHtml: string) { + const $ = cheerio.load(descriptionHtml); + for (const el of $('p,h1,h2,h3,h4,h5').toArray()) { + const text = $(el).text(); + const match = text.match(/^(\s*release note(?:s)?\s*:?\s*)/i); + + if (!match) { + continue; + } + + const note = text.replace(match[1], '').trim(); + return note || $(el).next().text().trim(); + } +} diff --git a/packages/kbn-release-notes/src/lib/index.ts b/packages/kbn-release-notes/src/lib/index.ts new file mode 100644 index 00000000000000..00d8f49cf763fa --- /dev/null +++ b/packages/kbn-release-notes/src/lib/index.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './pull_request'; +export * from './version'; +export * from './is_pr_relevant'; +export * from './streams'; +export * from './type_helpers'; +export * from './irrelevant_pr_summary'; +export * from './classify_pr'; diff --git a/packages/kbn-release-notes/src/lib/irrelevant_pr_summary.ts b/packages/kbn-release-notes/src/lib/irrelevant_pr_summary.ts new file mode 100644 index 00000000000000..1a458a04c7740d --- /dev/null +++ b/packages/kbn-release-notes/src/lib/irrelevant_pr_summary.ts @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ToolingLog } from '@kbn/dev-utils'; + +import { PullRequest } from './pull_request'; +import { Version } from './version'; + +export class IrrelevantPrSummary { + private readonly stats = { + 'skipped by label': new Map(), + 'skipped by label regexp': new Map(), + 'skipped by version': new Map(), + }; + + constructor(private readonly log: ToolingLog) {} + + skippedByLabel(pr: PullRequest, label: string) { + this.log.debug(`${pr.terminalLink} skipped, label [${label}] is ignored`); + this.increment('skipped by label', label); + } + + skippedByLabelRegExp(pr: PullRequest, regexp: RegExp, label: string) { + this.log.debug(`${pr.terminalLink} skipped, label [${label}] matches regexp [${regexp}]`); + this.increment('skipped by label regexp', `${regexp}`); + } + + skippedByVersion(pr: PullRequest, earliestVersion: Version) { + this.log.debug(`${pr.terminalLink} skipped, earliest version is [${earliestVersion.label}]`); + this.increment('skipped by version', earliestVersion.label); + } + + private increment(stat: keyof IrrelevantPrSummary['stats'], key: string) { + const n = this.stats[stat].get(key) || 0; + this.stats[stat].set(key, n + 1); + } + + logStats() { + for (const [description, stats] of Object.entries(this.stats)) { + for (const [key, count] of stats) { + this.log.warning(`${count} ${count === 1 ? 'pr was' : 'prs were'} ${description} [${key}]`); + } + } + } +} diff --git a/packages/kbn-release-notes/src/lib/is_pr_relevant.ts b/packages/kbn-release-notes/src/lib/is_pr_relevant.ts new file mode 100644 index 00000000000000..af2ef9440dedeb --- /dev/null +++ b/packages/kbn-release-notes/src/lib/is_pr_relevant.ts @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Version } from './version'; +import { PullRequest } from './pull_request'; +import { IGNORE_LABELS } from '../release_notes_config'; +import { IrrelevantPrSummary } from './irrelevant_pr_summary'; + +export function isPrRelevant( + pr: PullRequest, + version: Version, + includeVersions: Version[], + summary: IrrelevantPrSummary +) { + for (const label of IGNORE_LABELS) { + if (typeof label === 'string') { + if (pr.labels.includes(label)) { + summary.skippedByLabel(pr, label); + return false; + } + } + + if (label instanceof RegExp) { + const matching = pr.labels.find((l) => label.test(l)); + if (matching) { + summary.skippedByLabelRegExp(pr, label, matching); + return false; + } + } + } + + const [earliestVersion] = Version.sort( + // filter out `includeVersions` so that they won't be considered the "earliest version", only + // versions which are actually before the current `version` or the `version` itself are eligible + pr.versions.filter((v) => !includeVersions.includes(v)), + 'asc' + ); + + if (version !== earliestVersion) { + summary.skippedByVersion(pr, earliestVersion); + return false; + } + + return true; +} diff --git a/packages/kbn-release-notes/src/lib/pull_request.ts b/packages/kbn-release-notes/src/lib/pull_request.ts new file mode 100644 index 00000000000000..e61e496642062a --- /dev/null +++ b/packages/kbn-release-notes/src/lib/pull_request.ts @@ -0,0 +1,206 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { inspect } from 'util'; + +import Axios from 'axios'; +import gql from 'graphql-tag'; +import * as GraphqlPrinter from 'graphql/language/printer'; +import { DocumentNode } from 'graphql/language/ast'; +import makeTerminalLink from 'terminal-link'; +import { ToolingLog } from '@kbn/dev-utils'; + +import { Version } from './version'; +import { getFixReferences } from './get_fix_references'; +import { getNoteFromDescription } from './get_note_from_description'; + +const PrNodeFragment = gql` + fragment PrNode on PullRequest { + number + url + title + bodyText + bodyHTML + mergedAt + baseRefName + state + author { + login + ... on User { + name + } + } + labels(first: 100) { + nodes { + name + } + } + } +`; + +export interface PullRequest { + number: number; + url: string; + title: string; + targetBranch: string; + mergedAt: string; + state: string; + labels: string[]; + fixes: string[]; + user: { + name: string; + login: string; + }; + versions: Version[]; + terminalLink: string; + note?: string; +} + +/** + * Send a single request to the Github v4 GraphQL API + */ +async function gqlRequest( + token: string, + query: DocumentNode, + variables: Record = {} +) { + const resp = await Axios.request({ + url: 'https://api.github.com/graphql', + method: 'POST', + headers: { + 'user-agent': '@kbn/release-notes', + authorization: `bearer ${token}`, + }, + data: { + query: GraphqlPrinter.print(query), + variables, + }, + }); + + return resp.data; +} + +/** + * Convert the Github API response into the structure used by this tool + * + * @param node A GraphQL response from Github using the PrNode fragment + */ +function parsePullRequestNode(node: any): PullRequest { + const terminalLink = makeTerminalLink(`#${node.number}`, node.url); + + const labels: string[] = node.labels.nodes.map((l: { name: string }) => l.name); + + return { + number: node.number, + url: node.url, + terminalLink, + title: node.title, + targetBranch: node.baseRefName, + state: node.state, + mergedAt: node.mergedAt, + labels, + fixes: getFixReferences(node.bodyText), + user: { + login: node.author?.login || 'deleted user', + name: node.author?.name, + }, + versions: labels + .map((l) => Version.fromLabel(l)) + .filter((v): v is Version => v instanceof Version), + note: getNoteFromDescription(node.bodyHTML), + }; +} + +/** + * Iterate all of the PRs which have the `version` label + */ +export async function* iterRelevantPullRequests(token: string, version: Version, log: ToolingLog) { + let nextCursor: string | undefined; + let hasNextPage = true; + + while (hasNextPage) { + const resp = await gqlRequest( + token, + gql` + query($cursor: String, $labels: [String!]) { + repository(owner: "elastic", name: "kibana") { + pullRequests(first: 100, after: $cursor, labels: $labels, states: MERGED) { + pageInfo { + hasNextPage + endCursor + } + nodes { + ...PrNode + } + } + } + } + ${PrNodeFragment} + `, + { + cursor: nextCursor, + labels: [version.label], + } + ); + + const pullRequests = resp.data?.repository?.pullRequests; + if (!pullRequests) { + throw new Error(`unexpected github response, unable to fetch PRs: ${inspect(resp)}`); + } + + hasNextPage = pullRequests.pageInfo?.hasNextPage; + nextCursor = pullRequests.pageInfo?.endCursor; + + if (hasNextPage === undefined || (hasNextPage && !nextCursor)) { + throw new Error( + `github response does not include valid pagination information: ${inspect(resp)}` + ); + } + + for (const node of pullRequests.nodes) { + yield parsePullRequestNode(node); + } + } +} + +export async function getPr(token: string, number: number) { + const resp = await gqlRequest( + token, + gql` + query($number: Int!) { + repository(owner: "elastic", name: "kibana") { + pullRequest(number: $number) { + ...PrNode + } + } + } + ${PrNodeFragment} + `, + { + number, + } + ); + + const node = resp.data?.repository?.pullRequest; + if (!node) { + throw new Error(`unexpected github response, unable to fetch PR: ${inspect(resp)}`); + } + + return parsePullRequestNode(node); +} diff --git a/packages/kbn-release-notes/src/lib/streams.ts b/packages/kbn-release-notes/src/lib/streams.ts new file mode 100644 index 00000000000000..f8cb9ec39186ad --- /dev/null +++ b/packages/kbn-release-notes/src/lib/streams.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { promisify } from 'util'; +import { Readable, pipeline } from 'stream'; + +/** + * @types/node still doesn't have this method that was added + * in 10.17.0 https://nodejs.org/api/stream.html#stream_stream_readable_from_iterable_options + */ +export function streamFromIterable( + iter: Iterable | AsyncIterable +): Readable { + // @ts-ignore + return Readable.from(iter); +} + +export const asyncPipeline = promisify(pipeline); diff --git a/packages/kbn-release-notes/src/lib/type_helpers.ts b/packages/kbn-release-notes/src/lib/type_helpers.ts new file mode 100644 index 00000000000000..c9402b3584951b --- /dev/null +++ b/packages/kbn-release-notes/src/lib/type_helpers.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type ArrayItem = T extends ReadonlyArray ? X : never; diff --git a/packages/kbn-release-notes/src/lib/version.test.ts b/packages/kbn-release-notes/src/lib/version.test.ts new file mode 100644 index 00000000000000..afef2618656977 --- /dev/null +++ b/packages/kbn-release-notes/src/lib/version.test.ts @@ -0,0 +1,146 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Version } from './version'; + +it('parses version labels, returns null on failure', () => { + expect(Version.fromLabel('v1.0.2')).toMatchInlineSnapshot(` + Version { + "label": "v1.0.2", + "major": 1, + "minor": 0, + "patch": 2, + "tag": undefined, + "tagNum": undefined, + "tagOrder": Infinity, + } + `); + expect(Version.fromLabel('v1.0.0')).toMatchInlineSnapshot(` + Version { + "label": "v1.0.0", + "major": 1, + "minor": 0, + "patch": 0, + "tag": undefined, + "tagNum": undefined, + "tagOrder": Infinity, + } + `); + expect(Version.fromLabel('v9.0.2')).toMatchInlineSnapshot(` + Version { + "label": "v9.0.2", + "major": 9, + "minor": 0, + "patch": 2, + "tag": undefined, + "tagNum": undefined, + "tagOrder": Infinity, + } + `); + expect(Version.fromLabel('v9.0.2-alpha0')).toMatchInlineSnapshot(` + Version { + "label": "v9.0.2-alpha0", + "major": 9, + "minor": 0, + "patch": 2, + "tag": "alpha", + "tagNum": 0, + "tagOrder": 1, + } + `); + expect(Version.fromLabel('v9.0.2-beta1')).toMatchInlineSnapshot(` + Version { + "label": "v9.0.2-beta1", + "major": 9, + "minor": 0, + "patch": 2, + "tag": "beta", + "tagNum": 1, + "tagOrder": 2, + } + `); + expect(Version.fromLabel('v9.0')).toMatchInlineSnapshot(`undefined`); + expect(Version.fromLabel('some:area')).toMatchInlineSnapshot(`undefined`); +}); + +it('sorts versions in ascending order', () => { + const versions = [ + 'v1.7.3', + 'v1.7.0', + 'v1.5.0', + 'v2.7.0', + 'v7.0.0-beta2', + 'v7.0.0-alpha1', + 'v2.0.0', + 'v0.0.0', + 'v7.0.0-beta1', + 'v7.0.0', + ].map((l) => Version.fromLabel(l)!); + + const sorted = Version.sort(versions); + + expect(sorted.map((v) => v.label)).toMatchInlineSnapshot(` + Array [ + "v0.0.0", + "v1.5.0", + "v1.7.0", + "v1.7.3", + "v2.0.0", + "v2.7.0", + "v7.0.0-alpha1", + "v7.0.0-beta1", + "v7.0.0-beta2", + "v7.0.0", + ] + `); + + // ensure versions was not mutated + expect(sorted).not.toEqual(versions); +}); + +it('sorts versions in decending order', () => { + const versions = [ + 'v1.7.3', + 'v1.7.0', + 'v1.5.0', + 'v7.0.0-beta1', + 'v2.7.0', + 'v2.0.0', + 'v0.0.0', + 'v7.0.0', + ].map((l) => Version.fromLabel(l)!); + + const sorted = Version.sort(versions, 'desc'); + + expect(sorted.map((v) => v.label)).toMatchInlineSnapshot(` + Array [ + "v7.0.0", + "v7.0.0-beta1", + "v2.7.0", + "v2.0.0", + "v1.7.3", + "v1.7.0", + "v1.5.0", + "v0.0.0", + ] + `); + + // ensure versions was not mutated + expect(sorted).not.toEqual(versions); +}); diff --git a/packages/kbn-release-notes/src/lib/version.ts b/packages/kbn-release-notes/src/lib/version.ts new file mode 100644 index 00000000000000..e0a5c5e177c82a --- /dev/null +++ b/packages/kbn-release-notes/src/lib/version.ts @@ -0,0 +1,123 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const LABEL_RE = /^v(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta)(\d+))?$/; + +const versionCache = new Map(); + +const multiCompare = (...diffs: number[]) => { + for (const diff of diffs) { + if (diff !== 0) { + return diff; + } + } + return 0; +}; + +export class Version { + static fromFlag(flag: string | string[] | boolean | undefined) { + if (typeof flag !== 'string') { + return; + } + + return Version.fromLabel(flag) || Version.fromLabel(`v${flag}`); + } + + static fromFlags(flag: string | string[] | boolean | undefined) { + const flags = Array.isArray(flag) ? flag : [flag]; + const versions: Version[] = []; + + for (const f of flags) { + const version = Version.fromFlag(f); + if (!version) { + return; + } + versions.push(version); + } + + return versions; + } + + static fromLabel(label: string) { + const match = label.match(LABEL_RE); + if (!match) { + return; + } + + const cached = versionCache.get(label); + if (cached) { + return cached; + } + + const [, major, minor, patch, tag, tagNum] = match; + const version = new Version( + parseInt(major, 10), + parseInt(minor, 10), + parseInt(patch, 10), + tag as 'alpha' | 'beta' | undefined, + tagNum ? parseInt(tagNum, 10) : undefined + ); + + versionCache.set(label, version); + return version; + } + + static sort(versions: Version[], dir: 'asc' | 'desc' = 'asc') { + const order = dir === 'asc' ? 1 : -1; + + return versions.slice().sort((a, b) => a.compare(b) * order); + } + + public readonly label = `v${this.major}.${this.minor}.${this.patch}${ + this.tag ? `-${this.tag}${this.tagNum}` : '' + }`; + private readonly tagOrder: number; + + constructor( + public readonly major: number, + public readonly minor: number, + public readonly patch: number, + public readonly tag: 'alpha' | 'beta' | undefined, + public readonly tagNum: number | undefined + ) { + switch (tag) { + case undefined: + this.tagOrder = Infinity; + break; + case 'alpha': + this.tagOrder = 1; + break; + case 'beta': + this.tagOrder = 2; + break; + default: + throw new Error('unexpected tag'); + } + } + + compare(other: Version) { + return multiCompare( + this.major - other.major, + this.minor - other.minor, + this.patch - other.patch, + this.tagOrder - other.tagOrder, + (this.tagNum ?? 0) - (other.tagNum ?? 0) + ); + } +} diff --git a/packages/kbn-release-notes/src/release_notes_config.ts b/packages/kbn-release-notes/src/release_notes_config.ts new file mode 100644 index 00000000000000..88ab5dfa2fda43 --- /dev/null +++ b/packages/kbn-release-notes/src/release_notes_config.ts @@ -0,0 +1,294 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Exclude any PR from release notes that has a matching label. String + * labels must match exactly, for more complicated use a RegExp + */ +export const IGNORE_LABELS: Array = [ + 'Team:Docs', + ':KibanaApp/fix-it-week', + 'reverted', + /^test/, + 'non-issue', + 'jenkins', + 'build', + 'chore', + 'backport', + 'release_note:skip', + 'release_note:dev_docs', +]; + +/** + * Define areas that are used to categorize changes in the release notes + * based on the labels a PR has. the `labels` array can contain strings, which + * are matched exactly, or regular expressions. The first area, in definition + * order, which has a `label` which matches and label on a PR is the area + * assigned to that PR. + */ + +export interface Area { + title: string; + labels: Array; +} + +export const AREAS: Area[] = [ + { + title: 'Design', + labels: ['Team:Design', 'Project:Accessibility'], + }, + { + title: 'Logstash', + labels: ['App:Logstash', 'Feature:Logstash Pipelines'], + }, + { + title: 'Management', + labels: [ + 'Feature:license', + 'Feature:Console', + 'Feature:Search Profiler', + 'Feature:watcher', + 'Feature:Index Patterns', + 'Feature:Kibana Management', + 'Feature:Dev Tools', + 'Feature:Inspector', + 'Feature:Index Management', + 'Feature:Snapshot and Restore', + 'Team:Elasticsearch UI', + 'Feature:FieldFormatters', + 'Feature:CCR', + 'Feature:ILM', + 'Feature:Transforms', + ], + }, + { + title: 'Monitoring', + labels: ['Team:Monitoring', 'Feature:Telemetry', 'Feature:Stack Monitoring'], + }, + { + title: 'Operations', + labels: ['Team:Operations', 'Feature:License'], + }, + { + title: 'Kibana UI', + labels: ['Kibana UI', 'Team:Core UI', 'Feature:Header'], + }, + { + title: 'Platform', + labels: [ + 'Team:Platform', + 'Feature:Plugins', + 'Feature:New Platform', + 'Project:i18n', + 'Feature:ExpressionLanguage', + 'Feature:Saved Objects', + 'Team:Stack Services', + 'Feature:NP Migration', + 'Feature:Task Manager', + 'Team:Pulse', + ], + }, + { + title: 'Machine Learning', + labels: [ + ':ml', + 'Feature:Anomaly Detection', + 'Feature:Data Frames', + 'Feature:File Data Viz', + 'Feature:ml-results', + 'Feature:Data Frame Analytics', + ], + }, + { + title: 'Maps', + labels: ['Team:Geo'], + }, + { + title: 'Canvas', + labels: ['Team:Canvas'], + }, + { + title: 'QA', + labels: ['Team:QA'], + }, + { + title: 'Security', + labels: [ + 'Team:Security', + 'Feature:Security/Spaces', + 'Feature:users and roles', + 'Feature:Security/Authentication', + 'Feature:Security/Authorization', + 'Feature:Security/Feature Controls', + ], + }, + { + title: 'Dashboard', + labels: ['Feature:Dashboard', 'Feature:Drilldowns'], + }, + { + title: 'Discover', + labels: ['Feature:Discover'], + }, + { + title: 'Kibana Home & Add Data', + labels: ['Feature:Add Data', 'Feature:Home'], + }, + { + title: 'Querying & Filtering', + labels: [ + 'Feature:Query Bar', + 'Feature:Courier', + 'Feature:Filters', + 'Feature:Timepicker', + 'Feature:Highlight', + 'Feature:KQL', + 'Feature:Rollups', + ], + }, + { + title: 'Reporting', + labels: ['Feature:Reporting', 'Team:Reporting Services'], + }, + { + title: 'Sharing', + labels: ['Feature:Embedding', 'Feature:SharingURLs'], + }, + { + title: 'Visualizations', + labels: [ + 'Feature:Timelion', + 'Feature:TSVB', + 'Feature:Coordinate Map', + 'Feature:Region Map', + 'Feature:Vega', + 'Feature:Gauge Vis', + 'Feature:Tagcloud', + 'Feature:Vis Loader', + 'Feature:Vislib', + 'Feature:Vis Editor', + 'Feature:Aggregations', + 'Feature:Input Control', + 'Feature:Visualizations', + 'Feature:Markdown', + 'Feature:Data Table', + 'Feature:Heatmap', + 'Feature:Pie Chart', + 'Feature:XYAxis', + 'Feature:Graph', + 'Feature:New Feature', + 'Feature:MetricVis', + ], + }, + { + title: 'SIEM', + labels: ['Team:SIEM'], + }, + { + title: 'Code', + labels: ['Team:Code'], + }, + { + title: 'Infrastructure', + labels: ['App:Infrastructure', 'Feature:Infra UI', 'Feature:Service Maps'], + }, + { + title: 'Logs', + labels: ['App:Logs', 'Feature:Logs UI'], + }, + { + title: 'Uptime', + labels: ['App:Uptime', 'Feature:Uptime', 'Team:uptime'], + }, + { + title: 'Beats Management', + labels: ['App:Beats', 'Feature:beats-cm', 'Team:Beats'], + }, + { + title: 'APM', + labels: ['Team:apm', /^apm[:\-]/], + }, + { + title: 'Lens', + labels: ['App:Lens', 'Feature:Lens'], + }, + { + title: 'Alerting', + labels: ['App:Alerting', 'Feature:Alerting', 'Team:Alerting Services', 'Feature:Actions'], + }, + { + title: 'Metrics', + labels: ['App:Metrics', 'Feature:Metrics UI', 'Team:logs-metrics-ui'], + }, + { + title: 'Data ingest', + labels: ['Ingest', 'Feature:Ingest Node Pipelines'], + }, +]; + +export const UNKNOWN_AREA: Area = { + title: 'Unknown', + labels: [], +}; + +/** + * Define the sections that will be assigned to PRs when generating the + * asciidoc formatted report. The order of the sections determines the + * order they will be rendered in the report + */ + +export interface AsciidocSection { + title: string; + labels: Array; + id: string; +} + +export const ASCIIDOC_SECTIONS: AsciidocSection[] = [ + { + id: 'enhancement', + title: 'Enhancements', + labels: ['release_note:enhancement'], + }, + { + id: 'bug', + title: 'Bug fixes', + labels: ['release_note:fix'], + }, + { + id: 'roadmap', + title: 'Roadmap', + labels: ['release_note:roadmap'], + }, + { + id: 'deprecation', + title: 'Deprecations', + labels: ['release_note:deprecation'], + }, + { + id: 'breaking', + title: 'Breaking Changes', + labels: ['release_note:breaking'], + }, +]; + +export const UNKNOWN_ASCIIDOC_SECTION: AsciidocSection = { + id: 'unknown', + title: 'Unknown', + labels: [], +}; diff --git a/packages/kbn-release-notes/tsconfig.json b/packages/kbn-release-notes/tsconfig.json new file mode 100644 index 00000000000000..6ffa64d91fba0f --- /dev/null +++ b/packages/kbn-release-notes/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "declaration": true, + "sourceMap": true, + "target": "ES2019" + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/kbn-release-notes/yarn.lock b/packages/kbn-release-notes/yarn.lock new file mode 120000 index 00000000000000..3f82ebc9cdbae3 --- /dev/null +++ b/packages/kbn-release-notes/yarn.lock @@ -0,0 +1 @@ +../../yarn.lock \ No newline at end of file diff --git a/scripts/release_notes.js b/scripts/release_notes.js new file mode 100644 index 00000000000000..f46ee5823d70d1 --- /dev/null +++ b/scripts/release_notes.js @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +require('../src/setup_node_env/prebuilt_dev_only_entry'); +require('@kbn/release-notes').runReleaseNotesCli(); diff --git a/src/core/public/plugins/plugin_reader.test.ts b/src/core/public/plugins/plugin_reader.test.ts index d4324f81de8e64..b3bc84f30daac7 100644 --- a/src/core/public/plugins/plugin_reader.test.ts +++ b/src/core/public/plugins/plugin_reader.test.ts @@ -17,25 +17,37 @@ * under the License. */ -import { CoreWindow, read, UnknownPluginInitializer } from './plugin_reader'; +import { CoreWindow, read } from './plugin_reader'; + +const coreWindow: CoreWindow & { + __kbnBundles__: { stub(key: string, value: any): void }; +} = window as any; -const coreWindow: CoreWindow = window as any; beforeEach(() => { - coreWindow.__kbnBundles__ = {}; + const stubs = new Map(); + coreWindow.__kbnBundles__ = { + get(key) { + return stubs.get(key); + }, + has(key) { + return stubs.has(key); + }, + stub(key, value) { + stubs.set(key, value); + }, + }; }); it('handles undefined plugin exports', () => { - coreWindow.__kbnBundles__['plugin/foo'] = undefined; - expect(() => { read('foo'); }).toThrowError(`Definition of plugin "foo" not found and may have failed to load.`); }); it('handles plugin exports with a "plugin" export that is not a function', () => { - coreWindow.__kbnBundles__['plugin/foo'] = { + coreWindow.__kbnBundles__.stub('plugin/foo/public', { plugin: 1234, - } as any; + }); expect(() => { read('foo'); @@ -43,11 +55,8 @@ it('handles plugin exports with a "plugin" export that is not a function', () => }); it('returns the plugin initializer when the "plugin" named export is a function', () => { - const plugin: UnknownPluginInitializer = () => { - return undefined as any; - }; - - coreWindow.__kbnBundles__['plugin/foo'] = { plugin }; + const plugin = () => {}; + coreWindow.__kbnBundles__.stub('plugin/foo/public', { plugin }); expect(read('foo')).toBe(plugin); }); diff --git a/src/core/public/plugins/plugin_reader.ts b/src/core/public/plugins/plugin_reader.ts index 1907dfa6a3e99c..d80bda7483775a 100644 --- a/src/core/public/plugins/plugin_reader.ts +++ b/src/core/public/plugins/plugin_reader.ts @@ -31,7 +31,8 @@ export type UnknownPluginInitializer = PluginInitializer { optionalPlugins: true, ui: true, server: true, + extraPublicDirs: true, }; return new Set(Object.keys(manifestFields)); @@ -70,7 +71,11 @@ const KNOWN_MANIFEST_FIELDS = (() => { * @param packageInfo Kibana package info. * @internal */ -export async function parseManifest(pluginPath: string, packageInfo: PackageInfo, log: Logger) { +export async function parseManifest( + pluginPath: string, + packageInfo: PackageInfo, + log: Logger +): Promise { const manifestPath = resolve(pluginPath, MANIFEST_FILE_NAME); let manifestContent; @@ -130,6 +135,19 @@ export async function parseManifest(pluginPath: string, packageInfo: PackageInfo ); } + if ( + manifest.extraPublicDirs && + (!Array.isArray(manifest.extraPublicDirs) || + !manifest.extraPublicDirs.every((dir) => typeof dir === 'string')) + ) { + throw PluginDiscoveryError.invalidManifest( + manifestPath, + new Error( + `The "extraPublicDirs" in plugin manifest for "${manifest.id}" should be an array of strings.` + ) + ); + } + const expectedKibanaVersion = typeof manifest.kibanaVersion === 'string' && manifest.kibanaVersion ? manifest.kibanaVersion @@ -175,6 +193,7 @@ export async function parseManifest(pluginPath: string, packageInfo: PackageInfo optionalPlugins: Array.isArray(manifest.optionalPlugins) ? manifest.optionalPlugins : [], ui: includesUiPlugin, server: includesServerPlugin, + extraPublicDirs: manifest.extraPublicDirs, }; } diff --git a/src/core/server/plugins/plugins_system.test.ts b/src/core/server/plugins/plugins_system.test.ts index 8b318ad1b735eb..70983e4fd087b5 100644 --- a/src/core/server/plugins/plugins_system.test.ts +++ b/src/core/server/plugins/plugins_system.test.ts @@ -34,6 +34,7 @@ import { PluginWrapper } from './plugin'; import { PluginName } from './types'; import { PluginsSystem } from './plugins_system'; import { coreMock } from '../mocks'; +import { Logger } from '../logging'; const logger = loggingServiceMock.create(); function createPlugin( @@ -435,6 +436,21 @@ describe('setup', () => { `[Error: Setup lifecycle of "timeout-setup" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.]` ); }); + + it('logs only server-side plugins', async () => { + [ + createPlugin('order-0'), + createPlugin('order-not-run', { server: false }), + createPlugin('order-1'), + ].forEach((plugin, index) => { + jest.spyOn(plugin, 'setup').mockResolvedValue(`setup-as-${index}`); + jest.spyOn(plugin, 'start').mockResolvedValue(`started-as-${index}`); + pluginsSystem.addPlugin(plugin); + }); + await pluginsSystem.setupPlugins(setupDeps); + const log = logger.get.mock.results[0].value as jest.Mocked; + expect(log.info).toHaveBeenCalledWith(`Setting up [2] plugins: [order-1,order-0]`); + }); }); describe('start', () => { @@ -461,4 +477,20 @@ describe('start', () => { `[Error: Start lifecycle of "timeout-start" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.]` ); }); + + it('logs only server-side plugins', async () => { + [ + createPlugin('order-0'), + createPlugin('order-not-run', { server: false }), + createPlugin('order-1'), + ].forEach((plugin, index) => { + jest.spyOn(plugin, 'setup').mockResolvedValue(`setup-as-${index}`); + jest.spyOn(plugin, 'start').mockResolvedValue(`started-as-${index}`); + pluginsSystem.addPlugin(plugin); + }); + await pluginsSystem.setupPlugins(setupDeps); + await pluginsSystem.startPlugins(startDeps); + const log = logger.get.mock.results[0].value as jest.Mocked; + expect(log.info).toHaveBeenCalledWith(`Starting [2] plugins: [order-1,order-0]`); + }); }); diff --git a/src/core/server/plugins/plugins_system.ts b/src/core/server/plugins/plugins_system.ts index e0401006ffac96..1bf1f4b189a676 100644 --- a/src/core/server/plugins/plugins_system.ts +++ b/src/core/server/plugins/plugins_system.ts @@ -66,15 +66,16 @@ export class PluginsSystem { return contracts; } - const sortedPlugins = this.getTopologicallySortedPluginNames(); - this.log.info(`Setting up [${this.plugins.size}] plugins: [${[...sortedPlugins]}]`); - - for (const pluginName of sortedPlugins) { - const plugin = this.plugins.get(pluginName)!; - if (!plugin.includesServerPlugin) { - continue; - } + const sortedPlugins = new Map( + [...this.getTopologicallySortedPluginNames()] + .map((pluginName) => [pluginName, this.plugins.get(pluginName)!] as [string, PluginWrapper]) + .filter(([pluginName, plugin]) => plugin.includesServerPlugin) + ); + this.log.info( + `Setting up [${sortedPlugins.size}] plugins: [${[...sortedPlugins.keys()].join(',')}]` + ); + for (const [pluginName, plugin] of sortedPlugins) { this.log.debug(`Setting up plugin "${pluginName}"...`); const pluginDeps = new Set([...plugin.requiredPlugins, ...plugin.optionalPlugins]); const pluginDepContracts = Array.from(pluginDeps).reduce((depContracts, dependencyName) => { diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index 4fa4e1780e596c..2ca5c9f6ed3c56 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -153,6 +153,14 @@ export interface PluginManifest { * Specifies whether plugin includes some server-side specific functionality. */ readonly server: boolean; + + /** + * Specifies directory names that can be imported by other ui-plugins built + * using the same instance of the @kbn/optimizer. A temporary measure we plan + * to replace with better mechanisms for sharing static code between plugins + * @deprecated + */ + readonly extraPublicDirs?: string[]; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 78cc02d39e6c43..ecfa09fbd37f39 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1520,6 +1520,8 @@ export interface PluginInitializerContext { // @public export interface PluginManifest { readonly configPath: ConfigPath; + // @deprecated + readonly extraPublicDirs?: string[]; readonly id: PluginName; readonly kibanaVersion: string; readonly optionalPlugins: readonly PluginName[]; @@ -2549,8 +2551,8 @@ export const validBodyOutput: readonly ["data", "stream"]; // src/core/server/legacy/types.ts:165:3 - (ae-forgotten-export) The symbol "LegacyNavLinkSpec" needs to be exported by the entry point index.d.ts // src/core/server/legacy/types.ts:166:3 - (ae-forgotten-export) The symbol "LegacyAppSpec" needs to be exported by the entry point index.d.ts // src/core/server/legacy/types.ts:167:16 - (ae-forgotten-export) The symbol "LegacyPluginSpec" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:230:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:230:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:232:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:238:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:238:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:240:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts ``` diff --git a/src/core/server/ui_settings/saved_objects/migrations.test.ts b/src/core/server/ui_settings/saved_objects/migrations.test.ts new file mode 100644 index 00000000000000..1620995c932c49 --- /dev/null +++ b/src/core/server/ui_settings/saved_objects/migrations.test.ts @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { migrations } from './migrations'; + +describe('ui_settings 7.9.0 migrations', () => { + const migration = migrations['7.9.0']; + + test('returns doc on empty object', () => { + expect(migration({} as SavedObjectUnsanitizedDoc)).toEqual({ + references: [], + }); + }); + test('properly renames siem attributes to securitySolution', () => { + const doc = { + type: 'config', + id: '8.0.0', + attributes: { + buildNum: 9007199254740991, + 'siem:defaultAnomalyScore': 59, + 'siem:enableNewsFeed': false, + }, + references: [], + updated_at: '2020-06-09T20:18:20.349Z', + migrationVersion: {}, + }; + expect(migration(doc)).toEqual({ + type: 'config', + id: '8.0.0', + attributes: { + buildNum: 9007199254740991, + 'securitySolution:defaultAnomalyScore': 59, + 'securitySolution:enableNewsFeed': false, + }, + references: [], + updated_at: '2020-06-09T20:18:20.349Z', + migrationVersion: {}, + }); + }); +}); diff --git a/src/core/server/ui_settings/saved_objects/migrations.ts b/src/core/server/ui_settings/saved_objects/migrations.ts new file mode 100644 index 00000000000000..750d4e6bc1ea6d --- /dev/null +++ b/src/core/server/ui_settings/saved_objects/migrations.ts @@ -0,0 +1,42 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from 'kibana/server'; + +export const migrations = { + '7.9.0': (doc: SavedObjectUnsanitizedDoc): SavedObjectSanitizedDoc => ({ + ...doc, + ...(doc.attributes && { + attributes: Object.keys(doc.attributes).reduce( + (acc, key) => + key.startsWith('siem:') + ? { + ...acc, + [key.replace('siem', 'securitySolution')]: doc.attributes[key], + } + : { + ...acc, + [key]: doc.attributes[key], + }, + {} + ), + }), + references: doc.references || [], + }), +}; diff --git a/src/core/server/ui_settings/saved_objects/ui_settings.ts b/src/core/server/ui_settings/saved_objects/ui_settings.ts index 0eab40a7b3a5db..26704f46a509c4 100644 --- a/src/core/server/ui_settings/saved_objects/ui_settings.ts +++ b/src/core/server/ui_settings/saved_objects/ui_settings.ts @@ -18,6 +18,7 @@ */ import { SavedObjectsType } from '../../saved_objects'; +import { migrations } from './migrations'; export const uiSettingsType: SavedObjectsType = { name: 'config', @@ -46,4 +47,5 @@ export const uiSettingsType: SavedObjectsType = { return `Advanced Settings [${obj.id}]`; }, }, + migrations, }; diff --git a/src/es_archiver/cli.ts b/src/es_archiver/cli.ts index 98888b81d9a315..85e10b31a87ee3 100644 --- a/src/es_archiver/cli.ts +++ b/src/es_archiver/cli.ts @@ -67,9 +67,10 @@ cmd .action((name, indices) => execute((archiver, { raw }) => archiver.save(name, indices, { raw }))); cmd + .option('--use-create', 'use create instead of index for loading documents') .command('load ') .description('load the archive in --dir with ') - .action((name) => execute((archiver) => archiver.load(name))); + .action((name) => execute((archiver, { useCreate }) => archiver.load(name, { useCreate }))); cmd .command('unload ') diff --git a/src/legacy/ui/ui_render/bootstrap/app_bootstrap.js b/src/legacy/ui/ui_render/bootstrap/app_bootstrap.js index 0e6936dd64a151..19f75317883d79 100644 --- a/src/legacy/ui/ui_render/bootstrap/app_bootstrap.js +++ b/src/legacy/ui/ui_render/bootstrap/app_bootstrap.js @@ -22,9 +22,11 @@ import { createHash } from 'crypto'; import { readFile } from 'fs'; import { resolve } from 'path'; +import { kbnBundlesLoaderSource } from './kbn_bundles_loader_source'; + export class AppBootstrap { constructor({ templateData }) { - this.templateData = templateData; + this.templateData = { ...templateData, kbnBundlesLoaderSource }; this._rawTemplate = undefined; } diff --git a/src/legacy/ui/ui_render/bootstrap/kbn_bundles_loader_source.js b/src/legacy/ui/ui_render/bootstrap/kbn_bundles_loader_source.js new file mode 100644 index 00000000000000..cb5488118cc772 --- /dev/null +++ b/src/legacy/ui/ui_render/bootstrap/kbn_bundles_loader_source.js @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +module.exports = { + kbnBundlesLoaderSource: `(${kbnBundlesLoader.toString()})();`, +}; + +function kbnBundlesLoader() { + var modules = {}; + + function has(prop) { + return Object.prototype.hasOwnProperty.call(modules, prop); + } + + function define(key, bundleRequire, bundleModuleKey) { + if (has(key)) { + throw new Error('__kbnBundles__ already has a module defined for "' + key + '"'); + } + + modules[key] = { + bundleRequire, + bundleModuleKey, + }; + } + + function get(key) { + if (!has(key)) { + throw new Error('__kbnBundles__ does not have a module defined for "' + key + '"'); + } + + return modules[key].bundleRequire(modules[key].bundleModuleKey); + } + + return { has: has, define: define, get: get }; +} diff --git a/src/legacy/ui/ui_render/bootstrap/template.js.hbs b/src/legacy/ui/ui_render/bootstrap/template.js.hbs index e8f05b46f70611..ca2e944489a734 100644 --- a/src/legacy/ui/ui_render/bootstrap/template.js.hbs +++ b/src/legacy/ui/ui_render/bootstrap/template.js.hbs @@ -3,6 +3,7 @@ window.__kbnStrictCsp__ = kbnCsp.strictCsp; window.__kbnDarkMode__ = {{darkMode}}; window.__kbnThemeVersion__ = "{{themeVersion}}"; window.__kbnPublicPath__ = {{publicPathMap}}; +window.__kbnBundles__ = {{kbnBundlesLoaderSource}} if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) { var legacyBrowserError = document.getElementById('kbn_legacy_browser_error'); @@ -78,12 +79,7 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) { {{/each}} ], function () { {{#unless legacyBundlePath}} - if (!__kbnBundles__ || !__kbnBundles__['entry/core'] || typeof __kbnBundles__['entry/core'].__kbnBootstrap__ !== 'function') { - console.error('entry/core bundle did not load correctly'); - failure(); - } else { - __kbnBundles__['entry/core'].__kbnBootstrap__() - } + __kbnBundles__.get('entry/core/public').__kbnBootstrap__(); {{/unless}} load([ diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index b09d4861b343ba..673e19155879a3 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -26,8 +26,6 @@ import { AppBootstrap } from './bootstrap'; import { getApmConfig } from '../apm'; import { DllCompiler } from '../../../optimize/dynamic_dll_plugin'; -const uniq = (...items) => Array.from(new Set(items)); - /** * @typedef {import('../../server/kbn_server').default} KbnServer * @typedef {import('../../server/kbn_server').ResponseToolkit} ResponseToolkit @@ -150,15 +148,7 @@ export function uiRenderMixin(kbnServer, server, config) { ]), ]; - const kpPluginIds = uniq( - // load these plugins first, they are "shared" and other bundles access their - // public/index exports without considering topographic sorting by plugin deps (for now) - 'kibanaUtils', - 'kibanaReact', - 'data', - 'esUiShared', - ...kbnServer.newPlatform.__internals.uiPlugins.public.keys() - ); + const kpPluginIds = Array.from(kbnServer.newPlatform.__internals.uiPlugins.public.keys()); const jsDependencyPaths = [ ...UiSharedDeps.jsDepFilenames.map( diff --git a/src/plugins/data/kibana.json b/src/plugins/data/kibana.json index f5df747f17e1ea..3e5d96a4bc47bf 100644 --- a/src/plugins/data/kibana.json +++ b/src/plugins/data/kibana.json @@ -7,5 +7,6 @@ "expressions", "uiActions" ], - "optionalPlugins": ["usageCollection"] + "optionalPlugins": ["usageCollection"], + "extraPublicDirs": ["common", "common/utils/abort_utils"] } diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/generic_combo_box.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/generic_combo_box.tsx index 66adbfe9a6fc32..c66518c18a9eab 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/generic_combo_box.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/generic_combo_box.tsx @@ -59,6 +59,7 @@ export function GenericComboBox(props: GenericComboBoxProps) { options={euiOptions} selectedOptions={selectedEuiOptions} onChange={onComboBoxChange} + sortMatchesBy="startsWith" {...otherProps} /> ); diff --git a/src/plugins/embeddable/kibana.json b/src/plugins/embeddable/kibana.json index 535527b4d09db1..06b0e88da334fa 100644 --- a/src/plugins/embeddable/kibana.json +++ b/src/plugins/embeddable/kibana.json @@ -6,5 +6,8 @@ "requiredPlugins": [ "inspector", "uiActions" + ], + "extraPublicDirs": [ + "public/lib/test_samples" ] } diff --git a/src/plugins/es_ui_shared/kibana.json b/src/plugins/es_ui_shared/kibana.json index 5f772b3c83da95..980f43ea46a68f 100644 --- a/src/plugins/es_ui_shared/kibana.json +++ b/src/plugins/es_ui_shared/kibana.json @@ -2,5 +2,13 @@ "id": "esUiShared", "version": "kibana", "ui": true, - "server": true + "server": true, + "extraPublicDirs": [ + "static/ace_x_json/hooks", + "static/validators/string", + "static/forms/hook_form_lib", + "static/forms/helpers", + "static/forms/components", + "static/forms/helpers/field_validators/types" + ] } diff --git a/src/plugins/expressions/kibana.json b/src/plugins/expressions/kibana.json index 5d2112103e94d3..4774c69cc29ffd 100644 --- a/src/plugins/expressions/kibana.json +++ b/src/plugins/expressions/kibana.json @@ -5,5 +5,6 @@ "ui": true, "requiredPlugins": [ "bfetch" - ] + ], + "extraPublicDirs": ["common", "common/fonts"] } diff --git a/src/plugins/inspector/kibana.json b/src/plugins/inspector/kibana.json index 39d3ff65eed531..99a38d2928df69 100644 --- a/src/plugins/inspector/kibana.json +++ b/src/plugins/inspector/kibana.json @@ -2,5 +2,6 @@ "id": "inspector", "version": "kibana", "server": false, - "ui": true + "ui": true, + "extraPublicDirs": ["common", "common/adapters/request"] } diff --git a/src/plugins/kibana_legacy/kibana.json b/src/plugins/kibana_legacy/kibana.json index e96b4859a36d0b..606acd8b88b050 100644 --- a/src/plugins/kibana_legacy/kibana.json +++ b/src/plugins/kibana_legacy/kibana.json @@ -2,5 +2,6 @@ "id": "kibanaLegacy", "version": "kibana", "server": true, - "ui": true + "ui": true, + "extraPublicDirs": ["common/kbn_base_url"] } diff --git a/src/plugins/kibana_utils/kibana.json b/src/plugins/kibana_utils/kibana.json index 6fa39d82d10215..7e2127c27548e0 100644 --- a/src/plugins/kibana_utils/kibana.json +++ b/src/plugins/kibana_utils/kibana.json @@ -1,5 +1,10 @@ { "id": "kibanaUtils", "version": "kibana", - "ui": true + "ui": true, + "extraPublicDirs": [ + "common", + "demos/state_containers/todomvc", + "common/state_containers" + ] } diff --git a/src/plugins/maps_legacy/public/leaflet.js b/src/plugins/maps_legacy/public/leaflet.js index e36da2c52b8c51..bee75021c76ade 100644 --- a/src/plugins/maps_legacy/public/leaflet.js +++ b/src/plugins/maps_legacy/public/leaflet.js @@ -17,8 +17,6 @@ * under the License. */ -export let L; - if (!window.hasOwnProperty('L')) { require('leaflet/dist/leaflet.css'); window.L = require('leaflet/dist/leaflet.js'); @@ -31,6 +29,6 @@ if (!window.hasOwnProperty('L')) { require('leaflet-draw/dist/leaflet.draw.js'); require('leaflet-responsive-popup/leaflet.responsive.popup.css'); require('leaflet-responsive-popup/leaflet.responsive.popup.js'); -} else { - L = window.L; } + +export const L = window.L; diff --git a/src/plugins/saved_objects_management/kibana.json b/src/plugins/saved_objects_management/kibana.json index 22135ce4558ae4..6184d890c415cf 100644 --- a/src/plugins/saved_objects_management/kibana.json +++ b/src/plugins/saved_objects_management/kibana.json @@ -4,5 +4,6 @@ "server": true, "ui": true, "requiredPlugins": ["home", "management", "data"], - "optionalPlugins": ["dashboard", "visualizations", "discover"] + "optionalPlugins": ["dashboard", "visualizations", "discover"], + "extraPublicDirs": ["public/lib"] } diff --git a/src/plugins/telemetry/kibana.json b/src/plugins/telemetry/kibana.json index f623f4f2a565dc..a4975977625207 100644 --- a/src/plugins/telemetry/kibana.json +++ b/src/plugins/telemetry/kibana.json @@ -6,5 +6,8 @@ "requiredPlugins": [ "telemetryCollectionManager", "usageCollection" + ], + "extraPublicDirs": [ + "common/constants" ] } diff --git a/src/plugins/ui_actions/kibana.json b/src/plugins/ui_actions/kibana.json index 44ecbbfa684087..907cbabbdf9c97 100644 --- a/src/plugins/ui_actions/kibana.json +++ b/src/plugins/ui_actions/kibana.json @@ -2,5 +2,8 @@ "id": "uiActions", "version": "kibana", "server": false, - "ui": true + "ui": true, + "extraPublicDirs": [ + "public/tests/test_samples" + ] } diff --git a/test/api_integration/apis/saved_objects/bulk_get.js b/test/api_integration/apis/saved_objects/bulk_get.js index 23aa175740b67e..c802d529130651 100644 --- a/test/api_integration/apis/saved_objects/bulk_get.js +++ b/test/api_integration/apis/saved_objects/bulk_get.js @@ -94,6 +94,7 @@ export default function ({ getService }) { buildNum: 8467, defaultIndex: '91200a00-9efd-11e7-acb3-3dab96693fab', }, + migrationVersion: resp.body.saved_objects[2].migrationVersion, references: [], }, ], diff --git a/test/examples/embeddables/adding_children.ts b/test/examples/embeddables/adding_children.ts index 4e3d9d30b01aa2..9c088f0a5d3135 100644 --- a/test/examples/embeddables/adding_children.ts +++ b/test/examples/embeddables/adding_children.ts @@ -24,6 +24,7 @@ import { PluginFunctionalProviderContext } from 'test/plugin_functional/services export default function ({ getService }: PluginFunctionalProviderContext) { const testSubjects = getService('testSubjects'); const flyout = getService('flyout'); + const retry = getService('retry'); describe('creating and adding children', () => { before(async () => { @@ -33,8 +34,15 @@ export default function ({ getService }: PluginFunctionalProviderContext) { it('Can create a new child', async () => { await testSubjects.click('embeddablePanelToggleMenuIcon'); await testSubjects.click('embeddablePanelAction-ACTION_ADD_PANEL'); - await testSubjects.click('createNew'); + + // this seem like an overkill, but clicking this button which opens context menu was flaky + await testSubjects.waitForEnabled('createNew'); + await retry.waitFor('createNew popover opened', async () => { + await testSubjects.click('createNew'); + return await testSubjects.exists('createNew-TODO_EMBEDDABLE'); + }); await testSubjects.click('createNew-TODO_EMBEDDABLE'); + await testSubjects.setValue('taskInputField', 'new task'); await testSubjects.click('createTodoEmbeddable'); const tasks = await testSubjects.getVisibleTextAll('todoEmbeddableTask'); @@ -44,6 +52,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { it('Can add a child backed off a saved object', async () => { await testSubjects.click('embeddablePanelToggleMenuIcon'); await testSubjects.click('embeddablePanelAction-ACTION_ADD_PANEL'); + await testSubjects.waitForDeleted('savedObjectFinderLoadingIndicator'); await testSubjects.click('savedObjectTitleGarbage'); await testSubjects.moveMouseTo('euiFlyoutCloseButton'); await flyout.ensureClosed('dashboardAddPanel'); diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app/components/main.tsx b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app/components/main.tsx index ace2af2b4f0cfe..b4f9634b23d299 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app/components/main.tsx +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app/components/main.tsx @@ -21,7 +21,7 @@ import React from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiPageContentHeader } from '@elastic/eui'; import { first } from 'rxjs/operators'; import { IInterpreterRenderHandlers, ExpressionValue } from 'src/plugins/expressions'; -import { RequestAdapter, DataAdapter } from '../../../../../../../src/plugins/inspector'; +import { RequestAdapter, DataAdapter } from '../../../../../../../src/plugins/inspector/public'; import { Adapters, ExpressionRenderHandler } from '../../types'; import { getExpressions } from '../../services'; diff --git a/test/scripts/jenkins_build_kibana.sh b/test/scripts/jenkins_build_kibana.sh index e3f46e7a6ada41..3e49edc8e6ae5f 100755 --- a/test/scripts/jenkins_build_kibana.sh +++ b/test/scripts/jenkins_build_kibana.sh @@ -2,9 +2,16 @@ source src/dev/ci_setup/setup_env.sh -echo " -> building kibana platform plugins" +echo " -> building examples separate from test plugins" node scripts/build_kibana_platform_plugins \ --oss \ + --examples \ + --verbose; + +echo " -> building test plugins" +node scripts/build_kibana_platform_plugins \ + --oss \ + --no-examples \ --scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \ --scan-dir "$KIBANA_DIR/test/interpreter_functional/plugins" \ --verbose; diff --git a/test/scripts/jenkins_xpack_build_kibana.sh b/test/scripts/jenkins_xpack_build_kibana.sh index c962b962b1e5e6..58ef6a42d3fe4c 100755 --- a/test/scripts/jenkins_xpack_build_kibana.sh +++ b/test/scripts/jenkins_xpack_build_kibana.sh @@ -3,8 +3,14 @@ cd "$KIBANA_DIR" source src/dev/ci_setup/setup_env.sh -echo " -> building kibana platform plugins" +echo " -> building examples separate from test plugins" node scripts/build_kibana_platform_plugins \ + --examples \ + --verbose; + +echo " -> building test plugins" +node scripts/build_kibana_platform_plugins \ + --no-examples \ --scan-dir "$KIBANA_DIR/test/plugin_functional/plugins" \ --scan-dir "$XPACK_DIR/test/plugin_functional/plugins" \ --scan-dir "$XPACK_DIR/test/functional_with_es_ssl/fixtures/plugins" \ diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 96d5f04ac088f6..605676cee363d1 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -234,9 +234,24 @@ Payload: ## Firing actions -The plugin exposes an execute function that you can use to run actions. +Running actions is possible by using the ActionsClient which is provided by the `getActionsClientWithRequest` function part of the plugin's Start Contract. +By providing the user's Request you'll receive an instance of the ActionsClient which is tailered to the current user and is scoped to the resources the user is authorized to access. -**server.plugins.actions.execute(options)** +## Accessing a scoped ActionsClient + +``` +const actionsClient = server.plugins.actions.getActionsClientWithRequest(request); +``` + +Once you have a scoped ActionsClient you can execute an action by caling either the `enqueueExecution` which will schedule the action to run later or the `execute` apis which will run it immediately and return the result respectively. + +### actionsClient.enqueueExecution(options) + +This api schedules a task which will run the action using the current user scope at the soonest opportunity. + +Running the action by scheduling a task means that we will no longer have a user request by which to ascertain the action's privileges and so you might need to provide these yourself: +- The **SpaceId** in which the user's action is expected to run +- When security is enabled you'll also need to provide an **apiKey** which allows us to mimic the user and their privileges. The following table describes the properties of the `options` object. @@ -251,8 +266,9 @@ The following table describes the properties of the `options` object. This example makes action `3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5` send an email. The action plugin will load the saved object and find what action type to call with `params`. -``` -server.plugins.actions.execute({ +```typescript +const actionsClient = await server.plugins.actions.getActionsClientWithRequest(request); +await actionsClient.enqueueExecution({ id: '3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5', spaceId: 'default', // The spaceId of the action params: { @@ -264,6 +280,34 @@ server.plugins.actions.execute({ }); ``` +### actionsClient.execute(options) + +This api runs the action and asynchronously returns the result of running the action. + +The following table describes the properties of the `options` object. + +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------------------ | ------ | +| id | The id of the action you want to execute. | string | +| params | The `params` value to give the action type executor. | object | + +## Example + +As with the previous example, we'll use the action `3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5` to send an email. + +```typescript +const actionsClient = await server.plugins.actions.getActionsClientWithRequest(request); +const result = await actionsClient.execute({ + id: '3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5', + params: { + from: 'example@elastic.co', + to: ['destination@elastic.co'], + subject: 'My email subject', + body: 'My email body', + }, +}); +``` + # Built-in Action Types Kibana ships with a set of built-in action types: diff --git a/x-pack/plugins/actions/server/actions_client.mock.ts b/x-pack/plugins/actions/server/actions_client.mock.ts index a2b64e49f76e34..efd044c7e2493a 100644 --- a/x-pack/plugins/actions/server/actions_client.mock.ts +++ b/x-pack/plugins/actions/server/actions_client.mock.ts @@ -17,6 +17,8 @@ const createActionsClientMock = () => { update: jest.fn(), getAll: jest.fn(), getBulk: jest.fn(), + execute: jest.fn(), + enqueueExecution: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index bf55a1c18d169c..2865bbbe1d944a 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -19,10 +19,16 @@ import { elasticsearchServiceMock, savedObjectsClientMock, } from '../../../../src/core/server/mocks'; +import { actionExecutorMock } from './lib/action_executor.mock'; +import uuid from 'uuid'; +import { KibanaRequest } from 'kibana/server'; const defaultKibanaIndex = '.kibana'; const savedObjectsClient = savedObjectsClientMock.create(); const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); +const actionExecutor = actionExecutorMock.create(); +const executionEnqueuer = jest.fn(); +const request = {} as KibanaRequest; const mockTaskManager = taskManagerMock.setup(); @@ -53,6 +59,9 @@ beforeEach(() => { scopedClusterClient, defaultKibanaIndex, preconfiguredActions: [], + actionExecutor, + executionEnqueuer, + request, }); }); @@ -232,6 +241,9 @@ describe('create()', () => { scopedClusterClient, defaultKibanaIndex, preconfiguredActions: [], + actionExecutor, + executionEnqueuer, + request, }); const savedObjectCreateResult = { @@ -328,6 +340,9 @@ describe('get()', () => { savedObjectsClient, scopedClusterClient, defaultKibanaIndex, + actionExecutor, + executionEnqueuer, + request, preconfiguredActions: [ { id: 'testPreconfigured', @@ -388,6 +403,9 @@ describe('getAll()', () => { savedObjectsClient, scopedClusterClient, defaultKibanaIndex, + actionExecutor, + executionEnqueuer, + request, preconfiguredActions: [ { id: 'testPreconfigured', @@ -453,6 +471,9 @@ describe('getBulk()', () => { savedObjectsClient, scopedClusterClient, defaultKibanaIndex, + actionExecutor, + executionEnqueuer, + request, preconfiguredActions: [ { id: 'testPreconfigured', @@ -718,3 +739,40 @@ describe('update()', () => { ).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`); }); }); + +describe('execute()', () => { + test('calls the actionExecutor with the appropriate parameters', async () => { + const actionId = uuid.v4(); + actionExecutor.execute.mockResolvedValue({ status: 'ok', actionId }); + await expect( + actionsClient.execute({ + actionId, + params: { + name: 'my name', + }, + }) + ).resolves.toMatchObject({ status: 'ok', actionId }); + + expect(actionExecutor.execute).toHaveBeenCalledWith({ + actionId, + request, + params: { + name: 'my name', + }, + }); + }); +}); + +describe('enqueueExecution()', () => { + test('calls the executionEnqueuer with the appropriate parameters', async () => { + const opts = { + id: uuid.v4(), + params: { baz: false }, + spaceId: 'default', + apiKey: Buffer.from('123:abc').toString('base64'), + }; + await expect(actionsClient.enqueueExecution(opts)).resolves.toMatchInlineSnapshot(`undefined`); + + expect(executionEnqueuer).toHaveBeenCalledWith(savedObjectsClient, opts); + }); +}); diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index 48703f01f55091..a2feac83cba9f5 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -9,13 +9,25 @@ import { SavedObjectsClientContract, SavedObjectAttributes, SavedObject, + KibanaRequest, } from 'src/core/server'; import { i18n } from '@kbn/i18n'; import { ActionTypeRegistry } from './action_type_registry'; -import { validateConfig, validateSecrets } from './lib'; -import { ActionResult, FindActionResult, RawAction, PreConfiguredAction } from './types'; +import { validateConfig, validateSecrets, ActionExecutorContract } from './lib'; +import { + ActionResult, + FindActionResult, + RawAction, + PreConfiguredAction, + ActionTypeExecutorResult, +} from './types'; import { PreconfiguredActionDisabledModificationError } from './lib/errors/preconfigured_action_disabled_modification'; +import { ExecuteOptions } from './lib/action_executor'; +import { + ExecutionEnqueuer, + ExecuteOptions as EnqueueExecutionOptions, +} from './create_execute_function'; // We are assuming there won't be many actions. This is why we will load // all the actions in advance and assume the total count to not go over 10000. @@ -42,6 +54,9 @@ interface ConstructorOptions { actionTypeRegistry: ActionTypeRegistry; savedObjectsClient: SavedObjectsClientContract; preconfiguredActions: PreConfiguredAction[]; + actionExecutor: ActionExecutorContract; + executionEnqueuer: ExecutionEnqueuer; + request: KibanaRequest; } interface UpdateOptions { @@ -55,6 +70,9 @@ export class ActionsClient { private readonly savedObjectsClient: SavedObjectsClientContract; private readonly actionTypeRegistry: ActionTypeRegistry; private readonly preconfiguredActions: PreConfiguredAction[]; + private readonly actionExecutor: ActionExecutorContract; + private readonly request: KibanaRequest; + private readonly executionEnqueuer: ExecutionEnqueuer; constructor({ actionTypeRegistry, @@ -62,12 +80,18 @@ export class ActionsClient { scopedClusterClient, savedObjectsClient, preconfiguredActions, + actionExecutor, + executionEnqueuer, + request, }: ConstructorOptions) { this.actionTypeRegistry = actionTypeRegistry; this.savedObjectsClient = savedObjectsClient; this.scopedClusterClient = scopedClusterClient; this.defaultKibanaIndex = defaultKibanaIndex; this.preconfiguredActions = preconfiguredActions; + this.actionExecutor = actionExecutor; + this.executionEnqueuer = executionEnqueuer; + this.request = request; } /** @@ -251,6 +275,17 @@ export class ActionsClient { } return await this.savedObjectsClient.delete('action', id); } + + public async execute({ + actionId, + params, + }: Omit): Promise { + return this.actionExecutor.execute({ actionId, params, request: this.request }); + } + + public async enqueueExecution(options: EnqueueExecutionOptions): Promise { + return this.executionEnqueuer(this.savedObjectsClient, options); + } } function actionFromSavedObject(savedObject: SavedObject): ActionResult { diff --git a/x-pack/plugins/actions/server/create_execute_function.test.ts b/x-pack/plugins/actions/server/create_execute_function.test.ts index 1b7752588e3d39..04d4d92945cdb3 100644 --- a/x-pack/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/plugins/actions/server/create_execute_function.test.ts @@ -5,23 +5,20 @@ */ import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; -import { createExecuteFunction } from './create_execute_function'; +import { createExecutionEnqueuerFunction } from './create_execute_function'; import { savedObjectsClientMock } from '../../../../src/core/server/mocks'; import { actionTypeRegistryMock } from './action_type_registry.mock'; const mockTaskManager = taskManagerMock.start(); const savedObjectsClient = savedObjectsClientMock.create(); -const getBasePath = jest.fn(); beforeEach(() => jest.resetAllMocks()); describe('execute()', () => { test('schedules the action with all given parameters', async () => { - const executeFn = createExecuteFunction({ - getBasePath, + const executeFn = createExecutionEnqueuerFunction({ taskManager: mockTaskManager, actionTypeRegistry: actionTypeRegistryMock.create(), - getScopedSavedObjectsClient: jest.fn().mockReturnValueOnce(savedObjectsClient), isESOUsingEphemeralEncryptionKey: false, preconfiguredActions: [], }); @@ -39,7 +36,7 @@ describe('execute()', () => { attributes: {}, references: [], }); - await executeFn({ + await executeFn(savedObjectsClient, { id: '123', params: { baz: false }, spaceId: 'default', @@ -70,11 +67,9 @@ describe('execute()', () => { }); test('schedules the action with all given parameters with a preconfigured action', async () => { - const executeFn = createExecuteFunction({ - getBasePath, + const executeFn = createExecutionEnqueuerFunction({ taskManager: mockTaskManager, actionTypeRegistry: actionTypeRegistryMock.create(), - getScopedSavedObjectsClient: jest.fn().mockReturnValueOnce(savedObjectsClient), isESOUsingEphemeralEncryptionKey: false, preconfiguredActions: [ { @@ -101,7 +96,7 @@ describe('execute()', () => { attributes: {}, references: [], }); - await executeFn({ + await executeFn(savedObjectsClient, { id: '123', params: { baz: false }, spaceId: 'default', @@ -131,115 +126,15 @@ describe('execute()', () => { }); }); - test('uses API key when provided', async () => { - const getScopedSavedObjectsClient = jest.fn().mockReturnValueOnce(savedObjectsClient); - const executeFn = createExecuteFunction({ - getBasePath, - taskManager: mockTaskManager, - getScopedSavedObjectsClient, - isESOUsingEphemeralEncryptionKey: false, - actionTypeRegistry: actionTypeRegistryMock.create(), - preconfiguredActions: [], - }); - savedObjectsClient.get.mockResolvedValueOnce({ - id: '123', - type: 'action', - attributes: { - actionTypeId: 'mock-action', - }, - references: [], - }); - savedObjectsClient.create.mockResolvedValueOnce({ - id: '234', - type: 'action_task_params', - attributes: {}, - references: [], - }); - - await executeFn({ - id: '123', - params: { baz: false }, - spaceId: 'default', - apiKey: Buffer.from('123:abc').toString('base64'), - }); - expect(getScopedSavedObjectsClient).toHaveBeenCalledWith({ - getBasePath: expect.anything(), - headers: { - // base64 encoded "123:abc" - authorization: 'ApiKey MTIzOmFiYw==', - }, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }); - }); - - test(`doesn't use API keys when not provided`, async () => { - const getScopedSavedObjectsClient = jest.fn().mockReturnValueOnce(savedObjectsClient); - const executeFn = createExecuteFunction({ - getBasePath, - taskManager: mockTaskManager, - getScopedSavedObjectsClient, - isESOUsingEphemeralEncryptionKey: false, - actionTypeRegistry: actionTypeRegistryMock.create(), - preconfiguredActions: [], - }); - savedObjectsClient.get.mockResolvedValueOnce({ - id: '123', - type: 'action', - attributes: { - actionTypeId: 'mock-action', - }, - references: [], - }); - savedObjectsClient.create.mockResolvedValueOnce({ - id: '234', - type: 'action_task_params', - attributes: {}, - references: [], - }); - - await executeFn({ - id: '123', - params: { baz: false }, - spaceId: 'default', - apiKey: null, - }); - expect(getScopedSavedObjectsClient).toHaveBeenCalledWith({ - getBasePath: expect.anything(), - headers: {}, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }); - }); - test('throws when passing isESOUsingEphemeralEncryptionKey with true as a value', async () => { - const getScopedSavedObjectsClient = jest.fn().mockReturnValueOnce(savedObjectsClient); - const executeFn = createExecuteFunction({ - getBasePath, + const executeFn = createExecutionEnqueuerFunction({ taskManager: mockTaskManager, - getScopedSavedObjectsClient, isESOUsingEphemeralEncryptionKey: true, actionTypeRegistry: actionTypeRegistryMock.create(), preconfiguredActions: [], }); await expect( - executeFn({ + executeFn(savedObjectsClient, { id: '123', params: { baz: false }, spaceId: 'default', @@ -252,11 +147,8 @@ describe('execute()', () => { test('should ensure action type is enabled', async () => { const mockedActionTypeRegistry = actionTypeRegistryMock.create(); - const getScopedSavedObjectsClient = jest.fn().mockReturnValueOnce(savedObjectsClient); - const executeFn = createExecuteFunction({ - getBasePath, + const executeFn = createExecutionEnqueuerFunction({ taskManager: mockTaskManager, - getScopedSavedObjectsClient, isESOUsingEphemeralEncryptionKey: false, actionTypeRegistry: mockedActionTypeRegistry, preconfiguredActions: [], @@ -274,7 +166,7 @@ describe('execute()', () => { }); await expect( - executeFn({ + executeFn(savedObjectsClient, { id: '123', params: { baz: false }, spaceId: 'default', @@ -285,11 +177,8 @@ describe('execute()', () => { test('should skip ensure action type if action type is preconfigured and license is valid', async () => { const mockedActionTypeRegistry = actionTypeRegistryMock.create(); - const getScopedSavedObjectsClient = jest.fn().mockReturnValueOnce(savedObjectsClient); - const executeFn = createExecuteFunction({ - getBasePath, + const executeFn = createExecutionEnqueuerFunction({ taskManager: mockTaskManager, - getScopedSavedObjectsClient, isESOUsingEphemeralEncryptionKey: false, actionTypeRegistry: mockedActionTypeRegistry, preconfiguredActions: [ @@ -303,9 +192,6 @@ describe('execute()', () => { }, ], }); - mockedActionTypeRegistry.ensureActionTypeEnabled.mockImplementation(() => { - throw new Error('Fail'); - }); mockedActionTypeRegistry.isActionExecutable.mockImplementation(() => true); savedObjectsClient.get.mockResolvedValueOnce({ id: '123', @@ -322,25 +208,13 @@ describe('execute()', () => { references: [], }); - await executeFn({ + await executeFn(savedObjectsClient, { id: '123', params: { baz: false }, spaceId: 'default', apiKey: null, }); - expect(getScopedSavedObjectsClient).toHaveBeenCalledWith({ - getBasePath: expect.anything(), - headers: {}, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }); + + expect(mockedActionTypeRegistry.ensureActionTypeEnabled).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/actions/server/create_execute_function.ts b/x-pack/plugins/actions/server/create_execute_function.ts index a16b2f4295072d..2bad33d56f228e 100644 --- a/x-pack/plugins/actions/server/create_execute_function.ts +++ b/x-pack/plugins/actions/server/create_execute_function.ts @@ -4,19 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract, KibanaRequest } from '../../../../src/core/server'; +import { SavedObjectsClientContract } from '../../../../src/core/server'; import { TaskManagerStartContract } from '../../task_manager/server'; -import { - GetBasePathFunction, - RawAction, - ActionTypeRegistryContract, - PreConfiguredAction, -} from './types'; +import { RawAction, ActionTypeRegistryContract, PreConfiguredAction } from './types'; interface CreateExecuteFunctionOptions { taskManager: TaskManagerStartContract; - getScopedSavedObjectsClient: (request: KibanaRequest) => SavedObjectsClientContract; - getBasePath: GetBasePathFunction; isESOUsingEphemeralEncryptionKey: boolean; actionTypeRegistry: ActionTypeRegistryContract; preconfiguredActions: PreConfiguredAction[]; @@ -29,45 +22,27 @@ export interface ExecuteOptions { apiKey: string | null; } -export function createExecuteFunction({ - getBasePath, +export type ExecutionEnqueuer = ( + savedObjectsClient: SavedObjectsClientContract, + options: ExecuteOptions +) => Promise; + +export function createExecutionEnqueuerFunction({ taskManager, actionTypeRegistry, - getScopedSavedObjectsClient, isESOUsingEphemeralEncryptionKey, preconfiguredActions, }: CreateExecuteFunctionOptions) { - return async function execute({ id, params, spaceId, apiKey }: ExecuteOptions) { + return async function execute( + savedObjectsClient: SavedObjectsClientContract, + { id, params, spaceId, apiKey }: ExecuteOptions + ) { if (isESOUsingEphemeralEncryptionKey === true) { throw new Error( `Unable to execute action due to the Encrypted Saved Objects plugin using an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml` ); } - const requestHeaders: Record = {}; - - if (apiKey) { - requestHeaders.authorization = `ApiKey ${apiKey}`; - } - - // Since we're using API keys and accessing elasticsearch can only be done - // via a request, we're faking one with the proper authorization headers. - const fakeRequest: unknown = { - headers: requestHeaders, - getBasePath: () => getBasePath(spaceId), - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }; - - const savedObjectsClient = getScopedSavedObjectsClient(fakeRequest as KibanaRequest); const actionTypeId = await getActionTypeId(id); if (!actionTypeRegistry.isActionExecutable(id, actionTypeId)) { diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 09bc137da736f5..1763d275c6fb07 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -23,7 +23,6 @@ const createSetupMock = () => { const createStartMock = () => { const mock: jest.Mocked = { - execute: jest.fn(), isActionTypeEnabled: jest.fn(), isActionExecutable: jest.fn(), getActionsClientWithRequest: jest.fn().mockResolvedValue(actionsClientMock.create()), diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 8c1b21e600ae17..ad3fa97ee0c366 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -35,8 +35,7 @@ import { Services, ActionType, PreConfiguredAction } from './types'; import { ActionExecutor, TaskRunnerFactory, LicenseState, ILicenseState } from './lib'; import { ActionsClient } from './actions_client'; import { ActionTypeRegistry } from './action_type_registry'; -import { ExecuteOptions } from './create_execute_function'; -import { createExecuteFunction } from './create_execute_function'; +import { createExecutionEnqueuerFunction } from './create_execute_function'; import { registerBuiltInActionTypes } from './builtin_action_types'; import { registerActionsUsageCollector } from './usage'; @@ -68,7 +67,6 @@ export interface PluginSetupContract { export interface PluginStartContract { isActionTypeEnabled(id: string): boolean; isActionExecutable(actionId: string, actionTypeId: string): boolean; - execute(options: ExecuteOptions): Promise; getActionsClientWithRequest(request: KibanaRequest): Promise>; preconfiguredActions: PreConfiguredAction[]; } @@ -203,7 +201,7 @@ export class ActionsPlugin implements Plugin, Plugi getAllActionRoute(router, this.licenseState); updateActionRoute(router, this.licenseState); listActionTypesRoute(router, this.licenseState); - executeActionRoute(router, this.licenseState, actionExecutor); + executeActionRoute(router, this.licenseState); return { registerType: (actionType: ActionType) => { @@ -269,14 +267,6 @@ export class ActionsPlugin implements Plugin, Plugi scheduleActionsTelemetry(this.telemetryLogger, plugins.taskManager); return { - execute: createExecuteFunction({ - taskManager: plugins.taskManager, - actionTypeRegistry: actionTypeRegistry!, - getScopedSavedObjectsClient, - getBasePath: this.getBasePath, - isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!, - preconfiguredActions, - }), isActionTypeEnabled: (id) => { return this.actionTypeRegistry!.isActionTypeEnabled(id); }, @@ -296,6 +286,14 @@ export class ActionsPlugin implements Plugin, Plugi defaultKibanaIndex: await kibanaIndex, scopedClusterClient: core.elasticsearch.legacy.client.asScoped(request), preconfiguredActions, + request, + actionExecutor: actionExecutor!, + executionEnqueuer: createExecutionEnqueuerFunction({ + taskManager: plugins.taskManager, + actionTypeRegistry: actionTypeRegistry!, + isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!, + preconfiguredActions, + }), }); }, preconfiguredActions, @@ -319,10 +317,15 @@ export class ActionsPlugin implements Plugin, Plugi core: CoreSetup, defaultKibanaIndex: string ): IContextProvider, 'actions'> => { - const { actionTypeRegistry, isESOUsingEphemeralEncryptionKey, preconfiguredActions } = this; + const { + actionTypeRegistry, + isESOUsingEphemeralEncryptionKey, + preconfiguredActions, + actionExecutor, + } = this; return async function actionsRouteHandlerContext(context, request) { - const [{ savedObjects }] = await core.getStartServices(); + const [{ savedObjects }, { taskManager }] = await core.getStartServices(); return { getActionsClient: () => { if (isESOUsingEphemeralEncryptionKey === true) { @@ -336,6 +339,14 @@ export class ActionsPlugin implements Plugin, Plugi defaultKibanaIndex, scopedClusterClient: context.core.elasticsearch.legacy.client, preconfiguredActions, + request, + actionExecutor: actionExecutor!, + executionEnqueuer: createExecutionEnqueuerFunction({ + taskManager, + actionTypeRegistry: actionTypeRegistry!, + isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!, + preconfiguredActions, + }), }); }, listTypes: actionTypeRegistry!.list.bind(actionTypeRegistry!), diff --git a/x-pack/plugins/actions/server/routes/execute.test.ts b/x-pack/plugins/actions/server/routes/execute.test.ts index 4467ddf372b21d..6e8ebbf6f91cd7 100644 --- a/x-pack/plugins/actions/server/routes/execute.test.ts +++ b/x-pack/plugins/actions/server/routes/execute.test.ts @@ -8,7 +8,9 @@ import { executeActionRoute } from './execute'; import { httpServiceMock } from 'src/core/server/mocks'; import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; -import { ActionExecutorContract, verifyApiAccess, ActionTypeDisabledError } from '../lib'; +import { verifyApiAccess, ActionTypeDisabledError } from '../lib'; +import { actionsClientMock } from '../actions_client.mock'; +import { ActionTypeExecutorResult } from '../types'; jest.mock('../lib/verify_api_access.ts', () => ({ verifyApiAccess: jest.fn(), @@ -23,8 +25,11 @@ describe('executeActionRoute', () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.execute.mockResolvedValueOnce({ status: 'ok', actionId: '1' }); + const [context, req, res] = mockHandlerArguments( - {}, + { actionsClient }, { body: { params: { @@ -42,14 +47,8 @@ describe('executeActionRoute', () => { actionId: '1', status: 'ok', }; - const actionExecutor = { - initialize: jest.fn(), - execute: jest.fn(async ({ params, request, actionId }) => { - return executeResult; - }), - } as jest.Mocked; - executeActionRoute(router, licenseState, actionExecutor); + executeActionRoute(router, licenseState); const [config, handler] = router.post.mock.calls[0]; @@ -64,12 +63,11 @@ describe('executeActionRoute', () => { expect(await handler(context, req, res)).toEqual({ body: executeResult }); - expect(actionExecutor.execute).toHaveBeenCalledWith({ + expect(actionsClient.execute).toHaveBeenCalledWith({ actionId: '1', params: { someData: 'data', }, - request: req, }); expect(res.ok).toHaveBeenCalled(); @@ -79,8 +77,11 @@ describe('executeActionRoute', () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.execute.mockResolvedValueOnce((null as unknown) as ActionTypeExecutorResult); + const [context, req, res] = mockHandlerArguments( - {}, + { actionsClient }, { body: { params: {}, @@ -92,21 +93,15 @@ describe('executeActionRoute', () => { ['noContent'] ); - const actionExecutor = { - initialize: jest.fn(), - execute: jest.fn(), - } as jest.Mocked; - - executeActionRoute(router, licenseState, actionExecutor); + executeActionRoute(router, licenseState); const [, handler] = router.post.mock.calls[0]; expect(await handler(context, req, res)).toEqual(undefined); - expect(actionExecutor.execute).toHaveBeenCalledWith({ + expect(actionsClient.execute).toHaveBeenCalledWith({ actionId: '1', params: {}, - request: req, }); expect(res.ok).not.toHaveBeenCalled(); @@ -117,8 +112,14 @@ describe('executeActionRoute', () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.execute.mockResolvedValue({ + actionId: '1', + status: 'ok', + }); + const [context, req, res] = mockHandlerArguments( - {}, + { actionsClient }, { body: {}, params: {}, @@ -126,17 +127,7 @@ describe('executeActionRoute', () => { ['ok'] ); - const actionExecutor = { - initialize: jest.fn(), - execute: jest.fn(async ({ params, request, actionId }) => { - return { - actionId: '1', - status: 'ok', - }; - }), - } as jest.Mocked; - - executeActionRoute(router, licenseState, actionExecutor); + executeActionRoute(router, licenseState); const [, handler] = router.post.mock.calls[0]; @@ -149,12 +140,18 @@ describe('executeActionRoute', () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.execute.mockResolvedValue({ + actionId: '1', + status: 'ok', + }); + (verifyApiAccess as jest.Mock).mockImplementation(() => { throw new Error('OMG'); }); const [context, req, res] = mockHandlerArguments( - {}, + { actionsClient }, { body: {}, params: {}, @@ -162,17 +159,7 @@ describe('executeActionRoute', () => { ['ok'] ); - const actionExecutor = { - initialize: jest.fn(), - execute: jest.fn(async ({ params, request, actionId }) => { - return { - actionId: '1', - status: 'ok', - }; - }), - } as jest.Mocked; - - executeActionRoute(router, licenseState, actionExecutor); + executeActionRoute(router, licenseState); const [, handler] = router.post.mock.calls[0]; @@ -185,8 +172,11 @@ describe('executeActionRoute', () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.execute.mockRejectedValue(new ActionTypeDisabledError('Fail', 'license_invalid')); + const [context, req, res] = mockHandlerArguments( - {}, + { actionsClient }, { body: {}, params: {}, @@ -194,14 +184,7 @@ describe('executeActionRoute', () => { ['ok', 'forbidden'] ); - const actionExecutor = { - initialize: jest.fn(), - execute: jest.fn().mockImplementation(() => { - throw new ActionTypeDisabledError('Fail', 'license_invalid'); - }), - } as jest.Mocked; - - executeActionRoute(router, licenseState, actionExecutor); + executeActionRoute(router, licenseState); const [, handler] = router.post.mock.calls[0]; diff --git a/x-pack/plugins/actions/server/routes/execute.ts b/x-pack/plugins/actions/server/routes/execute.ts index 820bc191993d1e..28e6a54f5e92d9 100644 --- a/x-pack/plugins/actions/server/routes/execute.ts +++ b/x-pack/plugins/actions/server/routes/execute.ts @@ -13,7 +13,6 @@ import { } from 'kibana/server'; import { ILicenseState, verifyApiAccess, isErrorThatHandlesItsOwnResponse } from '../lib'; -import { ActionExecutorContract } from '../lib'; import { ActionTypeExecutorResult } from '../types'; import { BASE_ACTION_API_PATH } from '../../common'; @@ -25,11 +24,7 @@ const bodySchema = schema.object({ params: schema.recordOf(schema.string(), schema.any()), }); -export const executeActionRoute = ( - router: IRouter, - licenseState: ILicenseState, - actionExecutor: ActionExecutorContract -) => { +export const executeActionRoute = (router: IRouter, licenseState: ILicenseState) => { router.post( { path: `${BASE_ACTION_API_PATH}/action/{id}/_execute`, @@ -47,12 +42,17 @@ export const executeActionRoute = ( res: KibanaResponseFactory ): Promise { verifyApiAccess(licenseState); + + if (!context.actions) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for actions' }); + } + + const actionsClient = context.actions.getActionsClient(); const { params } = req.body; const { id } = req.params; try { - const body: ActionTypeExecutorResult = await actionExecutor.execute({ + const body: ActionTypeExecutorResult = await actionsClient.execute({ params, - request: req, actionId: id, }); return body diff --git a/x-pack/plugins/alerts/kibana.json b/x-pack/plugins/alerts/kibana.json index 3509f79dbbe4d3..eef61ff4b3d53a 100644 --- a/x-pack/plugins/alerts/kibana.json +++ b/x-pack/plugins/alerts/kibana.json @@ -6,5 +6,6 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "alerts"], "requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "actions", "eventLog"], - "optionalPlugins": ["usageCollection", "spaces", "security"] + "optionalPlugins": ["usageCollection", "spaces", "security"], + "extraPublicDirs": ["common", "common/parse_duration"] } diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts index ceae9f04fe5ae6..dd5a9f531bd58b 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts @@ -7,8 +7,9 @@ import { AlertType } from '../types'; import { createExecutionHandler } from './create_execution_handler'; import { loggingServiceMock } from '../../../../../src/core/server/mocks'; -import { actionsMock } from '../../../actions/server/mocks'; +import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; +import { KibanaRequest } from 'kibana/server'; const alertType: AlertType = { id: 'test', @@ -22,6 +23,7 @@ const alertType: AlertType = { producer: 'alerting', }; +const actionsClient = actionsClientMock.create(); const createExecutionHandlerParams = { actionsPlugin: actionsMock.createStart(), spaceId: 'default', @@ -47,15 +49,19 @@ const createExecutionHandlerParams = { }, }, ], + request: {} as KibanaRequest, }; beforeEach(() => { jest.resetAllMocks(); createExecutionHandlerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); createExecutionHandlerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + createExecutionHandlerParams.actionsPlugin.getActionsClientWithRequest.mockResolvedValue( + actionsClient + ); }); -test('calls actionsPlugin.execute per selected action', async () => { +test('enqueues execution per selected action', async () => { const executionHandler = createExecutionHandler(createExecutionHandlerParams); await executionHandler({ actionGroup: 'default', @@ -63,8 +69,11 @@ test('calls actionsPlugin.execute per selected action', async () => { context: {}, alertInstanceId: '2', }); - expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1); - expect(createExecutionHandlerParams.actionsPlugin.execute.mock.calls[0]).toMatchInlineSnapshot(` + expect( + createExecutionHandlerParams.actionsPlugin.getActionsClientWithRequest + ).toHaveBeenCalledWith(createExecutionHandlerParams.request); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "apiKey": "MTIzOmFiYw==", @@ -139,8 +148,8 @@ test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => context: {}, alertInstanceId: '2', }); - expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1); - expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledWith({ + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.enqueueExecution).toHaveBeenCalledWith({ id: '2', params: { foo: true, @@ -180,7 +189,7 @@ test('trow error error message when action type is disabled', async () => { alertInstanceId: '2', }); - expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(0); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(0); createExecutionHandlerParams.actionsPlugin.isActionExecutable.mockImplementation(() => true); const executionHandlerForPreconfiguredAction = createExecutionHandler({ @@ -193,7 +202,7 @@ test('trow error error message when action type is disabled', async () => { context: {}, alertInstanceId: '2', }); - expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); }); test('limits actionsPlugin.execute per action group', async () => { @@ -204,7 +213,7 @@ test('limits actionsPlugin.execute per action group', async () => { context: {}, alertInstanceId: '2', }); - expect(createExecutionHandlerParams.actionsPlugin.execute).not.toHaveBeenCalled(); + expect(actionsClient.enqueueExecution).not.toHaveBeenCalled(); }); test('context attribute gets parameterized', async () => { @@ -215,8 +224,8 @@ test('context attribute gets parameterized', async () => { state: {}, alertInstanceId: '2', }); - expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1); - expect(createExecutionHandlerParams.actionsPlugin.execute.mock.calls[0]).toMatchInlineSnapshot(` + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "apiKey": "MTIzOmFiYw==", @@ -241,8 +250,8 @@ test('state attribute gets parameterized', async () => { state: { value: 'state-val' }, alertInstanceId: '2', }); - expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1); - expect(createExecutionHandlerParams.actionsPlugin.execute.mock.calls[0]).toMatchInlineSnapshot(` + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "apiKey": "MTIzOmFiYw==", diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts index 3c58c6d9ba2883..8d859a570ba91f 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts @@ -6,7 +6,7 @@ import { pluck } from 'lodash'; import { AlertAction, State, Context, AlertType } from '../types'; -import { Logger } from '../../../../../src/core/server'; +import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { transformActionParams } from './transform_action_params'; import { PluginStartContract as ActionsPluginStartContract } from '../../../actions/server'; import { IEventLogger, IEvent, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; @@ -23,6 +23,7 @@ interface CreateExecutionHandlerOptions { alertType: AlertType; logger: Logger; eventLogger: IEventLogger; + request: KibanaRequest; } interface ExecutionHandlerOptions { @@ -43,6 +44,7 @@ export function createExecutionHandler({ apiKey, alertType, eventLogger, + request, }: CreateExecutionHandlerOptions) { const alertTypeActionGroups = new Set(pluck(alertType.actionGroups, 'id')); return async ({ actionGroup, context, state, alertInstanceId }: ExecutionHandlerOptions) => { @@ -80,7 +82,8 @@ export function createExecutionHandler({ // TODO would be nice to add the action name here, but it's not available const actionLabel = `${action.actionTypeId}:${action.id}`; - await actionsPlugin.execute({ + const actionsClient = await actionsPlugin.getActionsClientWithRequest(request); + await actionsClient.enqueueExecution({ id: action.id, params: action.params, spaceId, diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index 983dff86d5602b..690971bc870062 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -13,7 +13,7 @@ import { TaskRunner } from './task_runner'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; import { loggingServiceMock } from '../../../../../src/core/server/mocks'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; -import { actionsMock } from '../../../actions/server/mocks'; +import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; import { alertsMock } from '../mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; import { IEventLogger } from '../../../event_log/server'; @@ -57,6 +57,7 @@ describe('Task Runner', () => { const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); const services = alertsMock.createAlertServices(); const savedObjectsClient = services.savedObjectsClient; + const actionsClient = actionsClientMock.create(); const taskRunnerFactoryInitializerParams: jest.Mocked & { actionsPlugin: jest.Mocked; @@ -108,6 +109,9 @@ describe('Task Runner', () => { beforeEach(() => { jest.resetAllMocks(); taskRunnerFactoryInitializerParams.getServices.mockReturnValue(services); + taskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest.mockResolvedValue( + actionsClient + ); }); test('successfully executes the task', async () => { @@ -206,9 +210,129 @@ describe('Task Runner', () => { references: [], }); await taskRunner.run(); - expect(taskRunnerFactoryInitializerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1); - expect(taskRunnerFactoryInitializerParams.actionsPlugin.execute.mock.calls[0]) - .toMatchInlineSnapshot(` + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "apiKey": "MTIzOmFiYw==", + "id": "1", + "params": Object { + "foo": true, + }, + "spaceId": undefined, + }, + ] + `); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); + expect(eventLogger.logEvent).toHaveBeenCalledWith({ + event: { + action: 'execute', + outcome: 'success', + }, + kibana: { + saved_objects: [ + { + id: '1', + namespace: undefined, + rel: 'primary', + type: 'alert', + }, + ], + }, + message: "alert executed: test:1: 'alert-name'", + }); + expect(eventLogger.logEvent).toHaveBeenCalledWith({ + event: { + action: 'new-instance', + }, + kibana: { + alerting: { + instance_id: '1', + }, + saved_objects: [ + { + id: '1', + namespace: undefined, + rel: 'primary', + type: 'alert', + }, + ], + }, + message: "test:1: 'alert-name' created new instance: '1'", + }); + expect(eventLogger.logEvent).toHaveBeenCalledWith({ + event: { + action: 'execute-action', + }, + kibana: { + alerting: { + instance_id: '1', + }, + saved_objects: [ + { + id: '1', + namespace: undefined, + rel: 'primary', + type: 'alert', + }, + { + id: '1', + namespace: undefined, + type: 'action', + }, + ], + }, + message: + "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: undefined:1", + }); + }); + + test('includes the apiKey in the request used to initialize the actionsClient', async () => { + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect( + taskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest + ).toHaveBeenCalledWith({ + getBasePath: expect.anything(), + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', + }, + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, + }); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "apiKey": "MTIzOmFiYw==", diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index be399893088e33..3512ab16a37125 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -71,14 +71,14 @@ export class TaskRunner { return apiKey; } - async getServicesWithSpaceLevelPermissions(spaceId: string, apiKey: string | null) { + private getFakeKibanaRequest(spaceId: string, apiKey: string | null) { const requestHeaders: Record = {}; if (apiKey) { requestHeaders.authorization = `ApiKey ${apiKey}`; } - const fakeRequest = { + return ({ headers: requestHeaders, getBasePath: () => this.context.getBasePath(spaceId), path: '/', @@ -91,9 +91,11 @@ export class TaskRunner { url: '/', }, }, - }; + } as unknown) as KibanaRequest; + } - return this.context.getServices((fakeRequest as unknown) as KibanaRequest); + async getServicesWithSpaceLevelPermissions(spaceId: string, apiKey: string | null) { + return this.context.getServices(this.getFakeKibanaRequest(spaceId, apiKey)); } private getExecutionHandler( @@ -128,6 +130,7 @@ export class TaskRunner { spaceId, alertType: this.alertType, eventLogger: this.context.eventLogger, + request: this.getFakeKibanaRequest(spaceId, apiKey), }); } diff --git a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js index f1633799ea5832..2fae8bdab2b303 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js +++ b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js @@ -1,10 +1,10 @@ module.exports = { - "APM": { - "Transaction duration charts": { - "1": "350 ms", - "2": "175 ms", - "3": "0 ms" - } + APM: { + 'Transaction duration charts': { + '1': '350.0 ms', + '2': '175.0 ms', + '3': '0.0 ms', + }, }, - "__version": "4.5.0" -} + __version: '4.5.0', +}; diff --git a/x-pack/plugins/apm/e2e/run-e2e.sh b/x-pack/plugins/apm/e2e/run-e2e.sh index 4bebab8e0c6a80..aa7c0e21425ad8 100755 --- a/x-pack/plugins/apm/e2e/run-e2e.sh +++ b/x-pack/plugins/apm/e2e/run-e2e.sh @@ -164,6 +164,7 @@ echo "✅ Setup completed successfully. Running tests..." # run cypress tests ################################################## yarn cypress run --config pageLoadTimeout=100000,watchForFileChanges=true +e2e_status=$? # # Run interactively @@ -171,3 +172,9 @@ yarn cypress run --config pageLoadTimeout=100000,watchForFileChanges=true echo "${bold}If you want to run the test interactively, run:${normal}" echo "" # newline echo "cd ${E2E_DIR} && yarn cypress open --config pageLoadTimeout=100000,watchForFileChanges=true" + +# Report the e2e status at the very end +if [ $e2e_status -ne 0 ]; then + echo "⚠️ Running tests failed." + exit 1 +fi diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 1b8e7c4dc54318..56a9e226b65284 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -25,5 +25,8 @@ "configPath": [ "xpack", "apm" + ], + "extraPublicDirs": [ + "public/style/variables" ] } diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js index 927779b571fd8c..09fef5da16ae71 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js @@ -40,7 +40,7 @@ describe('ServiceOverview -> List', () => { expect(renderedColumns[0]).toMatchSnapshot(); expect(renderedColumns.slice(2)).toEqual([ 'python', - '92 ms', + '91.5 ms', '86.9 tpm', '12.6 err.', ]); diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx index b9048f013cb256..90cc9af45273e5 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx @@ -12,7 +12,7 @@ import styled from 'styled-components'; import { ServiceListAPIResponse } from '../../../../../server/lib/services/get_services'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { fontSizes, truncate } from '../../../../style/variables'; -import { asDecimal, convertTo } from '../../../../utils/formatters'; +import { asDecimal, asMillisecondDuration } from '../../../../utils/formatters'; import { ManagedTable } from '../../../shared/ManagedTable'; import { EnvironmentBadge } from '../../../shared/EnvironmentBadge'; import { TransactionOverviewLink } from '../../../shared/Links/apm/TransactionOverviewLink'; @@ -81,11 +81,7 @@ export const SERVICE_COLUMNS = [ }), sortable: true, dataType: 'number', - render: (time: number) => - convertTo({ - unit: 'milliseconds', - microseconds: time, - }).formatted, + render: (time: number) => asMillisecondDuration(time), }, { field: 'transactionsPerMinute', diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap index 3e6be107ce3a1b..e89acca55d4fe5 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap @@ -203,7 +203,7 @@ NodeList [
- 1 ms + 0.6 ms
- 0 ms + 0.3 ms > = [ }), sortable: true, dataType: 'number', - render: (time: number) => - convertTo({ - unit: 'milliseconds', - microseconds: time, - }).formatted, + render: (time: number) => asMillisecondDuration(time), }, { field: 'transactionsPerMinute', diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx index 61c8d3958b6250..ae1b07bde0c87a 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx @@ -12,7 +12,7 @@ import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ITransactionGroup } from '../../../../../server/lib/transaction_groups/transform'; import { fontFamilyCode, truncate } from '../../../../style/variables'; -import { asDecimal, convertTo } from '../../../../utils/formatters'; +import { asDecimal, asMillisecondDuration } from '../../../../utils/formatters'; import { ImpactBar } from '../../../shared/ImpactBar'; import { ITableColumn, ManagedTable } from '../../../shared/ManagedTable'; import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; @@ -29,12 +29,6 @@ interface Props { isLoading: boolean; } -const toMilliseconds = (time: number) => - convertTo({ - unit: 'milliseconds', - microseconds: time, - }).formatted; - export function TransactionList({ items, isLoading }: Props) { const columns: Array> = useMemo( () => [ @@ -74,7 +68,7 @@ export function TransactionList({ items, isLoading }: Props) { ), sortable: true, dataType: 'number', - render: (time: number) => toMilliseconds(time), + render: (time: number) => asMillisecondDuration(time), }, { field: 'p95', @@ -86,7 +80,7 @@ export function TransactionList({ items, isLoading }: Props) { ), sortable: true, dataType: 'number', - render: (time: number) => toMilliseconds(time), + render: (time: number) => asMillisecondDuration(time), }, { field: 'transactionsPerMinute', diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap index 4c7d21d968088e..b7ea026f80fdea 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap @@ -9,7 +9,7 @@ Array [ "text": Avg. - 468 ms + 467.6 ms , }, @@ -2744,7 +2744,7 @@ Array [ - 468 ms + 467.6 ms @@ -5923,7 +5923,7 @@ Array [ - 468 ms + 467.6 ms diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js index 1d0a53843f5388..f84b0cfeda369c 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js +++ b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js @@ -78,7 +78,7 @@ describe('Histogram', () => { const tooltips = wrapper.find('Tooltip'); expect(tooltips.length).toBe(1); - expect(tooltips.prop('header')).toBe('811 - 927 ms'); + expect(tooltips.prop('header')).toBe('811.1 - 926.9 ms'); expect(tooltips.prop('tooltipPoints')).toEqual([ { value: '49.0 occurrences' }, ]); diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap index f1c7d4826fe0c7..700602eb56929d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap +++ b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap @@ -127,7 +127,7 @@ exports[`Histogram Initially should have default markup 1`] = ` textAnchor="middle" transform="translate(0, 18)" > - 0 ms + 0.0 ms - 500 ms + 500.0 ms - 1,000 ms + 1,000.0 ms - 1,500 ms + 1,500.0 ms - 2,000 ms + 2,000.0 ms - 2,500 ms + 2,500.0 ms - 3,000 ms + 3,000.0 ms @@ -1477,7 +1477,7 @@ exports[`Histogram when hovering over a non-empty bucket should have correct mar
- 811 - 927 ms + 811.1 - 926.9 ms
diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx index d133ba5e715fd7..2b6f0c7aa1319f 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx @@ -12,7 +12,7 @@ import { act } from '@testing-library/react-hooks'; import { expectTextsInDocument } from '../../../../../utils/testHelpers'; describe('ErrorMarker', () => { - const mark = { + const mark = ({ id: 'agent', offset: 10000, type: 'errorMark', @@ -20,18 +20,24 @@ describe('ErrorMarker', () => { error: { trace: { id: '123' }, transaction: { id: '456' }, - error: { grouping_key: '123' }, + error: { + grouping_key: '123', + log: { + message: + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.", + }, + }, service: { name: 'bar' }, }, serviceColor: '#fff', - } as ErrorMark; + } as unknown) as ErrorMark; function openPopover(errorMark: ErrorMark) { const component = render(); act(() => { fireEvent.click(component.getByTestId('popover')); }); - expectTextsInDocument(component, ['10,000 μs']); + expectTextsInDocument(component, ['10.0 ms']); return component; } function getKueryDecoded(url: string) { @@ -76,4 +82,34 @@ describe('ErrorMarker', () => { const errorLink = component.getByTestId('errorLink') as HTMLAnchorElement; expect(getKueryDecoded(errorLink.hash)).toEqual('kuery='); }); + it('truncates the error message text', () => { + const { trace, transaction, ...withoutTraceAndTransaction } = mark.error; + const newMark = { + ...mark, + error: withoutTraceAndTransaction, + } as ErrorMark; + const component = openPopover(newMark); + const errorLink = component.getByTestId('errorLink') as HTMLAnchorElement; + expect(errorLink.innerHTML).toHaveLength(241); + }); + + describe('when the error message is not longer than 240 characters', () => { + it('truncates the error message text', () => { + const newMark = ({ + ...mark, + error: { + ...mark.error, + error: { + grouping_key: '123', + log: { + message: 'Blah.', + }, + }, + }, + } as unknown) as ErrorMark; + const component = openPopover(newMark); + const errorLink = component.getByTestId('errorLink') as HTMLAnchorElement; + expect(errorLink.innerHTML).toHaveLength(5); + }); + }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx index 42f4f278b07bc1..e3310c273a55b2 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx @@ -34,6 +34,7 @@ const TimeLegend = styled(Legend)` const ErrorLink = styled(ErrorDetailLink)` display: block; margin: ${px(units.half)} 0 ${px(units.half)} 0; + overflow-wrap: break-word; `; const Button = styled(Legend)` @@ -42,6 +43,16 @@ const Button = styled(Legend)` align-items: flex-end; `; +// We chose 240 characters because it fits most error messages and it's still easily readable on a screen. +function truncateMessage(errorMessage?: string) { + const maxLength = 240; + if (typeof errorMessage === 'string' && errorMessage.length > maxLength) { + return errorMessage.substring(0, maxLength) + '…'; + } else { + return errorMessage; + } +} + export const ErrorMarker: React.FC = ({ mark }) => { const { urlParams } = useUrlParams(); const [isPopoverOpen, showPopover] = useState(false); @@ -73,6 +84,10 @@ export const ErrorMarker: React.FC = ({ mark }) => { rangeTo, }; + const errorMessage = + error.error.log?.message || error.error.exception?.[0]?.message; + const truncatedErrorMessage = truncateMessage(errorMessage); + return ( = ({ mark }) => { serviceName={error.service.name} errorGroupId={error.error.grouping_key} query={query} + title={errorMessage} > - {error.error.log?.message || error.error.exception?.[0]?.message} + {truncatedErrorMessage} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx index 3c1f4c54fc635c..915b55f29ef804 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx @@ -120,7 +120,7 @@ export class TransactionCharts extends Component { 'xpack.apm.metrics.transactionChart.machineLearningTooltip', { defaultMessage: - 'The stream around the average duration shows the expected bounds. An annotation is shown for anomaly scores >= 75.', + 'The stream around the average duration shows the expected bounds. An annotation is shown for anomaly scores ≥ 75.', } )} /> diff --git a/x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts b/x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts index da9c32c84f36fd..2f0a30a5019a95 100644 --- a/x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts +++ b/x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts @@ -62,7 +62,7 @@ describe('chartSelectors', () => { { x: 0, y: 100 }, { x: 1000, y: 200 }, ], - legendValue: '0 ms', + legendValue: '200 μs', title: 'Avg.', type: 'linemark', }, diff --git a/x-pack/plugins/apm/public/selectors/chartSelectors.ts b/x-pack/plugins/apm/public/selectors/chartSelectors.ts index cfe1a6a60cd227..f8aed9dcf6d9fb 100644 --- a/x-pack/plugins/apm/public/selectors/chartSelectors.ts +++ b/x-pack/plugins/apm/public/selectors/chartSelectors.ts @@ -18,7 +18,7 @@ import { RectCoordinate, TimeSeries, } from '../../typings/timeseries'; -import { asDecimal, tpmUnit, convertTo } from '../utils/formatters'; +import { asDecimal, asDuration, tpmUnit } from '../utils/formatters'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { getEmptySeries } from '../components/shared/charts/CustomPlot/getEmptySeries'; import { httpStatusCodeToColor } from '../utils/httpStatusCodeToColor'; @@ -72,10 +72,7 @@ export function getResponseTimeSeries({ }: TimeSeriesAPIResponse) { const { overallAvgDuration } = apmTimeseries; const { avg, p95, p99 } = apmTimeseries.responseTimes; - const formattedDuration = convertTo({ - unit: 'milliseconds', - microseconds: overallAvgDuration, - }).formatted; + const formattedDuration = asDuration(overallAvgDuration); const series: TimeSeries[] = [ { diff --git a/x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts b/x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts index de3e3868de396c..6d4b65d2aa9b41 100644 --- a/x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts +++ b/x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { asDuration, convertTo, toMicroseconds } from '../duration'; +import { asDuration, toMicroseconds, asMillisecondDuration } from '../duration'; describe('duration formatters', () => { describe('asDuration', () => { @@ -14,10 +14,10 @@ describe('duration formatters', () => { expect(asDuration(1)).toEqual('1 μs'); expect(asDuration(toMicroseconds(1, 'milliseconds'))).toEqual('1,000 μs'); expect(asDuration(toMicroseconds(1000, 'milliseconds'))).toEqual( - '1,000 ms' + '1,000.0 ms' ); expect(asDuration(toMicroseconds(10000, 'milliseconds'))).toEqual( - '10,000 ms' + '10,000.0 ms' ); expect(asDuration(toMicroseconds(20, 'seconds'))).toEqual('20.0 s'); expect(asDuration(toMicroseconds(10, 'minutes'))).toEqual('10.0 min'); @@ -30,96 +30,6 @@ describe('duration formatters', () => { }); }); - describe('convertTo', () => { - it('hours', () => { - const unit = 'hours'; - const oneHourAsMicro = toMicroseconds(1, 'hours'); - const twoHourAsMicro = toMicroseconds(2, 'hours'); - expect(convertTo({ unit, microseconds: oneHourAsMicro })).toEqual({ - unit: 'h', - value: '1.0', - formatted: '1.0 h', - }); - expect(convertTo({ unit, microseconds: twoHourAsMicro })).toEqual({ - unit: 'h', - value: '2.0', - formatted: '2.0 h', - }); - expect( - convertTo({ unit, microseconds: null, defaultValue: '1.2' }) - ).toEqual({ value: '1.2', formatted: '1.2' }); - }); - - it('minutes', () => { - const unit = 'minutes'; - const oneHourAsMicro = toMicroseconds(1, 'hours'); - const twoHourAsMicro = toMicroseconds(2, 'hours'); - expect(convertTo({ unit, microseconds: oneHourAsMicro })).toEqual({ - unit: 'min', - value: '60.0', - formatted: '60.0 min', - }); - expect(convertTo({ unit, microseconds: twoHourAsMicro })).toEqual({ - unit: 'min', - value: '120.0', - formatted: '120.0 min', - }); - expect( - convertTo({ unit, microseconds: null, defaultValue: '10' }) - ).toEqual({ value: '10', formatted: '10' }); - }); - - it('seconds', () => { - const unit = 'seconds'; - const twentySecondsAsMicro = toMicroseconds(20, 'seconds'); - const thirtyFiveSecondsAsMicro = toMicroseconds(35, 'seconds'); - expect(convertTo({ unit, microseconds: twentySecondsAsMicro })).toEqual({ - unit: 's', - value: '20.0', - formatted: '20.0 s', - }); - expect( - convertTo({ unit, microseconds: thirtyFiveSecondsAsMicro }) - ).toEqual({ unit: 's', value: '35.0', formatted: '35.0 s' }); - expect( - convertTo({ unit, microseconds: null, defaultValue: '10' }) - ).toEqual({ value: '10', formatted: '10' }); - }); - - it('milliseconds', () => { - const unit = 'milliseconds'; - const twentyMilliAsMicro = toMicroseconds(20, 'milliseconds'); - const thirtyFiveMilliAsMicro = toMicroseconds(35, 'milliseconds'); - expect(convertTo({ unit, microseconds: twentyMilliAsMicro })).toEqual({ - unit: 'ms', - value: '20', - formatted: '20 ms', - }); - expect( - convertTo({ unit, microseconds: thirtyFiveMilliAsMicro }) - ).toEqual({ unit: 'ms', value: '35', formatted: '35 ms' }); - expect( - convertTo({ unit, microseconds: null, defaultValue: '10' }) - ).toEqual({ value: '10', formatted: '10' }); - }); - - it('microseconds', () => { - const unit = 'microseconds'; - expect(convertTo({ unit, microseconds: 20 })).toEqual({ - unit: 'μs', - value: '20', - formatted: '20 μs', - }); - expect(convertTo({ unit, microseconds: 35 })).toEqual({ - unit: 'μs', - value: '35', - formatted: '35 μs', - }); - expect( - convertTo({ unit, microseconds: null, defaultValue: '10' }) - ).toEqual({ value: '10', formatted: '10' }); - }); - }); describe('toMicroseconds', () => { it('transformes to microseconds', () => { expect(toMicroseconds(1, 'hours')).toEqual(3600000000); @@ -128,4 +38,10 @@ describe('duration formatters', () => { expect(toMicroseconds(10, 'milliseconds')).toEqual(10000); }); }); + + describe('asMilliseconds', () => { + it('converts to formatted decimal milliseconds', () => { + expect(asMillisecondDuration(0)).toEqual('0.0 ms'); + }); + }); }); diff --git a/x-pack/plugins/apm/public/utils/formatters/duration.ts b/x-pack/plugins/apm/public/utils/formatters/duration.ts index af87f7d517cb93..a603faab37538f 100644 --- a/x-pack/plugins/apm/public/utils/formatters/duration.ts +++ b/x-pack/plugins/apm/public/utils/formatters/duration.ts @@ -65,7 +65,7 @@ const durationUnit: DurationUnit = { defaultMessage: 'ms', }), convert: (value: number) => - asInteger(moment.duration(value / 1000).asMilliseconds()), + asDecimal(moment.duration(value / 1000).asMilliseconds()), }, microseconds: { label: i18n.translate('xpack.apm.formatters.microsTimeUnitLabel', { @@ -77,13 +77,8 @@ const durationUnit: DurationUnit = { /** * Converts a microseconds value into the unit defined. - * - * @param param0 - * { unit: "milliseconds" | "hours" | "minutes" | "seconds" | "microseconds", microseconds, defaultValue } - * - * @returns object { value, unit, formatted } */ -export function convertTo({ +function convertTo({ unit, microseconds, defaultValue = NOT_AVAILABLE_LABEL, @@ -118,7 +113,7 @@ function getDurationUnitKey(max: number): DurationTimeUnit { if (max > toMicroseconds(10, 'seconds')) { return 'seconds'; } - if (max > toMicroseconds(10, 'milliseconds')) { + if (max > toMicroseconds(1, 'milliseconds')) { return 'milliseconds'; } return 'microseconds'; @@ -135,10 +130,6 @@ export const getDurationFormatter: TimeFormatterBuilder = memoize( /** * Converts value and returns it formatted - 00 unit - * - * @param value - * @param param1 { defaultValue } - * @returns formated value - 00 unit */ export function asDuration( value: Maybe, @@ -151,3 +142,15 @@ export function asDuration( const formatter = getDurationFormatter(value); return formatter(value, { defaultValue }).formatted; } + +/** + * Convert a microsecond value to decimal milliseconds. Normally we use + * `asDuration`, but this is used in places like tables where we always want + * the same units. + */ +export function asMillisecondDuration(time: number) { + return convertTo({ + unit: 'milliseconds', + microseconds: time, + }).formatted; +} diff --git a/x-pack/plugins/canvas/common/lib/autocomplete.ts b/x-pack/plugins/canvas/common/lib/autocomplete.ts index 0ab549bd14e832..c97879de2137e6 100644 --- a/x-pack/plugins/canvas/common/lib/autocomplete.ts +++ b/x-pack/plugins/canvas/common/lib/autocomplete.ts @@ -14,7 +14,7 @@ import { ExpressionFunction, ExpressionFunctionParameter, getByAlias, -} from '../../../../../src/plugins/expressions'; +} from '../../../../../src/plugins/expressions/common'; const MARKER = 'CANVAS_SUGGESTION_MARKER'; diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index 443bb63a277997..1be55d2b7a6352 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -8,6 +8,7 @@ "requiredPlugins": [ "data" ], + "optionalPlugins": ["kibanaReact", "kibanaUtils"], "server": true, "ui": true } diff --git a/x-pack/plugins/features/kibana.json b/x-pack/plugins/features/kibana.json index 1cab1821b1bf59..92fdd08e934784 100644 --- a/x-pack/plugins/features/kibana.json +++ b/x-pack/plugins/features/kibana.json @@ -6,5 +6,6 @@ "optionalPlugins": ["visTypeTimelion"], "configPath": ["xpack", "features"], "server": true, - "ui": true + "ui": true, + "extraPublicDirs": ["common"] } diff --git a/x-pack/plugins/infra/common/alerting/metrics/index.ts b/x-pack/plugins/infra/common/alerting/metrics/index.ts new file mode 100644 index 00000000000000..2c0a1bd9b2589c --- /dev/null +++ b/x-pack/plugins/infra/common/alerting/metrics/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './types'; +export const INFRA_ALERT_PREVIEW_PATH = '/api/infra/alerting/preview'; + +export const TOO_MANY_BUCKETS_PREVIEW_EXCEPTION = 'TOO_MANY_BUCKETS_PREVIEW_EXCEPTION'; +export interface TooManyBucketsPreviewExceptionMetadata { + TOO_MANY_BUCKETS_PREVIEW_EXCEPTION: any; + maxBuckets: number; +} +export const isTooManyBucketsPreviewException = ( + value: any +): value is TooManyBucketsPreviewExceptionMetadata => + Boolean(value && value.TOO_MANY_BUCKETS_PREVIEW_EXCEPTION); diff --git a/x-pack/plugins/infra/common/alerting/metrics/types.ts b/x-pack/plugins/infra/common/alerting/metrics/types.ts new file mode 100644 index 00000000000000..a6184080cb7746 --- /dev/null +++ b/x-pack/plugins/infra/common/alerting/metrics/types.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +// TODO: Have threshold and inventory alerts import these types from this file instead of from their +// local directories +export const METRIC_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.threshold'; +export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold'; + +export enum Comparator { + GT = '>', + LT = '<', + GT_OR_EQ = '>=', + LT_OR_EQ = '<=', + BETWEEN = 'between', + OUTSIDE_RANGE = 'outside', +} + +export enum Aggregators { + COUNT = 'count', + AVERAGE = 'avg', + SUM = 'sum', + MIN = 'min', + MAX = 'max', + RATE = 'rate', + CARDINALITY = 'cardinality', + P95 = 'p95', + P99 = 'p99', +} + +// Alert Preview API +const baseAlertRequestParamsRT = rt.intersection([ + rt.partial({ + filterQuery: rt.union([rt.string, rt.undefined]), + sourceId: rt.string, + }), + rt.type({ + lookback: rt.union([rt.literal('h'), rt.literal('d'), rt.literal('w'), rt.literal('M')]), + criteria: rt.array(rt.any), + alertInterval: rt.string, + }), +]); + +const metricThresholdAlertPreviewRequestParamsRT = rt.intersection([ + baseAlertRequestParamsRT, + rt.partial({ + groupBy: rt.union([rt.string, rt.array(rt.string), rt.undefined]), + }), + rt.type({ + alertType: rt.literal(METRIC_THRESHOLD_ALERT_TYPE_ID), + }), +]); +export type MetricThresholdAlertPreviewRequestParams = rt.TypeOf< + typeof metricThresholdAlertPreviewRequestParamsRT +>; + +const inventoryAlertPreviewRequestParamsRT = rt.intersection([ + baseAlertRequestParamsRT, + rt.type({ + nodeType: rt.string, + alertType: rt.literal(METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID), + }), +]); + +export const alertPreviewRequestParamsRT = rt.union([ + metricThresholdAlertPreviewRequestParamsRT, + inventoryAlertPreviewRequestParamsRT, +]); + +export const alertPreviewSuccessResponsePayloadRT = rt.type({ + numberOfGroups: rt.number, + resultTotals: rt.type({ + fired: rt.number, + noData: rt.number, + error: rt.number, + tooManyBuckets: rt.number, + }), +}); diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index d5d61733e87176..febf849ccc9438 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -4,25 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import { debounce } from 'lodash'; +import { debounce, pick } from 'lodash'; +import * as rt from 'io-ts'; +import { HttpSetup } from 'src/core/public'; import React, { ChangeEvent, useCallback, useMemo, useEffect, useState } from 'react'; import { EuiSpacer, EuiText, EuiFormRow, + EuiButton, EuiButtonEmpty, EuiCheckbox, EuiToolTip, EuiIcon, EuiFieldSearch, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getIntervalInSeconds } from '../../../../server/utils/get_interval_in_seconds'; import { Comparator, Aggregators, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../server/lib/alerting/metric_threshold/types'; + INFRA_ALERT_PREVIEW_PATH, + alertPreviewRequestParamsRT, + alertPreviewSuccessResponsePayloadRT, + METRIC_THRESHOLD_ALERT_TYPE_ID, +} from '../../../../common/alerting/metrics'; import { ForLastExpression, // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -40,6 +51,7 @@ import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; import { ExpressionRow } from './expression_row'; import { AlertContextMeta, TimeUnit, MetricExpression } from '../types'; import { ExpressionChart } from './expression_chart'; +import { validateMetricThreshold } from './validation'; const FILTER_TYPING_DEBOUNCE_MS = 500; @@ -54,6 +66,7 @@ interface Props { alertOnNoData?: boolean; }; alertsContext: AlertsContextValue; + alertInterval: string; setAlertParams(key: string, value: any): void; setAlertProperty(key: string, value: any): void; } @@ -66,8 +79,24 @@ const defaultExpression = { timeUnit: 'm', } as MetricExpression; +async function getAlertPreview({ + fetch, + params, +}: { + fetch: HttpSetup['fetch']; + params: rt.TypeOf; +}): Promise> { + return await fetch(`${INFRA_ALERT_PREVIEW_PATH}`, { + method: 'POST', + body: JSON.stringify({ + ...params, + alertType: METRIC_THRESHOLD_ALERT_TYPE_ID, + }), + }); +} + export const Expressions: React.FC = (props) => { - const { setAlertParams, alertParams, errors, alertsContext } = props; + const { setAlertParams, alertParams, errors, alertsContext, alertInterval } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', type: 'metrics', @@ -75,6 +104,13 @@ export const Expressions: React.FC = (props) => { toastWarning: alertsContext.toastNotifications.addWarning, }); + const [previewLookbackInterval, setPreviewLookbackInterval] = useState('h'); + const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(false); + const [previewResult, setPreviewResult] = useState | null>(null); + const [timeSize, setTimeSize] = useState(1); const [timeUnit, setTimeUnit] = useState('m'); const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ @@ -143,7 +179,7 @@ export const Expressions: React.FC = (props) => { const onGroupByChange = useCallback( (group: string | null | string[]) => { - setAlertParams('groupBy', group || ''); + setAlertParams('groupBy', group && group.length ? group : ''); }, [setAlertParams] ); @@ -224,6 +260,33 @@ export const Expressions: React.FC = (props) => { } }, [alertsContext.metadata, derivedIndexPattern, setAlertParams]); + const onSelectPreviewLookbackInterval = useCallback((e) => { + setPreviewLookbackInterval(e.target.value); + setPreviewResult(null); + }, []); + + const onClickPreview = useCallback(async () => { + setIsPreviewLoading(true); + setPreviewResult(null); + setPreviewError(false); + try { + const result = await getAlertPreview({ + fetch: alertsContext.http.fetch, + params: { + ...pick(alertParams, 'criteria', 'groupBy', 'filterQuery'), + sourceId: alertParams.sourceId, + lookback: previewLookbackInterval as 'h' | 'd' | 'w' | 'M', + alertInterval, + }, + }); + setPreviewResult(result); + } catch (e) { + setPreviewError(true); + } finally { + setIsPreviewLoading(false); + } + }, [alertParams, alertInterval, alertsContext, previewLookbackInterval]); + useEffect(() => { if (alertParams.criteria && alertParams.criteria.length) { setTimeSize(alertParams.criteria[0].timeSize); @@ -246,6 +309,23 @@ export const Expressions: React.FC = (props) => { [onFilterChange] ); + const previewIntervalError = useMemo(() => { + const intervalInSeconds = getIntervalInSeconds(alertInterval); + const lookbackInSeconds = getIntervalInSeconds(`1${previewLookbackInterval}`); + if (intervalInSeconds >= lookbackInSeconds) { + return true; + } + return false; + }, [previewLookbackInterval, alertInterval]); + + const isPreviewDisabled = useMemo(() => { + const validationResult = validateMetricThreshold({ criteria: alertParams.criteria } as any); + const hasValidationErrors = Object.values(validationResult.errors).some((result) => + Object.values(result).some((arr) => Array.isArray(arr) && arr.length) + ); + return hasValidationErrors || previewIntervalError; + }, [alertParams.criteria, previewIntervalError]); + return ( <> @@ -381,10 +461,191 @@ export const Expressions: React.FC = (props) => { }} /> + + + + <> + + + + + + + {i18n.translate('xpack.infra.metrics.alertFlyout.testAlertTrigger', { + defaultMessage: 'Test alert trigger', + })} + + + + + {previewResult && !previewIntervalError && !previewResult.resultTotals.tooManyBuckets && ( + <> + + + {previewResult.resultTotals.fired}, + lookback: previewOptions.find((e) => e.value === previewLookbackInterval) + ?.shortText, + }} + />{' '} + {alertParams.groupBy ? ( + {previewResult.numberOfGroups}, + groupName: alertParams.groupBy, + plural: previewResult.numberOfGroups !== 1 ? 's' : '', + }} + /> + ) : ( + + )} + + {alertParams.alertOnNoData && previewResult.resultTotals.noData ? ( + <> + + + {previewResult.resultTotals.noData}, + plural: previewResult.resultTotals.noData !== 1 ? 's' : '', + }} + /> + + + ) : null} + {previewResult.resultTotals.error ? ( + <> + + + + + + ) : null} + + )} + {previewResult && previewResult.resultTotals.tooManyBuckets ? ( + <> + + + FOR THE LAST, + }} + /> + + + ) : null} + {previewIntervalError && ( + <> + + + check every, + }} + /> + + + )} + {previewError && ( + <> + + + + + + )} + + + ); }; +const previewOptions = [ + { + value: 'h', + text: i18n.translate('xpack.infra.metrics.alertFlyout.lastHourLabel', { + defaultMessage: 'Last hour', + }), + shortText: i18n.translate('xpack.infra.metrics.alertFlyout.hourLabel', { + defaultMessage: 'hour', + }), + }, + { + value: 'd', + text: i18n.translate('xpack.infra.metrics.alertFlyout.lastDayLabel', { + defaultMessage: 'Last day', + }), + shortText: i18n.translate('xpack.infra.metrics.alertFlyout.dayLabel', { + defaultMessage: 'day', + }), + }, + { + value: 'w', + text: i18n.translate('xpack.infra.metrics.alertFlyout.lastWeekLabel', { + defaultMessage: 'Last week', + }), + shortText: i18n.translate('xpack.infra.metrics.alertFlyout.weekLabel', { + defaultMessage: 'week', + }), + }, + { + value: 'M', + text: i18n.translate('xpack.infra.metrics.alertFlyout.lastMonthLabel', { + defaultMessage: 'Last month', + }), + shortText: i18n.translate('xpack.infra.metrics.alertFlyout.monthLabel', { + defaultMessage: 'month', + }), + }, +]; + +const firedTimeLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTime', { + defaultMessage: 'time', +}); +const firedTimesLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTimes', { + defaultMessage: 'times', +}); + // required for dynamic import // eslint-disable-next-line import/no-default-export export default Expressions; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 06135c6532d778..6fbdeff950d1a4 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -32,6 +32,7 @@ import { import { initInventoryMetaRoute } from './routes/inventory_metadata'; import { initLogSourceConfigurationRoutes, initLogSourceStatusRoutes } from './routes/log_sources'; import { initSourceRoute } from './routes/source'; +import { initAlertPreviewRoute } from './routes/alerting'; export const initInfraServer = (libs: InfraBackendLibs) => { const schema = makeExecutableSchema({ @@ -64,4 +65,5 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initInventoryMetaRoute(libs); initLogSourceConfigurationRoutes(libs); initLogSourceStatusRoutes(libs); + initAlertPreviewRoute(libs); }; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/create_percentile_aggregation.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_percentile_aggregation.ts similarity index 93% rename from x-pack/plugins/infra/server/lib/alerting/metric_threshold/create_percentile_aggregation.ts rename to x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_percentile_aggregation.ts index 2c83f6ecfd7056..3a5c53ca80880d 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/create_percentile_aggregation.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/create_percentile_aggregation.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Aggregators } from './types'; +import { Aggregators } from '../types'; export const createPercentileAggregation = ( type: Aggregators.P95 | Aggregators.P99, field: string diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts new file mode 100644 index 00000000000000..49b191c4e85c95 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mapValues, first, last, isNaN } from 'lodash'; +import { + TooManyBucketsPreviewExceptionMetadata, + isTooManyBucketsPreviewException, + TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, +} from '../../../../../common/alerting/metrics'; +import { InfraSource } from '../../../../../common/http_api/source_api'; +import { InfraDatabaseSearchResponse } from '../../../adapters/framework/adapter_types'; +import { createAfterKeyHandler } from '../../../../utils/create_afterkey_handler'; +import { AlertServices, AlertExecutorOptions } from '../../../../../../alerts/server'; +import { getAllCompositeData } from '../../../../utils/get_all_composite_data'; +import { MetricExpressionParams, Comparator, Aggregators } from '../types'; +import { DOCUMENT_COUNT_I18N } from '../messages'; +import { getElasticsearchMetricQuery } from './metric_query'; + +interface Aggregation { + aggregatedIntervals: { + buckets: Array<{ + aggregatedValue: { value: number; values?: Array<{ key: number; value: number }> }; + doc_count: number; + }>; + }; +} + +interface CompositeAggregationsResponse { + groupings: { + buckets: Aggregation[]; + }; +} + +export const evaluateAlert = ( + callCluster: AlertServices['callCluster'], + params: AlertExecutorOptions['params'], + config: InfraSource['configuration'], + timeframe?: { start: number; end: number } +) => { + const { criteria, groupBy, filterQuery } = params as { + criteria: MetricExpressionParams[]; + groupBy: string | undefined | string[]; + filterQuery: string | undefined; + }; + return Promise.all( + criteria.map(async (criterion) => { + const currentValues = await getMetric( + callCluster, + criterion, + config.metricAlias, + config.fields.timestamp, + groupBy, + filterQuery, + timeframe + ); + const { threshold, comparator } = criterion; + const comparisonFunction = comparatorMap[comparator]; + return mapValues( + currentValues, + (values: number | number[] | null | TooManyBucketsPreviewExceptionMetadata) => { + if (isTooManyBucketsPreviewException(values)) throw values; + return { + ...criterion, + metric: criterion.metric ?? DOCUMENT_COUNT_I18N, + currentValue: Array.isArray(values) ? last(values) : NaN, + shouldFire: Array.isArray(values) + ? values.map((value) => comparisonFunction(value, threshold)) + : [false], + isNoData: values === null, + isError: isNaN(values), + }; + } + ); + }) + ); +}; + +const getMetric: ( + callCluster: AlertServices['callCluster'], + params: MetricExpressionParams, + index: string, + timefield: string, + groupBy: string | undefined | string[], + filterQuery: string | undefined, + timeframe?: { start: number; end: number } +) => Promise> = async function ( + callCluster, + params, + index, + timefield, + groupBy, + filterQuery, + timeframe +) { + const { aggType } = params; + const hasGroupBy = groupBy && groupBy.length; + const searchBody = getElasticsearchMetricQuery( + params, + timefield, + hasGroupBy ? groupBy : undefined, + filterQuery, + timeframe + ); + + try { + if (hasGroupBy) { + const bucketSelector = ( + response: InfraDatabaseSearchResponse<{}, CompositeAggregationsResponse> + ) => response.aggregations?.groupings?.buckets || []; + const afterKeyHandler = createAfterKeyHandler( + 'aggs.groupings.composite.after', + (response) => response.aggregations?.groupings?.after_key + ); + const compositeBuckets = (await getAllCompositeData( + (body) => callCluster('search', { body, index }), + searchBody, + bucketSelector, + afterKeyHandler + )) as Array }>; + return compositeBuckets.reduce( + (result, bucket) => ({ + ...result, + [Object.values(bucket.key) + .map((value) => value) + .join(', ')]: getValuesFromAggregations(bucket, aggType), + }), + {} + ); + } + const result = await callCluster('search', { + body: searchBody, + index, + }); + + return { '*': getValuesFromAggregations(result.aggregations, aggType) }; + } catch (e) { + if (timeframe) { + // This code should only ever be reached when previewing the alert, not executing it + const causedByType = e.body?.error?.caused_by?.type; + if (causedByType === 'too_many_buckets_exception') { + return { + '*': { + [TOO_MANY_BUCKETS_PREVIEW_EXCEPTION]: true, + maxBuckets: e.body.error.caused_by.max_buckets, + }, + }; + } + } + return { '*': NaN }; // Trigger an Error state + } +}; + +const getValuesFromAggregations = ( + aggregations: Aggregation, + aggType: MetricExpressionParams['aggType'] +) => { + try { + const { buckets } = aggregations.aggregatedIntervals; + if (!buckets.length) return null; // No Data state + if (aggType === Aggregators.COUNT) { + return buckets.map((bucket) => bucket.doc_count); + } + if (aggType === Aggregators.P95 || aggType === Aggregators.P99) { + return buckets.map((bucket) => { + const values = bucket.aggregatedValue?.values || []; + const firstValue = first(values); + if (!firstValue) return null; + return firstValue.value; + }); + } + return buckets.map((bucket) => bucket.aggregatedValue.value); + } catch (e) { + return NaN; // Error state + } +}; + +const comparatorMap = { + [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => + value >= Math.min(a, b) && value <= Math.max(a, b), + [Comparator.OUTSIDE_RANGE]: (value: number, [a, b]: number[]) => value < a || value > b, + // `threshold` is always an array of numbers in case the BETWEEN/OUTSIDE_RANGE comparator is + // used; all other compartors will just destructure the first value in the array + [Comparator.GT]: (a: number, [b]: number[]) => a > b, + [Comparator.LT]: (a: number, [b]: number[]) => a < b, + [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, + [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts new file mode 100644 index 00000000000000..5680035d9d609b --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { networkTraffic } from '../../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; +import { MetricExpressionParams, Aggregators } from '../types'; +import { getIntervalInSeconds } from '../../../../utils/get_interval_in_seconds'; +import { getDateHistogramOffset } from '../../../snapshot/query_helpers'; +import { createPercentileAggregation } from './create_percentile_aggregation'; + +const MINIMUM_BUCKETS = 5; + +const getParsedFilterQuery: ( + filterQuery: string | undefined +) => Record | Array> = (filterQuery) => { + if (!filterQuery) return {}; + return JSON.parse(filterQuery).bool; +}; + +export const getElasticsearchMetricQuery = ( + { metric, aggType, timeUnit, timeSize }: MetricExpressionParams, + timefield: string, + groupBy?: string | string[], + filterQuery?: string, + timeframe?: { start: number; end: number } +) => { + if (aggType === Aggregators.COUNT && metric) { + throw new Error('Cannot aggregate document count with a metric'); + } + if (aggType !== Aggregators.COUNT && !metric) { + throw new Error('Can only aggregate without a metric if using the document count aggregator'); + } + const interval = `${timeSize}${timeUnit}`; + const intervalAsSeconds = getIntervalInSeconds(interval); + + const to = timeframe ? timeframe.end : Date.now(); + // We need enough data for 5 buckets worth of data. We also need + // to convert the intervalAsSeconds to milliseconds. + const minimumFrom = to - intervalAsSeconds * 1000 * MINIMUM_BUCKETS; + + const from = timeframe && timeframe.start <= minimumFrom ? timeframe.start : minimumFrom; + + const offset = getDateHistogramOffset(from, interval); + + const aggregations = + aggType === Aggregators.COUNT + ? {} + : aggType === Aggregators.RATE + ? networkTraffic('aggregatedValue', metric) + : aggType === Aggregators.P95 || aggType === Aggregators.P99 + ? createPercentileAggregation(aggType, metric) + : { + aggregatedValue: { + [aggType]: { + field: metric, + }, + }, + }; + + const baseAggs = { + aggregatedIntervals: { + date_histogram: { + field: timefield, + fixed_interval: interval, + offset, + extended_bounds: { + min: from, + max: to, + }, + }, + aggregations, + }, + }; + + const aggs = groupBy + ? { + groupings: { + composite: { + size: 10, + sources: Array.isArray(groupBy) + ? groupBy.map((field, index) => ({ + [`groupBy${index}`]: { + terms: { field }, + }, + })) + : [ + { + groupBy0: { + terms: { + field: groupBy, + }, + }, + }, + ], + }, + aggs: baseAggs, + }, + } + : baseAggs; + + const rangeFilters = [ + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'epoch_millis', + }, + }, + }, + ]; + + const metricFieldFilters = metric + ? [ + { + exists: { + field: metric, + }, + }, + ] + : []; + + const parsedFilterQuery = getParsedFilterQuery(filterQuery); + + return { + query: { + bool: { + filter: [ + ...rangeFilters, + ...metricFieldFilters, + ...(Array.isArray(parsedFilterQuery) ? parsedFilterQuery : []), + ], + ...(!Array.isArray(parsedFilterQuery) ? parsedFilterQuery : {}), + }, + }, + size: 0, + aggs, + }; +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 8260ebed846222..f28137d980b9f1 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -383,34 +383,6 @@ const executor = createMetricThresholdExecutor(mockLibs, 'test') as (opts: { }) => Promise; const services: AlertServicesMock = alertsMock.createAlertServices(); -services.callCluster.mockImplementation(async (_: string, { body, index }: any) => { - if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse; - const metric = body.query.bool.filter[1]?.exists.field; - if (body.aggs.groupings) { - if (body.aggs.groupings.composite.after) { - return mocks.compositeEndResponse; - } - if (metric === 'test.metric.2') { - return mocks.alternateCompositeResponse; - } - return mocks.basicCompositeResponse; - } - if (metric === 'test.metric.2') { - return mocks.alternateMetricResponse; - } - return mocks.basicMetricResponse; -}); -services.savedObjectsClient.get.mockImplementation(async (type: string, sourceId: string) => { - if (sourceId === 'alternate') - return { - id: 'alternate', - attributes: { metricAlias: 'alternatebeat-*' }, - type, - references: [], - }; - return { id: 'default', attributes: { metricAlias: 'metricbeat-*' }, type, references: [] }; -}); - services.callCluster.mockImplementation(async (_: string, { body, index }: any) => { if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse; const metric = body.query.bool.filter[1]?.exists.field; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 00a1d97dec811a..4fe28fad68c85c 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -3,263 +3,25 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { mapValues, first } from 'lodash'; +import { first, last } from 'lodash'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; -import { InfraDatabaseSearchResponse } from '../../adapters/framework/adapter_types'; -import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler'; -import { getAllCompositeData } from '../../../utils/get_all_composite_data'; -import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; -import { MetricExpressionParams, Comparator, Aggregators, AlertStates } from './types'; +import { AlertExecutorOptions } from '../../../../../alerts/server'; +import { InfraBackendLibs } from '../../infra_types'; +import { AlertStates } from './types'; +import { evaluateAlert } from './lib/evaluate_alert'; import { buildErrorAlertReason, buildFiredAlertReason, buildNoDataAlertReason, - DOCUMENT_COUNT_I18N, stateToAlertMessage, } from './messages'; -import { AlertServices, AlertExecutorOptions } from '../../../../../alerts/server'; -import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; -import { getDateHistogramOffset } from '../../snapshot/query_helpers'; -import { InfraBackendLibs } from '../../infra_types'; -import { createPercentileAggregation } from './create_percentile_aggregation'; - -const TOTAL_BUCKETS = 5; - -interface Aggregation { - aggregatedIntervals: { - buckets: Array<{ - aggregatedValue: { value: number; values?: Array<{ key: number; value: number }> }; - doc_count: number; - }>; - }; -} - -interface CompositeAggregationsResponse { - groupings: { - buckets: Aggregation[]; - }; -} - -const getCurrentValueFromAggregations = ( - aggregations: Aggregation, - aggType: MetricExpressionParams['aggType'] -) => { - try { - const { buckets } = aggregations.aggregatedIntervals; - if (!buckets.length) return null; // No Data state - const mostRecentBucket = buckets[buckets.length - 1]; - if (aggType === Aggregators.COUNT) { - return mostRecentBucket.doc_count; - } - if (aggType === Aggregators.P95 || aggType === Aggregators.P99) { - const values = mostRecentBucket.aggregatedValue?.values || []; - const firstValue = first(values); - if (!firstValue) return null; - return firstValue.value; - } - const { value } = mostRecentBucket.aggregatedValue; - return value; - } catch (e) { - return undefined; // Error state - } -}; - -const getParsedFilterQuery: ( - filterQuery: string | undefined -) => Record | Array> = (filterQuery) => { - if (!filterQuery) return {}; - return JSON.parse(filterQuery).bool; -}; - -export const getElasticsearchMetricQuery = ( - { metric, aggType, timeUnit, timeSize }: MetricExpressionParams, - timefield: string, - groupBy?: string | string[], - filterQuery?: string -) => { - if (aggType === Aggregators.COUNT && metric) { - throw new Error('Cannot aggregate document count with a metric'); - } - if (aggType !== Aggregators.COUNT && !metric) { - throw new Error('Can only aggregate without a metric if using the document count aggregator'); - } - const interval = `${timeSize}${timeUnit}`; - const to = Date.now(); - const intervalAsSeconds = getIntervalInSeconds(interval); - // We need enough data for 5 buckets worth of data. We also need - // to convert the intervalAsSeconds to milliseconds. - const from = to - intervalAsSeconds * 1000 * TOTAL_BUCKETS; - const offset = getDateHistogramOffset(from, interval); - - const aggregations = - aggType === Aggregators.COUNT - ? {} - : aggType === Aggregators.RATE - ? networkTraffic('aggregatedValue', metric) - : aggType === Aggregators.P95 || aggType === Aggregators.P99 - ? createPercentileAggregation(aggType, metric) - : { - aggregatedValue: { - [aggType]: { - field: metric, - }, - }, - }; - - const baseAggs = { - aggregatedIntervals: { - date_histogram: { - field: timefield, - fixed_interval: interval, - offset, - extended_bounds: { - min: from, - max: to, - }, - }, - aggregations, - }, - }; - - const aggs = groupBy - ? { - groupings: { - composite: { - size: 10, - sources: Array.isArray(groupBy) - ? groupBy.map((field, index) => ({ - [`groupBy${index}`]: { - terms: { field }, - }, - })) - : [ - { - groupBy0: { - terms: { - field: groupBy, - }, - }, - }, - ], - }, - aggs: baseAggs, - }, - } - : baseAggs; - - const rangeFilters = [ - { - range: { - '@timestamp': { - gte: from, - lte: to, - format: 'epoch_millis', - }, - }, - }, - ]; - - const metricFieldFilters = metric - ? [ - { - exists: { - field: metric, - }, - }, - ] - : []; - - const parsedFilterQuery = getParsedFilterQuery(filterQuery); - - return { - query: { - bool: { - filter: [ - ...rangeFilters, - ...metricFieldFilters, - ...(Array.isArray(parsedFilterQuery) ? parsedFilterQuery : []), - ], - ...(!Array.isArray(parsedFilterQuery) ? parsedFilterQuery : {}), - }, - }, - size: 0, - aggs, - }; -}; - -const getMetric: ( - services: AlertServices, - params: MetricExpressionParams, - index: string, - timefield: string, - groupBy: string | undefined | string[], - filterQuery: string | undefined -) => Promise> = async function ( - { callCluster }, - params, - index, - timefield, - groupBy, - filterQuery -) { - const { aggType } = params; - const searchBody = getElasticsearchMetricQuery(params, timefield, groupBy, filterQuery); - - try { - if (groupBy) { - const bucketSelector = ( - response: InfraDatabaseSearchResponse<{}, CompositeAggregationsResponse> - ) => response.aggregations?.groupings?.buckets || []; - const afterKeyHandler = createAfterKeyHandler( - 'aggs.groupings.composite.after', - (response) => response.aggregations?.groupings?.after_key - ); - const compositeBuckets = (await getAllCompositeData( - (body) => callCluster('search', { body, index }), - searchBody, - bucketSelector, - afterKeyHandler - )) as Array }>; - return compositeBuckets.reduce( - (result, bucket) => ({ - ...result, - [Object.values(bucket.key) - .map((value) => value) - .join(', ')]: getCurrentValueFromAggregations(bucket, aggType), - }), - {} - ); - } - const result = await callCluster('search', { - body: searchBody, - index, - }); - - return { '*': getCurrentValueFromAggregations(result.aggregations, aggType) }; - } catch (e) { - return { '*': undefined }; // Trigger an Error state - } -}; - -const comparatorMap = { - [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => - value >= Math.min(a, b) && value <= Math.max(a, b), - [Comparator.OUTSIDE_RANGE]: (value: number, [a, b]: number[]) => value < a || value > b, - // `threshold` is always an array of numbers in case the BETWEEN/OUTSIDE_RANGE comparator is - // used; all other compartors will just destructure the first value in the array - [Comparator.GT]: (a: number, [b]: number[]) => a > b, - [Comparator.LT]: (a: number, [b]: number[]) => a < b, - [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, - [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, -}; export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: string) => - async function ({ services, params }: AlertExecutorOptions) { - const { criteria, groupBy, filterQuery, sourceId, alertOnNoData } = params as { - criteria: MetricExpressionParams[]; - groupBy: string | undefined | string[]; - filterQuery: string | undefined; + async function (options: AlertExecutorOptions) { + const { services, params } = options; + const { criteria } = params; + const { sourceId, alertOnNoData } = params as { sourceId?: string; alertOnNoData: boolean; }; @@ -269,39 +31,18 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: s sourceId || 'default' ); const config = source.configuration; - const alertResults = await Promise.all( - criteria.map((criterion) => { - return (async () => { - const currentValues = await getMetric( - services, - criterion, - config.metricAlias, - config.fields.timestamp, - groupBy, - filterQuery - ); - const { threshold, comparator } = criterion; - const comparisonFunction = comparatorMap[comparator]; - return mapValues(currentValues, (value) => ({ - ...criterion, - metric: criterion.metric ?? DOCUMENT_COUNT_I18N, - currentValue: value, - shouldFire: - value !== undefined && value !== null && comparisonFunction(value, threshold), - isNoData: value === null, - isError: value === undefined, - })); - })(); - }) - ); + const alertResults = await evaluateAlert(services.callCluster, params, config); - // Because each alert result has the same group definitions, just grap the groups from the first one. + // Because each alert result has the same group definitions, just grab the groups from the first one. const groups = Object.keys(first(alertResults)); for (const group of groups) { const alertInstance = services.alertInstanceFactory(`${alertId}-${group}`); // AND logic; all criteria must be across the threshold - const shouldAlertFire = alertResults.every((result) => result[group].shouldFire); + const shouldAlertFire = alertResults.every((result) => + // Grab the result of the most recent bucket + last(result[group].shouldFire) + ); // AND logic; because we need to evaluate all criteria, if one of them reports no data then the // whole alert is in a No Data/Error state const isNoData = alertResults.some((result) => result[group].isNoData); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts new file mode 100644 index 00000000000000..7aa8367f7678ca --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { first, zip } from 'lodash'; +import { + TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, + isTooManyBucketsPreviewException, +} from '../../../../common/alerting/metrics'; +import { IScopedClusterClient } from '../../../../../../../src/core/server'; +import { InfraSource } from '../../../../common/http_api/source_api'; +import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; +import { MetricExpressionParams } from './types'; +import { evaluateAlert } from './lib/evaluate_alert'; + +const MAX_ITERATIONS = 50; + +interface PreviewMetricThresholdAlertParams { + callCluster: IScopedClusterClient['callAsCurrentUser']; + params: { + criteria: MetricExpressionParams[]; + groupBy: string | undefined | string[]; + filterQuery: string | undefined; + }; + config: InfraSource['configuration']; + lookback: 'h' | 'd' | 'w' | 'M'; + alertInterval: string; + end?: number; + overrideLookbackIntervalInSeconds?: number; +} + +export const previewMetricThresholdAlert: ( + params: PreviewMetricThresholdAlertParams, + iterations?: number, + precalculatedNumberOfGroups?: number +) => Promise> = async ( + { + callCluster, + params, + config, + lookback, + alertInterval, + end = Date.now(), + overrideLookbackIntervalInSeconds, + }, + iterations = 0, + precalculatedNumberOfGroups +) => { + // There are three different "intervals" we're dealing with here, so to disambiguate: + // - The lookback interval, which is how long of a period of time we want to examine to count + // how many times the alert fired + // - The interval in the alert params, which we'll call the bucket interval; this is how large of + // a time bucket the alert uses to evaluate its result + // - The alert interval, which is how often the alert fires + + const { timeSize, timeUnit } = params.criteria[0]; + const bucketInterval = `${timeSize}${timeUnit}`; + const bucketIntervalInSeconds = getIntervalInSeconds(bucketInterval); + + const lookbackInterval = `1${lookback}`; + const lookbackIntervalInSeconds = + overrideLookbackIntervalInSeconds ?? getIntervalInSeconds(lookbackInterval); + + const start = end - lookbackIntervalInSeconds * 1000; + const timeframe = { start, end }; + + // Get a date histogram using the bucket interval and the lookback interval + try { + const alertResults = await evaluateAlert(callCluster, params, config, timeframe); + const groups = Object.keys(first(alertResults)); + + // Now determine how to interpolate this histogram based on the alert interval + const alertIntervalInSeconds = getIntervalInSeconds(alertInterval); + const alertResultsPerExecution = alertIntervalInSeconds / bucketIntervalInSeconds; + const previewResults = await Promise.all( + groups.map(async (group) => { + const tooManyBuckets = alertResults.some((alertResult) => + isTooManyBucketsPreviewException(alertResult[group]) + ); + if (tooManyBuckets) { + return TOO_MANY_BUCKETS_PREVIEW_EXCEPTION; + } + + const isNoData = alertResults.some((alertResult) => alertResult[group].isNoData); + if (isNoData) { + return null; + } + const isError = alertResults.some((alertResult) => alertResult[group].isError); + if (isError) { + return NaN; + } + + // Interpolate the buckets returned by evaluateAlert and return a count of how many of these + // buckets would have fired the alert. If the alert interval and bucket interval are the same, + // this will be a 1:1 evaluation of the alert results. If these are different, the interpolation + // will skip some buckets or read some buckets more than once, depending on the differential + const numberOfResultBuckets = first(alertResults)[group].shouldFire.length; + const numberOfExecutionBuckets = Math.floor( + numberOfResultBuckets / alertResultsPerExecution + ); + let numberOfTimesFired = 0; + for (let i = 0; i < numberOfExecutionBuckets; i++) { + const mappedBucketIndex = Math.floor(i * alertResultsPerExecution); + const allConditionsFiredInMappedBucket = alertResults.every( + (alertResult) => alertResult[group].shouldFire[mappedBucketIndex] + ); + if (allConditionsFiredInMappedBucket) numberOfTimesFired++; + } + return numberOfTimesFired; + }) + ); + return previewResults; + } catch (e) { + if (isTooManyBucketsPreviewException(e)) { + // If there's too much data on the first request, recursively slice the lookback interval + // until all the data can be retrieved + const basePreviewParams = { callCluster, params, config, lookback, alertInterval }; + const { maxBuckets } = e; + // If this is still the first iteration, try to get the number of groups in order to + // calculate max buckets. If this fails, just estimate based on 1 group + const currentAlertResults = !precalculatedNumberOfGroups + ? await evaluateAlert(callCluster, params, config) + : []; + const numberOfGroups = + precalculatedNumberOfGroups ?? Math.max(Object.keys(first(currentAlertResults)).length, 1); + const estimatedTotalBuckets = + (lookbackIntervalInSeconds / bucketIntervalInSeconds) * numberOfGroups; + // The minimum number of slices is 2. In case we underestimate the total number of buckets + // in the first iteration, we can bisect the remaining buckets on further recursions to get + // all the data needed + const slices = Math.max(Math.ceil(estimatedTotalBuckets / maxBuckets), 2); + const slicedLookback = Math.floor(lookbackIntervalInSeconds / slices); + + // Bail out if it looks like this is going to take too long + if (slicedLookback <= 0 || iterations > MAX_ITERATIONS || slices > MAX_ITERATIONS) { + return [TOO_MANY_BUCKETS_PREVIEW_EXCEPTION]; + } + + const slicedRequests = [...Array(slices)].map((_, i) => { + return previewMetricThresholdAlert( + { + ...basePreviewParams, + end: Math.min(end, start + slicedLookback * (i + 1) * 1000), + overrideLookbackIntervalInSeconds: slicedLookback, + }, + iterations + slices, + numberOfGroups + ); + }); + const results = await Promise.all(slicedRequests); + const zippedResult = zip(...results).map((result) => + result + // `undefined` values occur if there is no data at all in a certain slice, and that slice + // returns an empty array. This is different from an error or no data state, + // so filter these results out entirely and only regard the resultA portion + .filter((value) => typeof value !== 'undefined') + .reduce((a, b) => { + if (typeof a !== 'number') return a; + if (typeof b !== 'number') return b; + return a + b; + }) + ); + return zippedResult; + } else throw e; + } +}; diff --git a/x-pack/plugins/infra/server/routes/alerting/index.ts b/x-pack/plugins/infra/server/routes/alerting/index.ts new file mode 100644 index 00000000000000..4ba2f56360f8a9 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/alerting/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './preview'; diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts new file mode 100644 index 00000000000000..f4eed041481f6a --- /dev/null +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + METRIC_THRESHOLD_ALERT_TYPE_ID, + METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, + INFRA_ALERT_PREVIEW_PATH, + TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, + alertPreviewRequestParamsRT, + alertPreviewSuccessResponsePayloadRT, + MetricThresholdAlertPreviewRequestParams, +} from '../../../common/alerting/metrics'; +import { createValidationFunction } from '../../../common/runtime_types'; +import { previewMetricThresholdAlert } from '../../lib/alerting/metric_threshold/preview_metric_threshold_alert'; +import { InfraBackendLibs } from '../../lib/infra_types'; + +export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) => { + const { callWithRequest } = framework; + framework.registerRoute( + { + method: 'post', + path: INFRA_ALERT_PREVIEW_PATH, + validate: { + body: createValidationFunction(alertPreviewRequestParamsRT), + }, + }, + framework.router.handleLegacyErrors(async (requestContext, request, response) => { + const { criteria, filterQuery, lookback, sourceId, alertType, alertInterval } = request.body; + + const callCluster = (endpoint: string, opts: Record) => { + return callWithRequest(requestContext, endpoint, opts); + }; + + const source = await sources.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId || 'default' + ); + + try { + switch (alertType) { + case METRIC_THRESHOLD_ALERT_TYPE_ID: { + const { groupBy } = request.body as MetricThresholdAlertPreviewRequestParams; + const previewResult = await previewMetricThresholdAlert({ + callCluster, + params: { criteria, filterQuery, groupBy }, + lookback, + config: source.configuration, + alertInterval, + }); + + const numberOfGroups = previewResult.length; + const resultTotals = previewResult.reduce( + (totals, groupResult) => { + if (groupResult === TOO_MANY_BUCKETS_PREVIEW_EXCEPTION) + return { ...totals, tooManyBuckets: totals.tooManyBuckets + 1 }; + if (groupResult === null) return { ...totals, noData: totals.noData + 1 }; + if (isNaN(groupResult)) return { ...totals, error: totals.error + 1 }; + return { ...totals, fired: totals.fired + groupResult }; + }, + { + fired: 0, + noData: 0, + error: 0, + tooManyBuckets: 0, + } + ); + + return response.ok({ + body: alertPreviewSuccessResponsePayloadRT.encode({ + numberOfGroups, + resultTotals, + }), + }); + } + case METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID: { + // TODO: Add inventory preview functionality + return response.ok({}); + } + default: + throw new Error('Unknown alert type'); + } + } catch (error) { + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + }) + ); +}; diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts index 3309d8497f4c52..1fe29aa54f6f9a 100644 --- a/x-pack/plugins/ingest_manager/common/constants/routes.ts +++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts @@ -44,6 +44,7 @@ export const AGENT_CONFIG_API_ROUTES = { INFO_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}`, CREATE_PATTERN: `${AGENT_CONFIG_API_ROOT}`, UPDATE_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}`, + COPY_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}/copy`, DELETE_PATTERN: `${AGENT_CONFIG_API_ROOT}/delete`, FULL_INFO_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}/full`, FULL_INFO_DOWNLOAD_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}/download`, diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json index f0ed3ed9a0364d..ea61d97145795e 100644 --- a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json +++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json @@ -48,13 +48,7 @@ "type": "boolean" } }, - "required": [ - "items", - "total", - "page", - "perPage", - "success" - ] + "required": ["items", "total", "page", "perPage", "success"] }, "examples": { "success": { @@ -66,14 +60,9 @@ "namespace": "default", "description": "Default agent configuration created by Kibana", "status": "active", - "datasources": [ - "8a5679b0-8fbf-11ea-b2ce-01c4a6127154" - ], + "datasources": ["8a5679b0-8fbf-11ea-b2ce-01c4a6127154"], "is_default": true, - "monitoring_enabled": [ - "logs", - "metrics" - ], + "monitoring_enabled": ["logs", "metrics"], "revision": 2, "updated_on": "2020-05-06T17:32:21.905Z", "updated_by": "system", @@ -175,10 +164,7 @@ "type": "boolean" } }, - "required": [ - "item", - "success" - ] + "required": ["item", "success"] }, "examples": { "success": { @@ -213,21 +199,13 @@ "dataset": "system.auth", "vars": { "paths": { - "value": [ - "/var/log/auth.log*", - "/var/log/secure*" - ], + "value": ["/var/log/auth.log*", "/var/log/secure*"], "type": "text" } }, "agent_stream": { - "paths": [ - "/var/log/auth.log*", - "/var/log/secure*" - ], - "exclude_files": [ - ".gz$" - ], + "paths": ["/var/log/auth.log*", "/var/log/secure*"], + "exclude_files": [".gz$"], "multiline": { "pattern": "^\\s", "match": "after" @@ -253,21 +231,13 @@ "dataset": "system.syslog", "vars": { "paths": { - "value": [ - "/var/log/messages*", - "/var/log/syslog*" - ], + "value": ["/var/log/messages*", "/var/log/syslog*"], "type": "text" } }, "agent_stream": { - "paths": [ - "/var/log/messages*", - "/var/log/syslog*" - ], - "exclude_files": [ - ".gz$" - ], + "paths": ["/var/log/messages*", "/var/log/syslog*"], + "exclude_files": [".gz$"], "multiline": { "pattern": "^\\s", "match": "after" @@ -299,16 +269,12 @@ "dataset": "system.core", "vars": { "core.metrics": { - "value": [ - "percentages" - ], + "value": ["percentages"], "type": "text" } }, "agent_stream": { - "metricsets": [ - "core" - ], + "metricsets": ["core"], "core.metrics": "percentages" } }, @@ -318,16 +284,11 @@ "dataset": "system.cpu", "vars": { "core.metrics": { - "value": [ - "percentages" - ], + "value": ["percentages"], "type": "text" }, "cpu.metrics": { - "value": [ - "percentages", - "normalized_percentages" - ], + "value": ["percentages", "normalized_percentages"], "type": "text" }, "period": { @@ -343,16 +304,12 @@ "type": "integer" }, "processes": { - "value": [ - ".*" - ], + "value": [".*"], "type": "text" } }, "agent_stream": { - "metricsets": [ - "cpu" - ], + "metricsets": ["cpu"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -366,9 +323,7 @@ "enabled": true, "dataset": "system.diskio", "agent_stream": { - "metricsets": [ - "diskio" - ] + "metricsets": ["diskio"] } }, { @@ -376,9 +331,7 @@ "enabled": true, "dataset": "system.entropy", "agent_stream": { - "metricsets": [ - "entropy" - ] + "metricsets": ["entropy"] } }, { @@ -396,9 +349,7 @@ } }, "agent_stream": { - "metricsets": [ - "filesystem" - ], + "metricsets": ["filesystem"], "period": "1m", "processors": [ { @@ -424,9 +375,7 @@ } }, "agent_stream": { - "metricsets": [ - "fsstat" - ], + "metricsets": ["fsstat"], "period": "1m", "processors": [ { @@ -443,16 +392,11 @@ "dataset": "system.load", "vars": { "core.metrics": { - "value": [ - "percentages" - ], + "value": ["percentages"], "type": "text" }, "cpu.metrics": { - "value": [ - "percentages", - "normalized_percentages" - ], + "value": ["percentages", "normalized_percentages"], "type": "text" }, "period": { @@ -468,16 +412,12 @@ "type": "integer" }, "processes": { - "value": [ - ".*" - ], + "value": [".*"], "type": "text" } }, "agent_stream": { - "metricsets": [ - "load" - ], + "metricsets": ["load"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -492,16 +432,11 @@ "dataset": "system.memory", "vars": { "core.metrics": { - "value": [ - "percentages" - ], + "value": ["percentages"], "type": "text" }, "cpu.metrics": { - "value": [ - "percentages", - "normalized_percentages" - ], + "value": ["percentages", "normalized_percentages"], "type": "text" }, "period": { @@ -517,16 +452,12 @@ "type": "integer" }, "processes": { - "value": [ - ".*" - ], + "value": [".*"], "type": "text" } }, "agent_stream": { - "metricsets": [ - "memory" - ], + "metricsets": ["memory"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -541,16 +472,11 @@ "dataset": "system.network", "vars": { "core.metrics": { - "value": [ - "percentages" - ], + "value": ["percentages"], "type": "text" }, "cpu.metrics": { - "value": [ - "percentages", - "normalized_percentages" - ], + "value": ["percentages", "normalized_percentages"], "type": "text" }, "period": { @@ -566,16 +492,12 @@ "type": "integer" }, "processes": { - "value": [ - ".*" - ], + "value": [".*"], "type": "text" } }, "agent_stream": { - "metricsets": [ - "network" - ], + "metricsets": ["network"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -589,9 +511,7 @@ "enabled": true, "dataset": "system.network_summary", "agent_stream": { - "metricsets": [ - "network_summary" - ] + "metricsets": ["network_summary"] } }, { @@ -600,16 +520,11 @@ "dataset": "system.process", "vars": { "core.metrics": { - "value": [ - "percentages" - ], + "value": ["percentages"], "type": "text" }, "cpu.metrics": { - "value": [ - "percentages", - "normalized_percentages" - ], + "value": ["percentages", "normalized_percentages"], "type": "text" }, "period": { @@ -625,16 +540,12 @@ "type": "integer" }, "processes": { - "value": [ - ".*" - ], + "value": [".*"], "type": "text" } }, "agent_stream": { - "metricsets": [ - "process" - ], + "metricsets": ["process"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -649,16 +560,11 @@ "dataset": "system.process_summary", "vars": { "core.metrics": { - "value": [ - "percentages" - ], + "value": ["percentages"], "type": "text" }, "cpu.metrics": { - "value": [ - "percentages", - "normalized_percentages" - ], + "value": ["percentages", "normalized_percentages"], "type": "text" }, "period": { @@ -674,16 +580,12 @@ "type": "integer" }, "processes": { - "value": [ - ".*" - ], + "value": [".*"], "type": "text" } }, "agent_stream": { - "metricsets": [ - "process_summary" - ], + "metricsets": ["process_summary"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -697,9 +599,7 @@ "enabled": true, "dataset": "system.raid", "agent_stream": { - "metricsets": [ - "raid" - ] + "metricsets": ["raid"] } }, { @@ -707,9 +607,7 @@ "enabled": true, "dataset": "system.service", "agent_stream": { - "metricsets": [ - "service" - ] + "metricsets": ["service"] } }, { @@ -717,9 +615,7 @@ "enabled": true, "dataset": "system.socket", "agent_stream": { - "metricsets": [ - "socket" - ] + "metricsets": ["socket"] } }, { @@ -728,16 +624,11 @@ "dataset": "system.socket_summary", "vars": { "core.metrics": { - "value": [ - "percentages" - ], + "value": ["percentages"], "type": "text" }, "cpu.metrics": { - "value": [ - "percentages", - "normalized_percentages" - ], + "value": ["percentages", "normalized_percentages"], "type": "text" }, "period": { @@ -753,16 +644,12 @@ "type": "integer" }, "processes": { - "value": [ - ".*" - ], + "value": [".*"], "type": "text" } }, "agent_stream": { - "metricsets": [ - "socket_summary" - ], + "metricsets": ["socket_summary"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -777,16 +664,11 @@ "dataset": "system.uptime", "vars": { "core.metrics": { - "value": [ - "percentages" - ], + "value": ["percentages"], "type": "text" }, "cpu.metrics": { - "value": [ - "percentages", - "normalized_percentages" - ], + "value": ["percentages", "normalized_percentages"], "type": "text" }, "period": { @@ -794,16 +676,12 @@ "type": "text" }, "processes": { - "value": [ - ".*" - ], + "value": [".*"], "type": "text" } }, "agent_stream": { - "metricsets": [ - "uptime" - ], + "metricsets": ["uptime"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -815,9 +693,7 @@ "enabled": true, "dataset": "system.users", "agent_stream": { - "metricsets": [ - "users" - ] + "metricsets": ["users"] } } ] @@ -827,10 +703,7 @@ } ], "is_default": true, - "monitoring_enabled": [ - "logs", - "metrics" - ], + "monitoring_enabled": ["logs", "metrics"], "revision": 2, "updated_on": "2020-05-06T17:32:21.905Z", "updated_by": "system" @@ -865,10 +738,7 @@ "type": "boolean" } }, - "required": [ - "item", - "success" - ] + "required": ["item", "success"] }, "examples": { "example-1": { @@ -916,6 +786,64 @@ ] } }, + "/agent_configs/{agentConfigId}/copy": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "agentConfigId", + "in": "path", + "required": true + } + ], + "post": { + "summary": "Agent config - copy one config", + "operationId": "agent-config-copy", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/AgentConfig" + }, + "success": { + "type": "boolean" + } + }, + "required": ["item", "success"] + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["name"] + }, + "examples": {} + } + }, + "description": "" + }, + "description": "Copies one agent config" + } + }, "/agent_configs/delete": { "post": { "summary": "Agent Config - Delete", @@ -937,10 +865,7 @@ "type": "boolean" } }, - "required": [ - "id", - "success" - ] + "required": ["id", "success"] } }, "examples": { @@ -982,9 +907,7 @@ "examples": { "example-1": { "value": { - "agentConfigIds": [ - "df7d2540-5a47-11ea-80da-89b5a66da347" - ] + "agentConfigIds": ["df7d2540-5a47-11ea-80da-89b5a66da347"] } } } @@ -1030,10 +953,7 @@ "type": "boolean" } }, - "required": [ - "items", - "success" - ] + "required": ["items", "success"] }, "examples": { "example-1": { @@ -1056,10 +976,7 @@ "type": "logs", "streams": [ { - "paths": [ - "/var/log/hello1.log", - "/var/log/hello2.log" - ] + "paths": ["/var/log/hello1.log", "/var/log/hello2.log"] } ] } @@ -1098,16 +1015,12 @@ { "enabled": true, "dataset": "nginx.acccess", - "paths": [ - "/var/log/nginx/access.log" - ] + "paths": ["/var/log/nginx/access.log"] }, { "enabled": true, "dataset": "nginx.error", - "paths": [ - "/var/log/nginx/error.log" - ] + "paths": ["/var/log/nginx/error.log"] } ] }, @@ -1141,16 +1054,12 @@ { "enabled": true, "dataset": "nginx.acccess", - "paths": [ - "/var/log/nginx/access.log" - ] + "paths": ["/var/log/nginx/access.log"] }, { "enabled": true, "dataset": "nginx.error", - "paths": [ - "/var/log/nginx/error.log" - ] + "paths": ["/var/log/nginx/error.log"] } ] }, @@ -1184,16 +1093,12 @@ { "enabled": true, "dataset": "nginx.acccess", - "paths": [ - "/var/log/nginx/access.log" - ] + "paths": ["/var/log/nginx/access.log"] }, { "enabled": true, "dataset": "nginx.error", - "paths": [ - "/var/log/nginx/error.log" - ] + "paths": ["/var/log/nginx/error.log"] } ] }, @@ -1224,16 +1129,12 @@ { "streams": [ { - "paths": [ - "/var/log/nginx/access.log" - ], + "paths": ["/var/log/nginx/access.log"], "dataset": "nginx.acccess", "enabled": true }, { - "paths": [ - "/var/log/nginx/error.log" - ], + "paths": ["/var/log/nginx/error.log"], "dataset": "nginx.error", "enabled": true } @@ -1302,16 +1203,12 @@ { "enabled": true, "dataset": "nginx.acccess", - "paths": [ - "/var/log/nginx/access.log" - ] + "paths": ["/var/log/nginx/access.log"] }, { "enabled": true, "dataset": "nginx.error", - "paths": [ - "/var/log/nginx/error.log" - ] + "paths": ["/var/log/nginx/error.log"] } ] }, @@ -1359,10 +1256,7 @@ "type": "boolean" } }, - "required": [ - "item", - "success" - ] + "required": ["item", "success"] } } } @@ -1398,10 +1292,7 @@ "type": "boolean" } }, - "required": [ - "item", - "sucess" - ] + "required": ["item", "sucess"] } } } @@ -1430,9 +1321,7 @@ "type": "boolean" } }, - "required": [ - "isInitialized" - ] + "required": ["isInitialized"] }, "examples": { "success": { @@ -1472,9 +1361,7 @@ "type": "boolean" } }, - "required": [ - "isInitialized" - ] + "required": ["isInitialized"] }, "examples": { "success": { @@ -1500,10 +1387,7 @@ "type": "string" } }, - "required": [ - "admin_username", - "admin_password" - ] + "required": ["admin_username", "admin_password"] } } } @@ -1541,19 +1425,13 @@ "properties": { "status": { "type": "string", - "enum": [ - "installed", - "not_installed" - ] + "enum": ["installed", "not_installed"] }, "savedObject": { "type": "string" } }, - "required": [ - "status", - "savedObject" - ] + "required": ["status", "savedObject"] } ] }, @@ -1567,10 +1445,7 @@ "readme": "/package/coredns-1.0.1/docs/README.md", "description": "CoreDNS logs and metrics integration.\nThe CoreDNS integrations allows to gather logs and metrics from the CoreDNS DNS server to get better insights.\n", "type": "integration", - "categories": [ - "logs", - "metrics" - ], + "categories": ["logs", "metrics"], "requirement": { "kibana": { "versions": ">6.7.0" @@ -1703,16 +1578,12 @@ "ingest_pipeline": "pipeline-entry", "vars": [ { - "default": [ - "/var/log/coredns.log" - ], + "default": ["/var/log/coredns.log"], "name": "paths", "type": "textarea" }, { - "default": [ - "coredns" - ], + "default": ["coredns"], "name": "tags", "type": "text" } @@ -1726,9 +1597,7 @@ "type": "metrics", "vars": [ { - "default": [ - "http://localhost:9153" - ], + "default": ["http://localhost:9153"], "description": "CoreDNS hosts", "name": "hosts", "required": true @@ -1889,20 +1758,14 @@ "type": "string" } }, - "required": [ - "id", - "type" - ] + "required": ["id", "type"] } }, "success": { "type": "boolean" } }, - "required": [ - "response", - "success" - ] + "required": ["response", "success"] } } } @@ -1939,20 +1802,14 @@ "type": "string" } }, - "required": [ - "id", - "type" - ] + "required": ["id", "type"] } }, "success": { "type": "boolean" } }, - "required": [ - "response", - "success" - ] + "required": ["response", "success"] } } } @@ -2668,11 +2525,7 @@ "type": "number" } }, - "required": [ - "id", - "title", - "count" - ] + "required": ["id", "title", "count"] } } } @@ -2690,82 +2543,76 @@ "200": { "description": "OK", "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "type": "object" + "application/json": { + "schema": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object" + } + }, + "success": { + "type": "boolean" + }, + "total": { + "type": "number" + }, + "page": { + "type": "number" + }, + "perPage": { + "type": "number" } }, - "success": { - "type": "boolean" - }, - "total": { - "type": "number" - }, - "page": { - "type": "number" - }, - "perPage": { - "type": "number" - } + "required": ["list", "success", "total", "page", "perPage"] }, - "required": [ - "list", - "success", - "total", - "page", - "perPage" - ] - }, - "examples": { - "example-1": { - "value": { - "list": [ - { - "id": "205661d0-5e53-11ea-ad31-4f31c06bd9a4", - "active": true, - "config_id": "ae556400-5e39-11ea-8b49-f9747e466f7b", - "type": "PERMANENT", - "enrolled_at": "2020-03-04T20:02:50.605Z", - "user_provided_metadata": { - "dev_agent_version": "0.0.1", - "region": "us-east" - }, - "local_metadata": { - "host": "localhost", - "ip": "127.0.0.1", - "system": "Darwin 18.7.0", - "memory": 34359738368 - }, - "actions": [ - { - "data": "{\"config\":{\"id\":\"ae556400-5e39-11ea-8b49-f9747e466f7b\",\"outputs\":{\"default\":{\"type\":\"elasticsearch\",\"hosts\":[\"http://localhost:9200\"],\"api_key\":\"\",\"api_token\":\"6ckkp3ABz7e_XRqr3LM8:gQuDfUNSRgmY0iziYqP9Hw\"}},\"datasources\":[]}}", - "created_at": "2020-03-04T20:02:56.149Z", - "id": "6a95c00a-d76d-4931-97c3-0bf935272d7d", - "type": "CONFIG_CHANGE" - } - ], - "access_api_key_id": "6Mkkp3ABz7e_XRqrzLNJ", - "default_api_key": "6ckkp3ABz7e_XRqr3LM8:gQuDfUNSRgmY0iziYqP9Hw", - "current_error_events": [], - "last_checkin": "2020-03-04T20:03:05.700Z", - "status": "online" - } - ], - "success": true, - "total": 1, - "page": 1, - "perPage": 20 + "examples": { + "example-1": { + "value": { + "list": [ + { + "id": "205661d0-5e53-11ea-ad31-4f31c06bd9a4", + "active": true, + "config_id": "ae556400-5e39-11ea-8b49-f9747e466f7b", + "type": "PERMANENT", + "enrolled_at": "2020-03-04T20:02:50.605Z", + "user_provided_metadata": { + "dev_agent_version": "0.0.1", + "region": "us-east" + }, + "local_metadata": { + "host": "localhost", + "ip": "127.0.0.1", + "system": "Darwin 18.7.0", + "memory": 34359738368 + }, + "actions": [ + { + "data": "{\"config\":{\"id\":\"ae556400-5e39-11ea-8b49-f9747e466f7b\",\"outputs\":{\"default\":{\"type\":\"elasticsearch\",\"hosts\":[\"http://localhost:9200\"],\"api_key\":\"\",\"api_token\":\"6ckkp3ABz7e_XRqr3LM8:gQuDfUNSRgmY0iziYqP9Hw\"}},\"datasources\":[]}}", + "created_at": "2020-03-04T20:02:56.149Z", + "id": "6a95c00a-d76d-4931-97c3-0bf935272d7d", + "type": "CONFIG_CHANGE" + } + ], + "access_api_key_id": "6Mkkp3ABz7e_XRqrzLNJ", + "default_api_key": "6ckkp3ABz7e_XRqr3LM8:gQuDfUNSRgmY0iziYqP9Hw", + "current_error_events": [], + "last_checkin": "2020-03-04T20:03:05.700Z", + "status": "online" + } + ], + "success": true, + "total": 1, + "page": 1, + "perPage": 20 + } } } } } } - } }, "operationId": "get-fleet-agents", "security": [ @@ -2804,10 +2651,7 @@ "type": "string" } }, - "required": [ - "item", - "success" - ] + "required": ["item", "success"] } } } @@ -2880,9 +2724,7 @@ "properties": { "action": { "type": "string", - "enum": [ - "checkin" - ] + "enum": ["checkin"] }, "success": { "type": "string" @@ -2909,13 +2751,7 @@ "type": "string" } }, - "required": [ - "agent_id", - "data", - "id", - "created_at", - "type" - ] + "required": ["agent_id", "data", "id", "created_at", "type"] } } } @@ -2935,9 +2771,7 @@ "outputs": { "default": { "type": "elasticsearch", - "hosts": [ - "http://localhost:9200" - ], + "hosts": ["http://localhost:9200"], "api_key": "Z-XkgHIBvwtjzIKtSCTh:AejRqdKpQx6z-6dqSI1LHg" } }, @@ -2957,13 +2791,8 @@ "id": "logs-system.auth", "enabled": true, "dataset": "system.auth", - "paths": [ - "/var/log/auth.log*", - "/var/log/secure*" - ], - "exclude_files": [ - ".gz$" - ], + "paths": ["/var/log/auth.log*", "/var/log/secure*"], + "exclude_files": [".gz$"], "multiline": { "pattern": "^\\s", "match": "after" @@ -2986,13 +2815,8 @@ "id": "logs-system.syslog", "enabled": true, "dataset": "system.syslog", - "paths": [ - "/var/log/messages*", - "/var/log/syslog*" - ], - "exclude_files": [ - ".gz$" - ], + "paths": ["/var/log/messages*", "/var/log/syslog*"], + "exclude_files": [".gz$"], "multiline": { "pattern": "^\\s", "match": "after" @@ -3021,18 +2845,14 @@ "id": "system/metrics-system.core", "enabled": true, "dataset": "system.core", - "metricsets": [ - "core" - ], + "metricsets": ["core"], "core.metrics": "percentages" }, { "id": "system/metrics-system.cpu", "enabled": true, "dataset": "system.cpu", - "metricsets": [ - "cpu" - ], + "metricsets": ["cpu"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -3044,25 +2864,19 @@ "id": "system/metrics-system.diskio", "enabled": true, "dataset": "system.diskio", - "metricsets": [ - "diskio" - ] + "metricsets": ["diskio"] }, { "id": "system/metrics-system.entropy", "enabled": true, "dataset": "system.entropy", - "metricsets": [ - "entropy" - ] + "metricsets": ["entropy"] }, { "id": "system/metrics-system.filesystem", "enabled": true, "dataset": "system.filesystem", - "metricsets": [ - "filesystem" - ], + "metricsets": ["filesystem"], "period": "1m", "processors": [ { @@ -3076,9 +2890,7 @@ "id": "system/metrics-system.fsstat", "enabled": true, "dataset": "system.fsstat", - "metricsets": [ - "fsstat" - ], + "metricsets": ["fsstat"], "period": "1m", "processors": [ { @@ -3092,9 +2904,7 @@ "id": "system/metrics-system.load", "enabled": true, "dataset": "system.load", - "metricsets": [ - "load" - ], + "metricsets": ["load"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -3106,9 +2916,7 @@ "id": "system/metrics-system.memory", "enabled": true, "dataset": "system.memory", - "metricsets": [ - "memory" - ], + "metricsets": ["memory"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -3120,9 +2928,7 @@ "id": "system/metrics-system.network", "enabled": true, "dataset": "system.network", - "metricsets": [ - "network" - ], + "metricsets": ["network"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -3134,17 +2940,13 @@ "id": "system/metrics-system.network_summary", "enabled": true, "dataset": "system.network_summary", - "metricsets": [ - "network_summary" - ] + "metricsets": ["network_summary"] }, { "id": "system/metrics-system.process", "enabled": true, "dataset": "system.process", - "metricsets": [ - "process" - ], + "metricsets": ["process"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -3156,9 +2958,7 @@ "id": "system/metrics-system.process_summary", "enabled": true, "dataset": "system.process_summary", - "metricsets": [ - "process_summary" - ], + "metricsets": ["process_summary"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -3170,33 +2970,25 @@ "id": "system/metrics-system.raid", "enabled": true, "dataset": "system.raid", - "metricsets": [ - "raid" - ] + "metricsets": ["raid"] }, { "id": "system/metrics-system.service", "enabled": true, "dataset": "system.service", - "metricsets": [ - "service" - ] + "metricsets": ["service"] }, { "id": "system/metrics-system.socket", "enabled": true, "dataset": "system.socket", - "metricsets": [ - "socket" - ] + "metricsets": ["socket"] }, { "id": "system/metrics-system.socket_summary", "enabled": true, "dataset": "system.socket_summary", - "metricsets": [ - "socket_summary" - ], + "metricsets": ["socket_summary"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -3208,9 +3000,7 @@ "id": "system/metrics-system.uptime", "enabled": true, "dataset": "system.uptime", - "metricsets": [ - "uptime" - ], + "metricsets": ["uptime"], "core.metrics": "percentages", "cpu.metrics": "percentages,normalized_percentages", "period": "10s", @@ -3220,9 +3010,7 @@ "id": "system/metrics-system.users", "enabled": true, "dataset": "system.users", - "metricsets": [ - "users" - ] + "metricsets": ["users"] } ] } @@ -3373,15 +3161,10 @@ }, "action": { "type": "string", - "enum": [ - "acks" - ] + "enum": ["acks"] } }, - "required": [ - "success", - "action" - ] + "required": ["success", "action"] }, "examples": { "success": { @@ -3498,21 +3281,14 @@ "properties": { "type": { "type": "string", - "enum": [ - "PERMANENT", - "EPHEMERAL", - "TEMPORARY" - ] + "enum": ["PERMANENT", "EPHEMERAL", "TEMPORARY"] }, "shared_id": { "type": "string" }, "metadata": { "type": "object", - "required": [ - "local", - "user_provided" - ], + "required": ["local", "user_provided"], "properties": { "local": { "$ref": "#/components/schemas/AgentMetadata" @@ -3523,10 +3299,7 @@ } } }, - "required": [ - "type", - "metadata" - ] + "required": ["type", "metadata"] }, "examples": { "good": { @@ -3723,10 +3496,7 @@ }, "status": { "type": "string", - "enum": [ - "active", - "inactive" - ] + "enum": ["active", "inactive"] }, "datasources": { "oneOf": [ @@ -3757,10 +3527,7 @@ "type": "number" } }, - "required": [ - "id", - "status" - ] + "required": ["id", "status"] } ] }, @@ -3781,10 +3548,7 @@ "items": {} } }, - "required": [ - "id", - "revision" - ] + "required": ["id", "revision"] }, { "$ref": "#/components/schemas/NewDatasource" @@ -3829,16 +3593,12 @@ { "enabled": true, "dataset": "nginx.acccess", - "paths": [ - "/var/log/nginx/access.log" - ] + "paths": ["/var/log/nginx/access.log"] }, { "enabled": true, "dataset": "nginx.error", - "paths": [ - "/var/log/nginx/error.log" - ] + "paths": ["/var/log/nginx/error.log"] } ] }, @@ -3874,11 +3634,7 @@ "type": "string" } }, - "required": [ - "name", - "version", - "title" - ] + "required": ["name", "version", "title"] }, "namespace": { "type": "string" @@ -3914,11 +3670,7 @@ "type": "object" } }, - "required": [ - "type", - "enabled", - "streams" - ] + "required": ["type", "enabled", "streams"] } }, "config_id": { @@ -3931,12 +3683,7 @@ "type": "string" } }, - "required": [ - "output_id", - "inputs", - "config_id", - "name" - ] + "required": ["output_id", "inputs", "config_id", "name"] }, "PackageInfo": { "title": "PackageInfo", @@ -4007,9 +3754,7 @@ "type": "string" } }, - "required": [ - "src" - ] + "required": ["src"] } }, "icons": { @@ -4059,10 +3804,7 @@ "type": "string" } }, - "required": [ - "name", - "default" - ] + "required": ["name", "default"] } }, "type": { @@ -4072,14 +3814,7 @@ "type": "string" } }, - "required": [ - "title", - "name", - "release", - "ingeset_pipeline", - "type", - "package" - ] + "required": ["title", "name", "release", "ingeset_pipeline", "type", "package"] } }, "download": { @@ -4153,13 +3888,7 @@ "AgentStatus": { "type": "string", "title": "AgentStatus", - "enum": [ - "offline", - "error", - "online", - "inactive", - "warning" - ] + "enum": ["offline", "error", "online", "inactive", "warning"] }, "Agent": { "title": "Agent", @@ -4187,10 +3916,7 @@ "type": "string" }, "config_revision": { - "type": [ - "number", - "null" - ] + "type": ["number", "null"] }, "config_newest_revision": { "type": "number" @@ -4223,23 +3949,12 @@ "type": "string" } }, - "required": [ - "type", - "active", - "enrolled_at", - "id", - "current_error_events", - "status" - ] + "required": ["type", "active", "enrolled_at", "id", "current_error_events", "status"] }, "AgentType": { "type": "string", "title": "AgentType", - "enum": [ - "PERMANENT", - "EPHEMERAL", - "TEMPORARY" - ] + "enum": ["PERMANENT", "EPHEMERAL", "TEMPORARY"] }, "AgentMetadata": { "title": "AgentMetadata", @@ -4251,12 +3966,7 @@ "properties": { "type": { "type": "string", - "enum": [ - "STATE", - "ERROR", - "ACTION_RESULT", - "ACTION" - ] + "enum": ["STATE", "ERROR", "ACTION_RESULT", "ACTION"] }, "subtype": { "type": "string", @@ -4295,13 +4005,7 @@ "type": "string" } }, - "required": [ - "type", - "subtype", - "timestamp", - "message", - "agent_id" - ] + "required": ["type", "subtype", "timestamp", "message", "agent_id"] }, "AgentEvent": { "title": "AgentEvent", @@ -4313,9 +4017,7 @@ "type": "string" } }, - "required": [ - "id" - ] + "required": ["id"] }, { "$ref": "#/components/schemas/NewAgentEvent" diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts index 3fc990ea9d70ca..8136abe1a42d47 100644 --- a/x-pack/plugins/ingest_manager/common/services/routes.ts +++ b/x-pack/plugins/ingest_manager/common/services/routes.ts @@ -83,6 +83,10 @@ export const agentConfigRouteService = { return AGENT_CONFIG_API_ROUTES.UPDATE_PATTERN.replace('{agentConfigId}', agentConfigId); }, + getCopyPath: (agentConfigId: string) => { + return AGENT_CONFIG_API_ROUTES.COPY_PATTERN.replace('{agentConfigId}', agentConfigId); + }, + getDeletePath: () => { return AGENT_CONFIG_API_ROUTES.DELETE_PATTERN; }, diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts index 82d7fa51b20826..86020cb5235ae6 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts @@ -49,6 +49,15 @@ export interface UpdateAgentConfigResponse { success: boolean; } +export interface CopyAgentConfigRequest { + body: Pick; +} + +export interface CopyAgentConfigResponse { + item: AgentConfig; + success: boolean; +} + export interface DeleteAgentConfigRequest { body: { agentConfigId: string; diff --git a/x-pack/plugins/ingest_manager/kibana.json b/x-pack/plugins/ingest_manager/kibana.json index 382ea0444093d4..35447139607a6b 100644 --- a/x-pack/plugins/ingest_manager/kibana.json +++ b/x-pack/plugins/ingest_manager/kibana.json @@ -5,5 +5,6 @@ "ui": true, "configPath": ["xpack", "ingestManager"], "requiredPlugins": ["licensing", "data", "encryptedSavedObjects"], - "optionalPlugins": ["security", "features", "cloud"] + "optionalPlugins": ["security", "features", "cloud"], + "extraPublicDirs": ["common"] } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts index 45ca6047b0d96d..c81303de3d7c3e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts @@ -19,6 +19,8 @@ import { CreateAgentConfigResponse, UpdateAgentConfigRequest, UpdateAgentConfigResponse, + CopyAgentConfigRequest, + CopyAgentConfigResponse, DeleteAgentConfigRequest, DeleteAgentConfigResponse, } from '../../types'; @@ -76,6 +78,17 @@ export const sendUpdateAgentConfig = ( }); }; +export const sendCopyAgentConfig = ( + agentConfigId: string, + body: CopyAgentConfigRequest['body'] +) => { + return sendRequest({ + path: agentConfigRouteService.getCopyPath(agentConfigId), + method: 'post', + body: JSON.stringify(body), + }); +}; + export const sendDeleteAgentConfig = (body: DeleteAgentConfigRequest['body']) => { return sendRequest({ path: agentConfigRouteService.getDeletePath(), diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/actions_menu.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/actions_menu.tsx index dc61da685c88d6..39fe090e5008cf 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/actions_menu.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/actions_menu.tsx @@ -6,64 +6,97 @@ import React, { memo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiContextMenuItem, EuiPortal } from '@elastic/eui'; -import { useCapabilities, useLink } from '../../../hooks'; +import { AgentConfig } from '../../../types'; +import { useCapabilities } from '../../../hooks'; import { ContextMenuActions } from '../../../components'; +import { AgentEnrollmentFlyout } from '../../fleet/components'; import { ConfigYamlFlyout } from './config_yaml_flyout'; +import { AgentConfigCopyProvider } from './config_copy_provider'; -export const AgentConfigActionMenu = memo<{ configId: string; fullButton?: boolean }>( - ({ configId, fullButton = false }) => { - const { getHref } = useLink(); - const hasWriteCapabilities = useCapabilities().write; - const [isYamlFlyoutOpen, setIsYamlFlyoutOpen] = useState(false); - return ( - <> - {isYamlFlyoutOpen ? ( - - setIsYamlFlyoutOpen(false)} /> - - ) : null} - - ), - } - : undefined - } - items={[ - setIsYamlFlyoutOpen(!isYamlFlyoutOpen)} - key="viewConfig" - > - - , - - - , - ]} - /> - - ); - } -); +export const AgentConfigActionMenu = memo<{ + config: AgentConfig; + onCopySuccess?: (newAgentConfig: AgentConfig) => void; + fullButton?: boolean; +}>(({ config, onCopySuccess, fullButton = false }) => { + const hasWriteCapabilities = useCapabilities().write; + const [isYamlFlyoutOpen, setIsYamlFlyoutOpen] = useState(false); + const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); + + return ( + + {(copyAgentConfigPrompt) => { + return ( + <> + {isYamlFlyoutOpen ? ( + + setIsYamlFlyoutOpen(false)} /> + + ) : null} + {isEnrollmentFlyoutOpen && ( + + setIsEnrollmentFlyoutOpen(false)} + /> + + )} + + ), + } + : undefined + } + items={[ + setIsEnrollmentFlyoutOpen(true)} + key="enrollAgents" + > + + , + setIsYamlFlyoutOpen(!isYamlFlyoutOpen)} + key="viewConfig" + > + + , + { + copyAgentConfigPrompt(config, onCopySuccess); + }} + key="copyConfig" + > + + , + ]} + /> + + ); + }} + + ); +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_copy_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_copy_provider.tsx new file mode 100644 index 00000000000000..9776304797fd45 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_copy_provider.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useRef, useState } from 'react'; +import { EuiConfirmModal, EuiOverlayMask, EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AgentConfig } from '../../../types'; +import { sendCopyAgentConfig, useCore } from '../../../hooks'; + +interface Props { + children: (copyAgentConfig: CopyAgentConfig) => React.ReactElement; +} + +export type CopyAgentConfig = (agentConfig: AgentConfig, onSuccess?: OnSuccessCallback) => void; + +type OnSuccessCallback = (newAgentConfig: AgentConfig) => void; + +export const AgentConfigCopyProvider: React.FunctionComponent = ({ children }) => { + const { notifications } = useCore(); + const [agentConfig, setAgentConfig] = useState(); + const [newAgentConfig, setNewAgentConfig] = useState>(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const onSuccessCallback = useRef(null); + + const copyAgentConfigPrompt: CopyAgentConfig = ( + agentConfigToCopy, + onSuccess = () => undefined + ) => { + if (!agentConfigToCopy) { + throw new Error('No agent config specified to copy'); + } + setIsModalOpen(true); + setAgentConfig(agentConfigToCopy); + setNewAgentConfig({ + name: i18n.translate( + 'xpack.ingestManager.copyAgentConfig.confirmModal.defaultNewConfigName', + { + defaultMessage: '{name} (copy)', + values: { name: agentConfigToCopy.name }, + } + ), + description: agentConfigToCopy.description, + }); + onSuccessCallback.current = onSuccess; + }; + + const closeModal = () => { + setAgentConfig(undefined); + setNewAgentConfig(undefined); + setIsLoading(false); + setIsModalOpen(false); + }; + + const copyAgentConfig = async () => { + setIsLoading(true); + try { + const { data } = await sendCopyAgentConfig(agentConfig!.id, newAgentConfig!); + + if (data?.success) { + notifications.toasts.addSuccess( + i18n.translate('xpack.ingestManager.copyAgentConfig.successNotificationTitle', { + defaultMessage: 'Agent config copied', + }) + ); + if (onSuccessCallback.current) { + onSuccessCallback.current(data.item); + } + } + + if (!data?.success) { + notifications.toasts.addDanger( + i18n.translate('xpack.ingestManager.copyAgentConfig.failureNotificationTitle', { + defaultMessage: "Error copying agent config '{id}'", + values: { id: agentConfig!.id }, + }) + ); + } + } catch (e) { + notifications.toasts.addDanger( + i18n.translate('xpack.ingestManager.copyAgentConfig.fatalErrorNotificationTitle', { + defaultMessage: 'Error copying agent config', + }) + ); + } + closeModal(); + }; + + const renderModal = () => { + if (!isModalOpen || !agentConfig || !newAgentConfig) { + return null; + } + + return ( + + + } + onCancel={closeModal} + onConfirm={copyAgentConfig} + cancelButtonText={ + + } + confirmButtonText={ + + } + confirmButtonDisabled={isLoading || !newAgentConfig.name.trim()} + > +

+ +

+ + } + fullWidth + > + setNewAgentConfig({ ...newAgentConfig, name: e.target.value })} + /> + + + } + fullWidth + > + + setNewAgentConfig({ ...newAgentConfig, description: e.target.value }) + } + /> + +
+
+ ); + }; + + return ( + + {children(copyAgentConfigPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx index 6fab78951038fa..410c0fcb2d140a 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { useMemo, useState } from 'react'; -import { Redirect, useRouteMatch, Switch, Route } from 'react-router-dom'; +import { Redirect, useRouteMatch, Switch, Route, useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; import { @@ -40,7 +40,8 @@ export const AgentConfigDetailsPage: React.FunctionComponent = () => { const { params: { configId, tabId = '' }, } = useRouteMatch<{ configId: string; tabId?: string }>(); - const { getHref } = useLink(); + const history = useHistory(); + const { getHref, getPath } = useLink(); const agentConfigRequest = useGetOneAgentConfig(configId); const agentConfig = agentConfigRequest.data ? agentConfigRequest.data.item : null; const { isLoading, error, sendRequest: refreshAgentConfig } = agentConfigRequest; @@ -147,7 +148,15 @@ export const AgentConfigDetailsPage: React.FunctionComponent = () => { }, { isDivider: true }, { - content: agentConfig && , + content: agentConfig && ( + { + history.push(getPath('configuration_details', { configId: newAgentConfig.id })); + }} + /> + ), }, ].map((item, index) => ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx index 0d43d8856c2fba..8b1ff0988d4431 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx @@ -189,7 +189,9 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { }), actions: [ { - render: (config: AgentConfig) => , + render: (config: AgentConfig) => ( + sendRequest()} /> + ), }, ], }, @@ -201,7 +203,7 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { } return cols; - }, [getHref, isFleetEnabled]); + }, [getHref, isFleetEnabled, sendRequest]); const createAgentConfigButton = useMemo( () => ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/list_layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/list_layout.tsx index cc12ea19fbecfe..60cbc31081302f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/list_layout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/list_layout.tsx @@ -112,7 +112,7 @@ export const ListLayout: React.FunctionComponent<{}> = ({ children }) => { setIsEnrollmentFlyoutOpen(true)}> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts index 05a97fd2e2a3c1..412bf412d1ef5a 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts @@ -31,6 +31,8 @@ export { CreateAgentConfigResponse, UpdateAgentConfigRequest, UpdateAgentConfigResponse, + CopyAgentConfigRequest, + CopyAgentConfigResponse, DeleteAgentConfigRequest, DeleteAgentConfigResponse, // API schemas - Datasource diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts index afc146cf90447d..d01b361bd6ca44 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts @@ -15,6 +15,7 @@ import { GetOneAgentConfigRequestSchema, CreateAgentConfigRequestSchema, UpdateAgentConfigRequestSchema, + CopyAgentConfigRequestSchema, DeleteAgentConfigRequestSchema, GetFullAgentConfigRequestSchema, AgentConfig, @@ -27,6 +28,7 @@ import { GetOneAgentConfigResponse, CreateAgentConfigResponse, UpdateAgentConfigResponse, + CopyAgentConfigResponse, DeleteAgentConfigResponse, GetFullAgentConfigResponse, } from '../../../common'; @@ -177,6 +179,34 @@ export const updateAgentConfigHandler: RequestHandler< } }; +export const copyAgentConfigHandler: RequestHandler< + TypeOf, + unknown, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + const user = await appContextService.getSecurity()?.authc.getCurrentUser(request); + try { + const agentConfig = await agentConfigService.copy( + soClient, + request.params.agentConfigId, + request.body, + { + user: user || undefined, + } + ); + const body: CopyAgentConfigResponse = { item: agentConfig, success: true }; + return response.ok({ + body, + }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + export const deleteAgentConfigsHandler: RequestHandler< unknown, unknown, diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts index 4f6cfb436b93b4..95c7c13377366f 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts @@ -10,6 +10,7 @@ import { GetOneAgentConfigRequestSchema, CreateAgentConfigRequestSchema, UpdateAgentConfigRequestSchema, + CopyAgentConfigRequestSchema, DeleteAgentConfigRequestSchema, GetFullAgentConfigRequestSchema, } from '../../types'; @@ -18,6 +19,7 @@ import { getOneAgentConfigHandler, createAgentConfigHandler, updateAgentConfigHandler, + copyAgentConfigHandler, deleteAgentConfigsHandler, getFullAgentConfig, downloadFullAgentConfig, @@ -64,6 +66,16 @@ export const registerRoutes = (router: IRouter) => { updateAgentConfigHandler ); + // Copy + router.post( + { + path: AGENT_CONFIG_API_ROUTES.COPY_PATTERN, + validate: CopyAgentConfigRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + copyAgentConfigHandler + ); + // Delete router.post( { diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts index 678262ab6dcac3..4a877ef7de13b9 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -179,6 +179,48 @@ class AgentConfigService { return this._update(soClient, id, agentConfig, options?.user); } + public async copy( + soClient: SavedObjectsClientContract, + id: string, + newAgentConfigProps: Pick, + options?: { user?: AuthenticatedUser } + ): Promise { + // Copy base config + const baseAgentConfig = await this.get(soClient, id, true); + if (!baseAgentConfig) { + throw new Error('Agent config not found'); + } + const { namespace, monitoring_enabled } = baseAgentConfig; + const newAgentConfig = await this.create( + soClient, + { + namespace, + monitoring_enabled, + ...newAgentConfigProps, + }, + options + ); + + // Copy all datasources + if (baseAgentConfig.datasources.length) { + const newDatasources = (baseAgentConfig.datasources as Datasource[]).map( + (datasource: Datasource) => { + const { id: datasourceId, ...newDatasource } = datasource; + return newDatasource; + } + ); + await datasourceService.bulkCreate(soClient, newDatasources, newAgentConfig.id, options); + } + + // Get updated config + const updatedAgentConfig = await this.get(soClient, newAgentConfig.id, true); + if (!updatedAgentConfig) { + throw new Error('Copied agent config not found'); + } + + return updatedAgentConfig; + } + public async bumpRevision( soClient: SavedObjectsClientContract, id: string, @@ -203,7 +245,6 @@ class AgentConfigService { soClient, id, { - ...oldAgentConfig, datasources: uniq( [...((oldAgentConfig.datasources || []) as string[])].concat(datasourceIds) ), @@ -242,7 +283,8 @@ class AgentConfigService { public async getDefaultAgentConfigId(soClient: SavedObjectsClientContract) { const configs = await soClient.find({ type: AGENT_CONFIG_SAVED_OBJECT_TYPE, - filter: `${AGENT_CONFIG_SAVED_OBJECT_TYPE}.attributes.is_default:true`, + searchFields: ['is_default'], + search: 'true', }); if (configs.saved_objects.length === 0) { diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.ts b/x-pack/plugins/ingest_manager/server/services/datasource.ts index c559dac0c0dcd0..3ad94ea8191d4d 100644 --- a/x-pack/plugins/ingest_manager/server/services/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/services/datasource.ts @@ -56,6 +56,44 @@ class DatasourceService { }; } + public async bulkCreate( + soClient: SavedObjectsClientContract, + datasources: NewDatasource[], + configId: string, + options?: { user?: AuthenticatedUser } + ): Promise { + const isoDate = new Date().toISOString(); + const { saved_objects: newSos } = await soClient.bulkCreate>( + datasources.map((datasource) => ({ + type: SAVED_OBJECT_TYPE, + attributes: { + ...datasource, + config_id: configId, + revision: 1, + created_at: isoDate, + created_by: options?.user?.username ?? 'system', + updated_at: isoDate, + updated_by: options?.user?.username ?? 'system', + }, + })) + ); + + // Assign it to the given agent config + await agentConfigService.assignDatasources( + soClient, + configId, + newSos.map((newSo) => newSo.id), + { + user: options?.user, + } + ); + + return newSos.map((newSo) => ({ + id: newSo.id, + ...newSo.attributes, + })); + } + public async get(soClient: SavedObjectsClientContract, id: string): Promise { const datasourceSO = await soClient.get(SAVED_OBJECT_TYPE, id); if (!datasourceSO) { diff --git a/x-pack/plugins/ingest_manager/server/services/output.ts b/x-pack/plugins/ingest_manager/server/services/output.ts index b70578efe468c0..b4af2310243701 100644 --- a/x-pack/plugins/ingest_manager/server/services/output.ts +++ b/x-pack/plugins/ingest_manager/server/services/output.ts @@ -14,11 +14,16 @@ const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE; let cachedAdminUser: null | { username: string; password: string } = null; class OutputService { - public async ensureDefaultOutput(soClient: SavedObjectsClientContract) { - const outputs = await soClient.find({ + public async getDefaultOutput(soClient: SavedObjectsClientContract) { + return await soClient.find({ type: OUTPUT_SAVED_OBJECT_TYPE, - filter: `${OUTPUT_SAVED_OBJECT_TYPE}.attributes.is_default:true`, + searchFields: ['is_default'], + search: 'true', }); + } + + public async ensureDefaultOutput(soClient: SavedObjectsClientContract) { + const outputs = await this.getDefaultOutput(soClient); const cloud = appContextService.getCloud(); const cloudId = cloud?.isCloudEnabled && cloud.cloudId; const cloudUrl = cloudId && decodeCloudId(cloudId)?.elasticsearchUrl; @@ -50,10 +55,7 @@ class OutputService { } public async getDefaultOutputId(soClient: SavedObjectsClientContract) { - const outputs = await soClient.find({ - type: OUTPUT_SAVED_OBJECT_TYPE, - filter: `${OUTPUT_SAVED_OBJECT_TYPE}.attributes.is_default:true`, - }); + const outputs = await this.getDefaultOutput(soClient); if (!outputs.saved_objects.length) { return null; diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts index 2b0bce99c5bdaf..ee91813a48e2f9 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts @@ -8,7 +8,7 @@ import { DatasourceSchema } from './datasource'; import { AgentConfigStatus } from '../../../common'; const AgentConfigBaseSchema = { - name: schema.string(), + name: schema.string({ minLength: 1 }), namespace: schema.string({ minLength: 1 }), description: schema.maybe(schema.string()), monitoring_enabled: schema.maybe( diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts index 123a413bb84427..306aefb0d51ff5 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts @@ -29,6 +29,14 @@ export const UpdateAgentConfigRequestSchema = { body: NewAgentConfigSchema, }; +export const CopyAgentConfigRequestSchema = { + ...GetOneAgentConfigRequestSchema, + body: schema.object({ + name: schema.string({ minLength: 1 }), + description: schema.maybe(schema.string()), + }), +}; + export const DeleteAgentConfigRequestSchema = { body: schema.object({ agentConfigId: schema.string(), diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index a8b22b3e22750f..346a5a24c269f4 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -13,5 +13,6 @@ "dashboard" ], "optionalPlugins": ["embeddable", "usageCollection", "taskManager", "uiActions"], - "configPath": ["xpack", "lens"] + "configPath": ["xpack", "lens"], + "extraPublicDirs": ["common/constants"] } diff --git a/x-pack/plugins/license_management/kibana.json b/x-pack/plugins/license_management/kibana.json index be28c8e978d8aa..6da923c5cff5a3 100644 --- a/x-pack/plugins/license_management/kibana.json +++ b/x-pack/plugins/license_management/kibana.json @@ -5,5 +5,6 @@ "ui": true, "requiredPlugins": ["home", "licensing", "management"], "optionalPlugins": ["telemetry"], - "configPath": ["xpack", "license_management"] + "configPath": ["xpack", "license_management"], + "extraPublicDirs": ["common/constants"] } diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index 67520321de7612..f8a30b8d0337e4 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -18,5 +18,6 @@ "usageCollection" ], "ui": true, - "server": true + "server": true, + "extraPublicDirs": ["common/constants"] } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js index b463322ea55db5..3508d69ee2212c 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js @@ -48,6 +48,8 @@ export class EditJobFlyoutUI extends Component { jobDescription: '', jobGroups: [], jobModelMemoryLimit: '', + jobModelSnapshotRetentionDays: 10, + jobDailyModelSnapshotRetentionAfterDays: 10, jobDetectors: [], jobDetectorDescriptions: [], jobCustomUrls: [], @@ -96,6 +98,8 @@ export class EditJobFlyoutUI extends Component { 'jobDescription', 'jobGroups', 'jobModelMemoryLimit', + 'jobModelSnapshotRetentionDays', + 'jobDailyModelSnapshotRetentionAfterDays', 'jobCustomUrls', 'jobDetectors', 'jobDetectorDescriptions', @@ -128,6 +132,15 @@ export class EditJobFlyoutUI extends Component { job.analysis_limits && job.analysis_limits.model_memory_limit ? job.analysis_limits.model_memory_limit : ''; + + const modelSnapshotRetentionDays = + job.model_snapshot_retention_days !== undefined ? job.model_snapshot_retention_days : 10; + + const dailyModelSnapshotRetentionAfterDays = + job.daily_model_snapshot_retention_after_days !== undefined + ? job.daily_model_snapshot_retention_after_days + : modelSnapshotRetentionDays; + const detectors = job.analysis_config && job.analysis_config.detectors ? [...job.analysis_config.detectors] @@ -146,6 +159,8 @@ export class EditJobFlyoutUI extends Component { jobDescription: job.description, jobGroups: job.groups !== undefined ? job.groups : [], jobModelMemoryLimit: mml, + jobModelSnapshotRetentionDays: modelSnapshotRetentionDays, + jobDailyModelSnapshotRetentionAfterDays: dailyModelSnapshotRetentionAfterDays, jobDetectors: detectors, jobDetectorDescriptions: detectors.map((d) => d.detector_description), jobBucketSpan: bucketSpan, @@ -229,6 +244,8 @@ export class EditJobFlyoutUI extends Component { description: this.state.jobDescription, groups: this.state.jobGroups, mml: this.state.jobModelMemoryLimit, + modelSnapshotRetentionDays: this.state.jobModelSnapshotRetentionDays, + dailyModelSnapshotRetentionAfterDays: this.state.jobDailyModelSnapshotRetentionAfterDays, detectorDescriptions: this.state.jobDetectorDescriptions, datafeedQuery: collapseLiteralStrings(this.state.datafeedQuery), datafeedQueryDelay: this.state.datafeedQueryDelay, @@ -275,6 +292,8 @@ export class EditJobFlyoutUI extends Component { jobDescription, jobGroups, jobModelMemoryLimit, + jobModelSnapshotRetentionDays, + jobDailyModelSnapshotRetentionAfterDays, jobDetectors, jobDetectorDescriptions, jobBucketSpan, @@ -302,6 +321,8 @@ export class EditJobFlyoutUI extends Component { jobDescription={jobDescription} jobGroups={jobGroups} jobModelMemoryLimit={jobModelMemoryLimit} + jobModelSnapshotRetentionDays={jobModelSnapshotRetentionDays} + jobDailyModelSnapshotRetentionAfterDays={jobDailyModelSnapshotRetentionAfterDays} setJobDetails={this.setJobDetails} jobGroupsValidationError={jobGroupsValidationError} jobModelMemoryLimitValidationError={jobModelMemoryLimitValidationError} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_utils.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_utils.js index fcd2c09f727675..5030c48a4e367b 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_utils.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_utils.js @@ -16,6 +16,8 @@ export function saveJob(job, newJobData, finish) { ...extractDescription(job, newJobData), ...extractGroups(job, newJobData), ...extractMML(job, newJobData), + ...extractModelSnapshotRetentionDays(job, newJobData), + ...extractDailyModelSnapshotRetentionAfterDays(job, newJobData), ...extractDetectorDescriptions(job, newJobData), ...extractCustomSettings(job, newJobData), }; @@ -175,6 +177,22 @@ function extractMML(job, newJobData) { return mmlData; } +function extractModelSnapshotRetentionDays(job, newJobData) { + const modelSnapshotRetentionDays = newJobData.modelSnapshotRetentionDays; + if (modelSnapshotRetentionDays !== job.model_snapshot_retention_days) { + return { model_snapshot_retention_days: modelSnapshotRetentionDays }; + } + return {}; +} + +function extractDailyModelSnapshotRetentionAfterDays(job, newJobData) { + const dailyModelSnapshotRetentionAfterDays = newJobData.dailyModelSnapshotRetentionAfterDays; + if (dailyModelSnapshotRetentionAfterDays !== job.daily_model_snapshot_retention_after_days) { + return { daily_model_snapshot_retention_after_days: dailyModelSnapshotRetentionAfterDays }; + } + return {}; +} + function extractDetectorDescriptions(job, newJobData) { const detectors = []; const descriptions = newJobData.detectorDescriptions.map((d, i) => ({ diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js index 974afafc08b6b0..ec5ef6fce26b5c 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/job_details.js @@ -7,7 +7,14 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer, EuiComboBox } from '@elastic/eui'; +import { + EuiFieldText, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiComboBox, + EuiFieldNumber, +} from '@elastic/eui'; import { ml } from '../../../../../services/ml_api_service'; import { i18n } from '@kbn/i18n'; @@ -24,6 +31,8 @@ export class JobDetails extends Component { mml: '', mmlValidationError: '', groupsValidationError: '', + modelSnapshotRetentionDays: 1, + dailyModelSnapshotRetentionAfterDays: 1, }; this.setJobDetails = props.setJobDetails; @@ -52,6 +61,8 @@ export class JobDetails extends Component { mml: props.jobModelMemoryLimit, mmlValidationError: props.jobModelMemoryLimitValidationError, groupsValidationError: props.jobGroupsValidationError, + modelSnapshotRetentionDays: props.jobModelSnapshotRetentionDays, + dailyModelSnapshotRetentionAfterDays: props.jobDailyModelSnapshotRetentionAfterDays, }; } @@ -63,6 +74,24 @@ export class JobDetails extends Component { this.setJobDetails({ jobModelMemoryLimit: e.target.value }); }; + onModelSnapshotRetentionDaysChange = (e) => { + const jobModelSnapshotRetentionDays = Math.floor(+e.target.value); + + this.setJobDetails({ + jobModelSnapshotRetentionDays, + ...(this.state.dailyModelSnapshotRetentionAfterDays > jobModelSnapshotRetentionDays + ? { jobDailyModelSnapshotRetentionAfterDays: jobModelSnapshotRetentionDays } + : {}), + }); + }; + + onDailyModelSnapshotRetentionAfterDaysChange = (e) => { + const jobDailyModelSnapshotRetentionAfterDays = Math.floor(+e.target.value); + if (jobDailyModelSnapshotRetentionAfterDays <= this.state.modelSnapshotRetentionDays) { + this.setJobDetails({ jobDailyModelSnapshotRetentionAfterDays }); + } + }; + onGroupsChange = (selectedGroups) => { this.setJobDetails({ jobGroups: selectedGroups.map((g) => g.label) }); }; @@ -104,6 +133,8 @@ export class JobDetails extends Component { groups, mmlValidationError, groupsValidationError, + modelSnapshotRetentionDays, + dailyModelSnapshotRetentionAfterDays, } = this.state; const { datafeedRunning } = this.props; return ( @@ -172,6 +203,35 @@ export class JobDetails extends Component { disabled={datafeedRunning} /> + + } + > + + + + } + > + + ); diff --git a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts index de393e002c55bb..3a01a616a4f7c2 100644 --- a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts @@ -69,6 +69,7 @@ export const anomalyDetectionUpdateJobSchema = schema.object({ }) ), groups: schema.maybe(schema.arrayOf(schema.maybe(schema.string()))), + model_snapshot_retention_days: schema.maybe(schema.number()), }); export const analysisConfigSchema = schema.object({ diff --git a/x-pack/plugins/reporting/server/core.ts b/x-pack/plugins/reporting/server/core.ts index 94b138ffcae0b2..9acd359fa0db4e 100644 --- a/x-pack/plugins/reporting/server/core.ts +++ b/x-pack/plugins/reporting/server/core.ts @@ -5,7 +5,7 @@ */ import * as Rx from 'rxjs'; -import { first, map, mapTo } from 'rxjs/operators'; +import { first, map, take } from 'rxjs/operators'; import { BasePath, ElasticsearchServiceSetup, @@ -33,7 +33,8 @@ export interface ReportingInternalSetup { security?: SecurityPluginSetup; } -interface ReportingInternalStart { +export interface ReportingInternalStart { + browserDriverFactory: HeadlessChromiumDriverFactory; enqueueJob: EnqueueJobFn; esqueue: ESQueueInstance; savedObjects: SavedObjectsServiceStart; @@ -43,33 +44,83 @@ interface ReportingInternalStart { export class ReportingCore { private pluginSetupDeps?: ReportingInternalSetup; private pluginStartDeps?: ReportingInternalStart; - private browserDriverFactory?: HeadlessChromiumDriverFactory; - private readonly pluginSetup$ = new Rx.ReplaySubject(); - private readonly pluginStart$ = new Rx.ReplaySubject(); + private readonly pluginSetup$ = new Rx.ReplaySubject(); // observe async background setupDeps and config each are done + private readonly pluginStart$ = new Rx.ReplaySubject(); // observe async background startDeps private exportTypesRegistry = getExportTypesRegistry(); + private config?: ReportingConfig; - constructor(private config: ReportingConfig) {} + constructor() {} - public pluginSetup(reportingSetupDeps: ReportingInternalSetup) { - this.pluginSetupDeps = reportingSetupDeps; - this.pluginSetup$.next(reportingSetupDeps); + /* + * Register setupDeps + */ + public pluginSetup(setupDeps: ReportingInternalSetup) { + this.pluginSetup$.next(true); // trigger the observer + this.pluginSetupDeps = setupDeps; // cache } - public pluginStart(reportingStartDeps: ReportingInternalStart) { - this.pluginStart$.next(reportingStartDeps); + /* + * Register startDeps + */ + public pluginStart(startDeps: ReportingInternalStart) { + this.pluginStart$.next(startDeps); // trigger the observer + this.pluginStartDeps = startDeps; // cache } - public pluginHasStarted(): Promise { - return this.pluginStart$.pipe(first(), mapTo(true)).toPromise(); + /* + * Blocks the caller until setup is done + */ + public async pluginSetsUp(): Promise { + // use deps and config as a cached resolver + if (this.pluginSetupDeps && this.config) { + return true; + } + return await this.pluginSetup$.pipe(take(2)).toPromise(); // once for pluginSetupDeps (sync) and twice for config (async) } - public setBrowserDriverFactory(browserDriverFactory: HeadlessChromiumDriverFactory) { - this.browserDriverFactory = browserDriverFactory; + /* + * Blocks the caller until start is done + */ + public async pluginStartsUp(): Promise { + return await this.getPluginStartDeps().then(() => true); + } + + /* + * Synchronously checks if all async background setup and startup is completed + */ + public pluginIsStarted() { + return this.pluginSetupDeps != null && this.config != null && this.pluginStartDeps != null; } /* - * Internal module dependencies + * Allows config to be set in the background */ + public setConfig(config: ReportingConfig) { + this.config = config; + this.pluginSetup$.next(true); + } + + /* + * Gives synchronous access to the config + */ + public getConfig(): ReportingConfig { + if (!this.config) { + throw new Error('Config is not yet initialized'); + } + return this.config; + } + + /* + * Gives async access to the startDeps + */ + private async getPluginStartDeps() { + if (this.pluginStartDeps) { + return this.pluginStartDeps; + } + + return await this.pluginStart$.pipe(first()).toPromise(); + } + public getExportTypesRegistry() { return this.exportTypesRegistry; } @@ -92,18 +143,15 @@ export class ReportingCore { .toPromise(); } - public getConfig(): ReportingConfig { - return this.config; - } - - public getScreenshotsObservable(): ScreenshotsObservableFn { - const { browserDriverFactory } = this; - if (!browserDriverFactory) { - throw new Error(`"browserDriverFactory" dependency hasn't initialized yet`); - } - return screenshotsObservableFactory(this.config.get('capture'), browserDriverFactory); + public async getScreenshotsObservable(): Promise { + const config = this.getConfig(); + const { browserDriverFactory } = await this.getPluginStartDeps(); + return screenshotsObservableFactory(config.get('capture'), browserDriverFactory); } + /* + * Gives synchronous access to the setupDeps + */ public getPluginSetupDeps() { if (!this.pluginSetupDeps) { throw new Error(`"pluginSetupDeps" dependencies haven't initialized yet`); @@ -111,18 +159,7 @@ export class ReportingCore { return this.pluginSetupDeps; } - /* - * Outside dependencies - */ - - private async getPluginStartDeps() { - if (this.pluginStartDeps) { - return this.pluginStartDeps; - } - return await this.pluginStart$.pipe(first()).toPromise(); - } - - public async getElasticsearchService() { + public getElasticsearchService() { return this.getPluginSetupDeps().elasticsearch; } diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/execute_job.test.ts index ddcf94079ade42..4ce448e953bd17 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/execute_job.test.ts @@ -5,10 +5,16 @@ */ import nodeCrypto from '@elastic/node-crypto'; +import { IUiSettingsClient, ElasticsearchServiceSetup } from 'kibana/server'; // @ts-ignore import Puid from 'puid'; import sinon from 'sinon'; +import { ReportingConfig, ReportingCore } from '../../../'; import { fieldFormats, UI_SETTINGS } from '../../../../../../../src/plugins/data/server'; +import { + CSV_QUOTE_VALUES_SETTING, + CSV_SEPARATOR_SETTING, +} from '../../../../../../../src/plugins/share/server'; import { CancellationToken } from '../../../../common'; import { CSV_BOM_CHARS } from '../../../../common/constants'; import { LevelLogger } from '../../../lib'; @@ -16,10 +22,6 @@ import { setFieldFormats } from '../../../services'; import { createMockReportingCore } from '../../../test_helpers'; import { JobDocPayloadDiscoverCsv } from '../types'; import { executeJobFactory } from './execute_job'; -import { - CSV_SEPARATOR_SETTING, - CSV_QUOTE_VALUES_SETTING, -} from '../../../../../../../src/plugins/share/server'; const delay = (ms: number) => new Promise((resolve) => setTimeout(() => resolve(), ms)); @@ -48,8 +50,8 @@ describe('CSV Execute Job', function () { let clusterStub: any; let configGetStub: any; - let mockReportingConfig: any; - let mockReportingCore: any; + let mockReportingConfig: ReportingConfig; + let mockReportingCore: ReportingCore; let callAsCurrentUserStub: any; let cancellationToken: any; @@ -78,9 +80,11 @@ describe('CSV Execute Job', function () { mockReportingConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; mockReportingCore = await createMockReportingCore(mockReportingConfig); - mockReportingCore.getUiSettingsServiceFactory = () => Promise.resolve(mockUiSettingsClient); - mockReportingCore.getElasticsearchService = () => Promise.resolve(mockElasticsearch); - mockReportingCore.config = mockReportingConfig; + mockReportingCore.getUiSettingsServiceFactory = () => + Promise.resolve((mockUiSettingsClient as unknown) as IUiSettingsClient); + mockReportingCore.getElasticsearchService = () => + mockElasticsearch as ElasticsearchServiceSetup; + mockReportingCore.setConfig(mockReportingConfig); cancellationToken = new CancellationToken(); @@ -995,7 +999,8 @@ describe('CSV Execute Job', function () { let maxSizeReached: boolean; beforeEach(async function () { - mockReportingCore.getUiSettingsServiceFactory = () => mockUiSettingsClient; + mockReportingCore.getUiSettingsServiceFactory = () => + Promise.resolve((mockUiSettingsClient as unknown) as IUiSettingsClient); configGetStub.withArgs('csv', 'maxSizeBytes').returns(18); callAsCurrentUserStub.onFirstCall().returns({ diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv/server/execute_job.ts index 4b17cc669efe1b..91a4db0469fb53 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/execute_job.ts @@ -33,7 +33,7 @@ export const executeJobFactory: ExecuteJobFactory callAsCurrentUser(...params); const uiSettings = await getUiSettings(uiConfig); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/server/execute_job/index.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/server/execute_job/index.test.ts index b8e1e5eebd9e79..2f4ca47cf739e9 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/server/execute_job/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/server/execute_job/index.test.ts @@ -84,6 +84,7 @@ test(`passes browserTimezone to generatePdf`, async () => { await executeJob( 'pdfJobId', getJobDocPayload({ + title: 'PDF Params Timezone Test', relativeUrl: '/app/kibana#/something', browserTimezone, headers: encryptedHeaders, @@ -91,39 +92,8 @@ test(`passes browserTimezone to generatePdf`, async () => { cancellationToken ); - expect(generatePdfObservable.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - LevelLogger { - "_logger": Object { - "get": [MockFunction], - }, - "_tags": Array [ - "printable_pdf", - "execute", - "pdfJobId", - ], - "warning": [Function], - }, - undefined, - Array [ - "http://localhost:5601/sbp/app/kibana#/something", - ], - "UTC", - Object { - "conditions": Object { - "basePath": "/sbp", - "hostname": "localhost", - "port": 5601, - "protocol": "http", - }, - "headers": Object {}, - }, - undefined, - false, - ], - ] - `); + const tzParam = generatePdfObservable.mock.calls[0][3]; + expect(tzParam).toBe('UTC'); }); test(`returns content_type of application/pdf`, async () => { diff --git a/x-pack/plugins/reporting/server/lib/jobs_query.ts b/x-pack/plugins/reporting/server/lib/jobs_query.ts index 8784d8ff35d251..f4670847260ee6 100644 --- a/x-pack/plugins/reporting/server/lib/jobs_query.ts +++ b/x-pack/plugins/reporting/server/lib/jobs_query.ts @@ -6,10 +6,9 @@ import { i18n } from '@kbn/i18n'; import { errors as elasticsearchErrors } from 'elasticsearch'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; +import { ReportingCore } from '../'; import { AuthenticatedUser } from '../../../security/server'; -import { ReportingConfig } from '../'; import { JobSource } from '../types'; const esErrors = elasticsearchErrors as Record; @@ -42,11 +41,8 @@ interface CountAggResult { const getUsername = (user: AuthenticatedUser | null) => (user ? user.username : false); -export function jobsQueryFactory( - config: ReportingConfig, - elasticsearch: ElasticsearchServiceSetup -) { - const index = config.get('index'); +export function jobsQueryFactory(reportingCore: ReportingCore) { + const { elasticsearch } = reportingCore.getPluginSetupDeps(); const { callAsInternalUser } = elasticsearch.legacy.client; function execQuery(queryType: string, body: QueryBody) { @@ -60,6 +56,8 @@ export function jobsQueryFactory( }, }; + const config = reportingCore.getConfig(); + const index = config.get('index'); const query = { index: `${index}-*`, body: Object.assign(defaultBody[queryType] || {}, body), diff --git a/x-pack/plugins/reporting/server/plugin.test.ts b/x-pack/plugins/reporting/server/plugin.test.ts index b2bcd6b9c97ce3..420fa8347cdebb 100644 --- a/x-pack/plugins/reporting/server/plugin.test.ts +++ b/x-pack/plugins/reporting/server/plugin.test.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + jest.mock('./browsers/install', () => ({ installBrowser: jest.fn().mockImplementation(() => ({ binaryPath$: { @@ -62,10 +63,10 @@ describe('Reporting Plugin', () => { }); it('logs setup issues', async () => { + initContext.config = null; const plugin = new ReportingPlugin(initContext); // @ts-ignore overloading error logger plugin.logger.error = jest.fn(); - coreSetup.elasticsearch = null; plugin.setup(coreSetup, pluginSetup); await sleep(5); diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index a3c89c7b8a8ce1..693b0917603fcd 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as Rx from 'rxjs'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/server'; import { ReportingCore } from './'; import { initializeBrowserDriverFactory } from './browsers'; @@ -15,47 +14,57 @@ import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; import { registerReportingUsageCollector } from './usage'; +declare module 'src/core/server' { + interface RequestHandlerContext { + reporting?: ReportingStart | null; + } +} + export class ReportingPlugin implements Plugin { private readonly initializerContext: PluginInitializerContext; private logger: LevelLogger; - private reportingCore?: ReportingCore; - - // Setup some observables for modules that need to await setup/start - public readonly setup$ = new Rx.Subject(); - public readonly start$ = new Rx.Subject(); + private reportingCore: ReportingCore; constructor(context: PluginInitializerContext) { this.logger = new LevelLogger(context.logger.get()); this.initializerContext = context; + this.reportingCore = new ReportingCore(); } public setup(core: CoreSetup, plugins: ReportingSetupDeps) { + // prevent throwing errors in route handlers about async deps not being initialized + core.http.registerRouteHandlerContext('reporting', () => { + if (this.reportingCore.pluginIsStarted()) { + return {}; // ReportingStart contract + } else { + return null; + } + }); + const { elasticsearch, http } = core; const { licensing, security } = plugins; - const { initializerContext: initContext } = this; + const { initializerContext: initContext, reportingCore } = this; + const router = http.createRouter(); const basePath = http.basePath.get; + reportingCore.pluginSetup({ + elasticsearch, + licensing, + basePath, + router, + security, + }); + + registerReportingUsageCollector(reportingCore, plugins); + registerRoutes(reportingCore, this.logger); + // async background setup (async () => { const config = await buildConfig(initContext, core, this.logger); - const reportingCore = new ReportingCore(config); - - reportingCore.pluginSetup({ - elasticsearch, - licensing, - basePath, - router, - security, - }); - - registerReportingUsageCollector(reportingCore, plugins); - registerRoutes(reportingCore, this.logger); - this.reportingCore = reportingCore; - + reportingCore.setConfig(config); this.logger.debug('Setup complete'); - this.setup$.next(true); })().catch((e) => { this.logger.error(`Error in Reporting setup, reporting may not function properly`); this.logger.error(e); @@ -68,20 +77,21 @@ export class ReportingPlugin // use data plugin for csv formats setFieldFormats(plugins.data.fieldFormats); - const { logger } = this; - const reportingCore = this.getReportingCore(); - const config = reportingCore.getConfig(); + const { logger, reportingCore } = this; const { elasticsearch } = reportingCore.getPluginSetupDeps(); // async background start (async () => { + await this.reportingCore.pluginSetsUp(); + const config = reportingCore.getConfig(); + const browserDriverFactory = await initializeBrowserDriverFactory(config, logger); - reportingCore.setBrowserDriverFactory(browserDriverFactory); - const esqueue = await createQueueFactory(reportingCore, logger); - const enqueueJob = enqueueJobFactory(reportingCore, logger); + const esqueue = await createQueueFactory(reportingCore, logger); // starts polling for pending jobs + const enqueueJob = enqueueJobFactory(reportingCore, logger); // called from generation routes reportingCore.pluginStart({ + browserDriverFactory, savedObjects: core.savedObjects, uiSettings: core.uiSettings, esqueue, @@ -92,7 +102,6 @@ export class ReportingPlugin runValidations(config, elasticsearch, browserDriverFactory, this.logger); this.logger.debug('Start complete'); - this.start$.next(true); })().catch((e) => { this.logger.error(`Error in Reporting start, reporting may not function properly`); this.logger.error(e); @@ -100,11 +109,4 @@ export class ReportingPlugin return {}; } - - public getReportingCore() { - if (!this.reportingCore) { - throw new Error('Setup is not ready'); - } - return this.reportingCore; - } } diff --git a/x-pack/plugins/reporting/server/routes/generation.test.ts b/x-pack/plugins/reporting/server/routes/generation.test.ts index f9b3e5446cfce6..4474f2c95e1c31 100644 --- a/x-pack/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/plugins/reporting/server/routes/generation.test.ts @@ -18,6 +18,7 @@ import { of } from 'rxjs'; type setupServerReturn = UnwrapPromise>; describe('POST /api/reporting/generate', () => { + const reportingSymbol = Symbol('reporting'); let server: setupServerReturn['server']; let httpSetup: setupServerReturn['httpSetup']; let exportTypesRegistry: ExportTypesRegistry; @@ -47,7 +48,8 @@ describe('POST /api/reporting/generate', () => { } as unknown) as jest.Mocked; beforeEach(async () => { - ({ server, httpSetup } = await setupServer()); + ({ server, httpSetup } = await setupServer(reportingSymbol)); + httpSetup.registerRouteHandlerContext(reportingSymbol, 'reporting', () => ({})); const mockDeps = ({ elasticsearch: { legacy: { diff --git a/x-pack/plugins/reporting/server/routes/generation.ts b/x-pack/plugins/reporting/server/routes/generation.ts index f2e616c0803a78..b4c81e698ce71f 100644 --- a/x-pack/plugins/reporting/server/routes/generation.ts +++ b/x-pack/plugins/reporting/server/routes/generation.ts @@ -17,15 +17,21 @@ import { HandlerFunction } from './types'; const esErrors = elasticsearchErrors as Record; -export function registerJobGenerationRoutes(reporting: ReportingCore, logger: Logger) { +const getDownloadBaseUrl = (reporting: ReportingCore) => { const config = reporting.getConfig(); - const downloadBaseUrl = - config.kbnConfig.get('server', 'basePath') + `${API_BASE_URL}/jobs/download`; + return config.kbnConfig.get('server', 'basePath') + `${API_BASE_URL}/jobs/download`; +}; +export function registerJobGenerationRoutes(reporting: ReportingCore, logger: Logger) { /* * Generates enqueued job details to use in responses */ const handler: HandlerFunction = async (user, exportTypeId, jobParams, context, req, res) => { + // ensure the async dependencies are loaded + if (!context.reporting) { + return res.custom({ statusCode: 503, body: 'Not Available' }); + } + const licenseInfo = await reporting.getLicenseInfo(); const licenseResults = licenseInfo[exportTypeId]; @@ -42,6 +48,7 @@ export function registerJobGenerationRoutes(reporting: ReportingCore, logger: Lo // return the queue's job information const jobJson = job.toJSON(); + const downloadBaseUrl = getDownloadBaseUrl(reporting); return res.ok({ headers: { @@ -86,10 +93,6 @@ export function registerJobGenerationRoutes(reporting: ReportingCore, logger: Lo } registerGenerateFromJobParams(reporting, handler, handleError); - - // Register beta panel-action download-related API's - if (config.get('csv', 'enablePanelActionDownload')) { - registerGenerateCsvFromSavedObject(reporting, handler, handleError); - registerGenerateCsvFromSavedObjectImmediate(reporting, handleError, logger); - } + registerGenerateCsvFromSavedObject(reporting, handler, handleError); // FIXME: remove this https://github.com/elastic/kibana/issues/62986 + registerGenerateCsvFromSavedObjectImmediate(reporting, handleError, logger); } diff --git a/x-pack/plugins/reporting/server/routes/jobs.test.ts b/x-pack/plugins/reporting/server/routes/jobs.test.ts index 22d60d62d5fdb5..35594474685b0d 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.test.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.test.ts @@ -19,6 +19,7 @@ import { registerJobInfoRoutes } from './jobs'; type setupServerReturn = UnwrapPromise>; describe('GET /api/reporting/jobs/download', () => { + const reportingSymbol = Symbol('reporting'); let server: setupServerReturn['server']; let httpSetup: setupServerReturn['httpSetup']; let exportTypesRegistry: ExportTypesRegistry; @@ -39,7 +40,8 @@ describe('GET /api/reporting/jobs/download', () => { }; beforeEach(async () => { - ({ server, httpSetup } = await setupServer()); + ({ server, httpSetup } = await setupServer(reportingSymbol)); + httpSetup.registerRouteHandlerContext(reportingSymbol, 'reporting', () => ({})); core = await createMockReportingCore(config, ({ elasticsearch: { legacy: { client: { callAsInternalUser: jest.fn() } }, diff --git a/x-pack/plugins/reporting/server/routes/jobs.ts b/x-pack/plugins/reporting/server/routes/jobs.ts index 29cf55bc5c72ec..90185f0736ed81 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.ts @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; import { ReportingCore } from '../'; import { API_BASE_URL } from '../../common/constants'; import { jobsQueryFactory } from '../lib/jobs_query'; +import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; import { deleteJobResponseHandlerFactory, downloadJobResponseHandlerFactory, } from './lib/job_response_handler'; -import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; interface ListQuery { page: string; @@ -22,12 +22,14 @@ interface ListQuery { } const MAIN_ENTRY = `${API_BASE_URL}/jobs`; +const handleUnavailable = (res: any) => { + return res.custom({ statusCode: 503, body: 'Not Available' }); +}; + export function registerJobInfoRoutes(reporting: ReportingCore) { - const config = reporting.getConfig(); const setupDeps = reporting.getPluginSetupDeps(); const userHandler = authorizedUserPreRoutingFactory(reporting); - const { elasticsearch, router } = setupDeps; - const jobsQuery = jobsQueryFactory(config, elasticsearch); + const { router } = setupDeps; // list jobs in the queue, paginated router.get( @@ -36,6 +38,11 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { validate: false, }, userHandler(async (user, context, req, res) => { + // ensure the async dependencies are loaded + if (!context.reporting) { + return handleUnavailable(res); + } + const { management: { jobTypes = [] }, } = await reporting.getLicenseInfo(); @@ -47,6 +54,7 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { const page = parseInt(queryPage, 10) || 0; const size = Math.min(100, parseInt(querySize, 10) || 10); const jobIds = queryIds ? queryIds.split(',') : null; + const jobsQuery = jobsQueryFactory(reporting); const results = await jobsQuery.list(jobTypes, user, page, size, jobIds); return res.ok({ @@ -65,10 +73,16 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { validate: false, }, userHandler(async (user, context, req, res) => { + // ensure the async dependencies are loaded + if (!context.reporting) { + return handleUnavailable(res); + } + const { management: { jobTypes = [] }, } = await reporting.getLicenseInfo(); + const jobsQuery = jobsQueryFactory(reporting); const count = await jobsQuery.count(jobTypes, user); return res.ok({ @@ -91,11 +105,17 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { }, }, userHandler(async (user, context, req, res) => { + // ensure the async dependencies are loaded + if (!context.reporting) { + return handleUnavailable(res); + } + const { docId } = req.params as { docId: string }; const { management: { jobTypes = [] }, } = await reporting.getLicenseInfo(); + const jobsQuery = jobsQueryFactory(reporting); const result = await jobsQuery.get(user, docId, { includeContent: true }); if (!result) { @@ -130,11 +150,17 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { }, }, userHandler(async (user, context, req, res) => { + // ensure the async dependencies are loaded + if (!context.reporting) { + return res.custom({ statusCode: 503 }); + } + const { docId } = req.params as { docId: string }; const { management: { jobTypes = [] }, } = await reporting.getLicenseInfo(); + const jobsQuery = jobsQueryFactory(reporting); const result = await jobsQuery.get(user, docId); if (!result) { @@ -164,12 +190,7 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { ); // trigger a download of the output from a job - const exportTypesRegistry = reporting.getExportTypesRegistry(); - const downloadResponseHandler = downloadJobResponseHandlerFactory( - config, - elasticsearch, - exportTypesRegistry - ); + const downloadResponseHandler = downloadJobResponseHandlerFactory(reporting); router.get( { @@ -181,6 +202,11 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { }, }, userHandler(async (user, context, req, res) => { + // ensure the async dependencies are loaded + if (!context.reporting) { + return handleUnavailable(res); + } + const { docId } = req.params as { docId: string }; const { management: { jobTypes = [] }, @@ -191,7 +217,7 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { ); // allow a report to be deleted - const deleteResponseHandler = deleteJobResponseHandlerFactory(config, elasticsearch); + const deleteResponseHandler = deleteJobResponseHandlerFactory(reporting); router.delete( { path: `${MAIN_ENTRY}/delete/{docId}`, @@ -202,6 +228,11 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { }, }, userHandler(async (user, context, req, res) => { + // ensure the async dependencies are loaded + if (!context.reporting) { + return handleUnavailable(res); + } + const { docId } = req.params as { docId: string }; const { management: { jobTypes = [] }, diff --git a/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts b/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts index 2ad974c9dd8e18..2f5d4ebe1419aa 100644 --- a/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts +++ b/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts @@ -19,7 +19,6 @@ export type RequestHandlerUser = RequestHandler extends (...a: infer U) => infer export const authorizedUserPreRoutingFactory = function authorizedUserPreRoutingFn( reporting: ReportingCore ) { - const config = reporting.getConfig(); const setupDeps = reporting.getPluginSetupDeps(); const getUser = getUserFactory(setupDeps.security); return (handler: RequestHandlerUser): RequestHandler => { @@ -36,6 +35,7 @@ export const authorizedUserPreRoutingFactory = function authorizedUserPreRouting if (user) { // check allowance with the configured set of roleas + "superuser" + const config = reporting.getConfig(); const allowedRoles = config.get('roles', 'allow') || []; const authorizedRoles = [superuserRole, ...allowedRoles]; diff --git a/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts index 1a2e10cf355a2c..a8492481e6b135 100644 --- a/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup, kibanaResponseFactory } from 'kibana/server'; +import { kibanaResponseFactory } from 'kibana/server'; +import { ReportingCore } from '../../'; import { AuthenticatedUser } from '../../../../security/server'; -import { ReportingConfig } from '../../'; import { WHITELISTED_JOB_CONTENT_TYPES } from '../../../common/constants'; -import { ExportTypesRegistry } from '../../lib/export_types_registry'; import { jobsQueryFactory } from '../../lib/jobs_query'; import { getDocumentPayloadFactory } from './get_document_payload'; @@ -20,12 +19,9 @@ interface JobResponseHandlerOpts { excludeContent?: boolean; } -export function downloadJobResponseHandlerFactory( - config: ReportingConfig, - elasticsearch: ElasticsearchServiceSetup, - exportTypesRegistry: ExportTypesRegistry -) { - const jobsQuery = jobsQueryFactory(config, elasticsearch); +export function downloadJobResponseHandlerFactory(reporting: ReportingCore) { + const jobsQuery = jobsQueryFactory(reporting); + const exportTypesRegistry = reporting.getExportTypesRegistry(); const getDocumentPayload = getDocumentPayloadFactory(exportTypesRegistry); return async function jobResponseHandler( @@ -69,11 +65,8 @@ export function downloadJobResponseHandlerFactory( }; } -export function deleteJobResponseHandlerFactory( - config: ReportingConfig, - elasticsearch: ElasticsearchServiceSetup -) { - const jobsQuery = jobsQueryFactory(config, elasticsearch); +export function deleteJobResponseHandlerFactory(reporting: ReportingCore) { + const jobsQuery = jobsQueryFactory(reporting); return async function deleteJobResponseHander( res: typeof kibanaResponseFactory, diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index 669381a92c5227..579035a46f615e 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -11,18 +11,15 @@ jest.mock('../lib/create_queue'); jest.mock('../lib/enqueue_job'); jest.mock('../lib/validate'); -import { of } from 'rxjs'; -import { first } from 'rxjs/operators'; -import { coreMock } from 'src/core/server/mocks'; +import * as Rx from 'rxjs'; import { ReportingConfig, ReportingCore } from '../'; import { chromium, HeadlessChromiumDriverFactory, initializeBrowserDriverFactory, } from '../browsers'; -import { ReportingInternalSetup } from '../core'; -import { ReportingPlugin } from '../plugin'; -import { ReportingSetupDeps, ReportingStartDeps } from '../types'; +import { ReportingInternalSetup, ReportingInternalStart } from '../core'; +import { ReportingStartDeps } from '../types'; (initializeBrowserDriverFactory as jest.Mock< Promise @@ -30,32 +27,30 @@ import { ReportingSetupDeps, ReportingStartDeps } from '../types'; (chromium as any).createDriverFactory.mockImplementation(() => ({})); -const createMockSetupDeps = (setupMock?: any): ReportingSetupDeps => { +const createMockPluginSetup = (setupMock?: any): ReportingInternalSetup => { return { + elasticsearch: setupMock.elasticsearch || { legacy: { client: {} } }, + basePath: setupMock.basePath, + router: setupMock.router, security: setupMock.security, - licensing: { - license$: of({ isAvailable: true, isActive: true, type: 'basic' }), - } as any, - usageCollection: { - makeUsageCollector: jest.fn(), - registerCollector: jest.fn(), - } as any, + licensing: { license$: Rx.of({ isAvailable: true, isActive: true, type: 'basic' }) } as any, + }; +}; + +const createMockPluginStart = (startMock?: any): ReportingInternalStart => { + return { + browserDriverFactory: startMock.browserDriverFactory, + enqueueJob: startMock.enqueueJob, + esqueue: startMock.esqueue, + savedObjects: startMock.savedObjects || { getScopedClient: jest.fn() }, + uiSettings: startMock.uiSettings || { asScopedToClient: () => ({ get: jest.fn() }) }, }; }; export const createMockConfigSchema = (overrides?: any) => ({ index: '.reporting', - kibanaServer: { - hostname: 'localhost', - port: '80', - }, - capture: { - browser: { - chromium: { - disableSandbox: true, - }, - }, - }, + kibanaServer: { hostname: 'localhost', port: '80' }, + capture: { browser: { chromium: { disableSandbox: true } } }, ...overrides, }); @@ -63,36 +58,20 @@ export const createMockStartDeps = (startMock?: any): ReportingStartDeps => ({ data: startMock.data, }); -const createMockReportingPlugin = async (config: ReportingConfig): Promise => { - const mockConfigSchema = createMockConfigSchema(config); - const plugin = new ReportingPlugin(coreMock.createPluginInitializerContext(mockConfigSchema)); - const setupMock = coreMock.createSetup(); - const coreStartMock = coreMock.createStart(); - const startMock = { - ...coreStartMock, - data: { fieldFormats: {} }, - }; - - plugin.setup(setupMock, createMockSetupDeps(setupMock)); - await plugin.setup$.pipe(first()).toPromise(); - plugin.start(startMock, createMockStartDeps(startMock)); - await plugin.start$.pipe(first()).toPromise(); - - return plugin; -}; - export const createMockReportingCore = async ( config: ReportingConfig, - setupDepsMock?: ReportingInternalSetup -): Promise => { + setupDepsMock: ReportingInternalSetup | undefined = createMockPluginSetup({}), + startDepsMock: ReportingInternalStart | undefined = createMockPluginStart({}) +) => { config = config || {}; - const plugin = await createMockReportingPlugin(config); - const core = plugin.getReportingCore(); + const core = new ReportingCore(); + + core.pluginSetup(setupDepsMock); + core.setConfig(config); + await core.pluginSetsUp(); - if (setupDepsMock) { - // @ts-ignore overwriting private properties - core.pluginSetupDeps = setupDepsMock; - } + core.pluginStart(startDepsMock); + await core.pluginStartsUp(); return core; }; diff --git a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts index d5dccaca3042a0..ed2abef2542deb 100644 --- a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts +++ b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts @@ -7,7 +7,7 @@ import * as Rx from 'rxjs'; import sinon from 'sinon'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { ReportingConfig } from '../'; +import { ReportingConfig, ReportingCore } from '../'; import { createMockReportingCore } from '../test_helpers'; import { getExportTypesRegistry } from '../lib/export_types_registry'; import { ReportingSetupDeps } from '../types'; @@ -62,8 +62,10 @@ const getResponseMock = (base = {}) => base; describe('license checks', () => { let mockConfig: ReportingConfig; + let mockCore: ReportingCore; beforeAll(async () => { mockConfig = getMockReportingConfig(); + mockCore = await createMockReportingCore(mockConfig); }); describe('with a basic license', () => { @@ -72,7 +74,7 @@ describe('license checks', () => { const plugins = getPluginsMock({ license: 'basic' }); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); const { fetch } = getReportingUsageCollector( - mockConfig, + mockCore, plugins.usageCollection, getLicenseMock('basic'), exportTypesRegistry, @@ -102,7 +104,7 @@ describe('license checks', () => { const plugins = getPluginsMock({ license: 'none' }); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); const { fetch } = getReportingUsageCollector( - mockConfig, + mockCore, plugins.usageCollection, getLicenseMock('none'), exportTypesRegistry, @@ -132,7 +134,7 @@ describe('license checks', () => { const plugins = getPluginsMock({ license: 'platinum' }); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); const { fetch } = getReportingUsageCollector( - mockConfig, + mockCore, plugins.usageCollection, getLicenseMock('platinum'), exportTypesRegistry, @@ -162,7 +164,7 @@ describe('license checks', () => { const plugins = getPluginsMock({ license: 'basic' }); const callClusterMock = jest.fn(() => Promise.resolve({})); const { fetch } = getReportingUsageCollector( - mockConfig, + mockCore, plugins.usageCollection, getLicenseMock('basic'), exportTypesRegistry, @@ -184,11 +186,16 @@ describe('license checks', () => { }); describe('data modeling', () => { + let mockConfig: ReportingConfig; + let mockCore: ReportingCore; + beforeAll(async () => { + mockConfig = getMockReportingConfig(); + mockCore = await createMockReportingCore(mockConfig); + }); test('with normal looking usage data', async () => { - const mockConfig = getMockReportingConfig(); const plugins = getPluginsMock(); const { fetch } = getReportingUsageCollector( - mockConfig, + mockCore, plugins.usageCollection, getLicenseMock(), exportTypesRegistry, @@ -238,10 +245,9 @@ describe('data modeling', () => { }); test('with sparse data', async () => { - const mockConfig = getMockReportingConfig(); const plugins = getPluginsMock(); const { fetch } = getReportingUsageCollector( - mockConfig, + mockCore, plugins.usageCollection, getLicenseMock(), exportTypesRegistry, @@ -291,10 +297,9 @@ describe('data modeling', () => { }); test('with empty data', async () => { - const mockConfig = getMockReportingConfig(); const plugins = getPluginsMock(); const { fetch } = getReportingUsageCollector( - mockConfig, + mockCore, plugins.usageCollection, getLicenseMock(), exportTypesRegistry, diff --git a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.ts b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.ts index d77d1b5396844f..364f5187f056c0 100644 --- a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.ts +++ b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.ts @@ -9,7 +9,6 @@ import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { ReportingCore } from '../'; import { KIBANA_REPORTING_TYPE } from '../../common/constants'; -import { ReportingConfig } from '../../server'; import { ExportTypesRegistry } from '../lib/export_types_registry'; import { ReportingSetupDeps } from '../types'; import { GetLicense } from './'; @@ -23,7 +22,7 @@ const METATYPE = 'kibana_stats'; * @return {Object} kibana usage stats type collection object */ export function getReportingUsageCollector( - config: ReportingConfig, + reporting: ReportingCore, usageCollection: UsageCollectionSetup, getLicense: GetLicense, exportTypesRegistry: ExportTypesRegistry, @@ -31,8 +30,10 @@ export function getReportingUsageCollector( ) { return usageCollection.makeUsageCollector({ type: KIBANA_REPORTING_TYPE, - fetch: (callCluster: CallCluster) => - getReportingUsage(config, getLicense, callCluster, exportTypesRegistry), + fetch: (callCluster: CallCluster) => { + const config = reporting.getConfig(); + return getReportingUsage(config, getLicense, callCluster, exportTypesRegistry); + }, isReady, /* @@ -63,7 +64,6 @@ export function registerReportingUsageCollector( return; } - const config = reporting.getConfig(); const exportTypesRegistry = reporting.getExportTypesRegistry(); const getLicense = async () => { return await licensing.license$ @@ -78,10 +78,10 @@ export function registerReportingUsageCollector( ) .toPromise(); }; - const collectionIsReady = reporting.pluginHasStarted.bind(reporting); + const collectionIsReady = reporting.pluginStartsUp.bind(reporting); const collector = getReportingUsageCollector( - config, + reporting, usageCollection, getLicense, exportTypesRegistry, diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index b0b9d70a76c32b..93883eeff52710 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -191,7 +191,7 @@ export type Severity = t.TypeOf; export const severityOrUndefined = t.union([severity, t.undefined]); export type SeverityOrUndefined = t.TypeOf; -export const status = t.keyof({ open: null, closed: null }); +export const status = t.keyof({ open: null, closed: null, 'in-progress': null }); export type Status = t.TypeOf; export const job_status = t.keyof({ succeeded: null, failed: null, 'going to run': null }); diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts new file mode 100644 index 00000000000000..bb00321567e80b --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const eventsIndexPattern = 'events-endpoint-*'; +export const metadataIndexPattern = 'metrics-endpoint.metadata-*'; +export const policyIndexPattern = 'metrics-endpoint.policy-*'; +export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; diff --git a/x-pack/plugins/security_solution/common/endpoint_alerts/alert_constants.ts b/x-pack/plugins/security_solution/common/endpoint_alerts/alert_constants.ts index 66de2b85ef3a78..cb0e5f67c701b4 100644 --- a/x-pack/plugins/security_solution/common/endpoint_alerts/alert_constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint_alerts/alert_constants.ts @@ -9,14 +9,6 @@ export class AlertConstants { * The prefix for all Alert APIs */ static BASE_API_URL = '/api/endpoint'; - /** - * The path for the Alert's Index Pattern API. - */ - static INDEX_PATTERN_ROUTE = `${AlertConstants.BASE_API_URL}/index_pattern`; - /** - * A paramter passed to Alert's Index Pattern. - */ - static EVENT_DATASET = 'events'; /** * Alert's Search API default page size */ diff --git a/x-pack/plugins/security_solution/cypress/integration/detections.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detections.spec.ts index 23e84070e93aed..43acdd40dadcc9 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detections.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detections.spec.ts @@ -5,10 +5,10 @@ */ import { NUMBER_OF_ALERTS, - OPEN_CLOSE_ALERTS_BTN, SELECTED_ALERTS, SHOWING_ALERTS, ALERTS, + TAKE_ACTION_POPOVER_BTN, } from '../screens/detections'; import { @@ -22,6 +22,8 @@ import { waitForAlertsPanelToBeLoaded, waitForAlerts, waitForAlertsToBeLoaded, + markInProgressFirstAlert, + goToInProgressAlerts, } from '../tasks/detections'; import { esArchiverLoad } from '../tasks/es_archiver'; import { loginAndWaitForPage } from '../tasks/login'; @@ -128,9 +130,9 @@ describe('Detections', () => { const numberOfAlertsToBeClosed = 1; const numberOfAlertsToBeSelected = 3; - cy.get(OPEN_CLOSE_ALERTS_BTN).should('have.attr', 'disabled'); + cy.get(TAKE_ACTION_POPOVER_BTN).should('have.attr', 'disabled'); selectNumberOfAlerts(numberOfAlertsToBeSelected); - cy.get(OPEN_CLOSE_ALERTS_BTN).should('not.have.attr', 'disabled'); + cy.get(TAKE_ACTION_POPOVER_BTN).should('not.have.attr', 'disabled'); closeFirstAlert(); cy.reload(); @@ -173,9 +175,9 @@ describe('Detections', () => { const numberOfAlertsToBeOpened = 1; const numberOfAlertsToBeSelected = 3; - cy.get(OPEN_CLOSE_ALERTS_BTN).should('have.attr', 'disabled'); + cy.get(TAKE_ACTION_POPOVER_BTN).should('have.attr', 'disabled'); selectNumberOfAlerts(numberOfAlertsToBeSelected); - cy.get(OPEN_CLOSE_ALERTS_BTN).should('not.have.attr', 'disabled'); + cy.get(TAKE_ACTION_POPOVER_BTN).should('not.have.attr', 'disabled'); openFirstAlert(); cy.reload(); @@ -202,4 +204,49 @@ describe('Detections', () => { }); }); }); + context('Marking alerts as in-progress', () => { + beforeEach(() => { + esArchiverLoad('alerts'); + loginAndWaitForPage(DETECTIONS); + }); + + it('Mark one alert in progress when more than one open alerts are selected', () => { + waitForAlerts(); + waitForAlertsToBeLoaded(); + + cy.get(NUMBER_OF_ALERTS) + .invoke('text') + .then((numberOfAlerts) => { + const numberOfAlertsToBeMarkedInProgress = 1; + const numberOfAlertsToBeSelected = 3; + + cy.get(TAKE_ACTION_POPOVER_BTN).should('have.attr', 'disabled'); + selectNumberOfAlerts(numberOfAlertsToBeSelected); + cy.get(TAKE_ACTION_POPOVER_BTN).should('not.have.attr', 'disabled'); + + markInProgressFirstAlert(); + cy.reload(); + goToOpenedAlerts(); + waitForAlertsToBeLoaded(); + waitForAlerts(); + + const expectedNumberOfAlerts = +numberOfAlerts - numberOfAlertsToBeMarkedInProgress; + cy.get(NUMBER_OF_ALERTS).invoke('text').should('eq', expectedNumberOfAlerts.toString()); + cy.get(SHOWING_ALERTS) + .invoke('text') + .should('eql', `Showing ${expectedNumberOfAlerts.toString()} alerts`); + + goToInProgressAlerts(); + waitForAlerts(); + + cy.get(NUMBER_OF_ALERTS) + .invoke('text') + .should('eql', numberOfAlertsToBeMarkedInProgress.toString()); + cy.get(SHOWING_ALERTS) + .invoke('text') + .should('eql', `Showing ${numberOfAlertsToBeMarkedInProgress.toString()} alert`); + cy.get(ALERTS).should('have.length', numberOfAlertsToBeMarkedInProgress); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/detections.ts b/x-pack/plugins/security_solution/cypress/screens/detections.ts index b915bcba2f880c..fb7e7e73986b9a 100644 --- a/x-pack/plugins/security_solution/cypress/screens/detections.ts +++ b/x-pack/plugins/security_solution/cypress/screens/detections.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export const CLOSED_ALERTS_BTN = '[data-test-subj="closedAlerts"]'; - export const EXPAND_ALERT_BTN = '[data-test-subj="expand-event"]'; export const LOADING_ALERTS_PANEL = '[data-test-subj="loading-alerts-panel"]'; @@ -14,11 +12,11 @@ export const MANAGE_ALERT_DETECTION_RULES_BTN = '[data-test-subj="manage-alert-d export const NUMBER_OF_ALERTS = '[data-test-subj="server-side-event-count"] .euiBadge__text'; -export const OPEN_CLOSE_ALERT_BTN = '[data-test-subj="update-alert-status-button"]'; +export const OPENED_ALERTS_FILTER_BTN = '[data-test-subj="openAlerts"]'; -export const OPEN_CLOSE_ALERTS_BTN = '[data-test-subj="openCloseAlert"] button'; +export const CLOSED_ALERTS_FILTER_BTN = '[data-test-subj="closedAlerts"]'; -export const OPENED_ALERTS_BTN = '[data-test-subj="openAlerts"]'; +export const IN_PROGRESS_ALERTS_FILTER_BTN = '[data-test-subj="inProgressAlerts"]'; export const SELECTED_ALERTS = '[data-test-subj="selectedAlerts"]'; @@ -31,3 +29,20 @@ export const ALERTS = '[data-test-subj="event"]'; export const ALERT_ID = '[data-test-subj="draggable-content-_id"]'; export const ALERT_CHECKBOX = '[data-test-subj="select-event-container"] .euiCheckbox__input'; + +export const TAKE_ACTION_POPOVER_BTN = '[data-test-subj="alertActionPopover"] button'; + +export const TIMELINE_CONTEXT_MENU_BTN = '[data-test-subj="timeline-context-menu-button"]'; + +export const OPEN_SELECTED_ALERTS_BTN = '[data-test-subj="openSelectedAlertsButton"]'; + +export const CLOSE_SELECTED_ALERTS_BTN = '[data-test-subj="closeSelectedAlertsButton"]'; + +export const MARK_SELECTED_ALERTS_IN_PROGRESS_BTN = + '[data-test-subj="markSelectedAlertsInProgressButton"]'; + +export const OPEN_ALERT_BTN = '[data-test-subj="open-alert-status"]'; + +export const CLOSE_ALERT_BTN = '[data-test-subj="close-alert-status"]'; + +export const MARK_ALERT_IN_PROGRESS_BTN = '[data-test-subj="in-progress-alert-status"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/detections.ts b/x-pack/plugins/security_solution/cypress/tasks/detections.ts index a6596bfbcc3e97..8b1e8b41b6da14 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/detections.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/detections.ts @@ -5,25 +5,34 @@ */ import { - CLOSED_ALERTS_BTN, + CLOSED_ALERTS_FILTER_BTN, EXPAND_ALERT_BTN, LOADING_ALERTS_PANEL, MANAGE_ALERT_DETECTION_RULES_BTN, - OPEN_CLOSE_ALERT_BTN, - OPEN_CLOSE_ALERTS_BTN, - OPENED_ALERTS_BTN, + OPENED_ALERTS_FILTER_BTN, SEND_ALERT_TO_TIMELINE_BTN, ALERTS, ALERT_CHECKBOX, + TIMELINE_CONTEXT_MENU_BTN, + CLOSE_ALERT_BTN, + TAKE_ACTION_POPOVER_BTN, + CLOSE_SELECTED_ALERTS_BTN, + IN_PROGRESS_ALERTS_FILTER_BTN, + OPEN_ALERT_BTN, + OPEN_SELECTED_ALERTS_BTN, + MARK_ALERT_IN_PROGRESS_BTN, + MARK_SELECTED_ALERTS_IN_PROGRESS_BTN, } from '../screens/detections'; import { REFRESH_BUTTON } from '../screens/security_header'; export const closeFirstAlert = () => { - cy.get(OPEN_CLOSE_ALERT_BTN).first().click({ force: true }); + cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); + cy.get(CLOSE_ALERT_BTN).click(); }; export const closeAlerts = () => { - cy.get(OPEN_CLOSE_ALERTS_BTN).click({ force: true }); + cy.get(TAKE_ACTION_POPOVER_BTN).click({ force: true }); + cy.get(CLOSE_SELECTED_ALERTS_BTN).click(); }; export const expandFirstAlert = () => { @@ -31,7 +40,7 @@ export const expandFirstAlert = () => { }; export const goToClosedAlerts = () => { - cy.get(CLOSED_ALERTS_BTN).click({ force: true }); + cy.get(CLOSED_ALERTS_FILTER_BTN).click({ force: true }); }; export const goToManageAlertDetectionRules = () => { @@ -39,15 +48,31 @@ export const goToManageAlertDetectionRules = () => { }; export const goToOpenedAlerts = () => { - cy.get(OPENED_ALERTS_BTN).click({ force: true }); + cy.get(OPENED_ALERTS_FILTER_BTN).click({ force: true }); }; export const openFirstAlert = () => { - cy.get(OPEN_CLOSE_ALERT_BTN).first().click({ force: true }); + cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); + cy.get(OPEN_ALERT_BTN).click(); }; export const openAlerts = () => { - cy.get(OPEN_CLOSE_ALERTS_BTN).click({ force: true }); + cy.get(TAKE_ACTION_POPOVER_BTN).click({ force: true }); + cy.get(OPEN_SELECTED_ALERTS_BTN).click(); +}; + +export const goToInProgressAlerts = () => { + cy.get(IN_PROGRESS_ALERTS_FILTER_BTN).click({ force: true }); +}; + +export const markInProgressFirstAlert = () => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); + cy.get(MARK_ALERT_IN_PROGRESS_BTN).click(); +}; + +export const markInProgressAlerts = () => { + cy.get(TAKE_ACTION_POPOVER_BTN).click({ force: true }); + cy.get(MARK_SELECTED_ALERTS_IN_PROGRESS_BTN).click(); }; export const selectNumberOfAlerts = (numberOfAlerts: number) => { diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/actions.tsx index cde81d44bc5d69..ba392e9904cc46 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/actions.tsx @@ -54,6 +54,7 @@ export const updateAlertStatusAction = async ({ query, alertIds, status, + selectedStatus, setEventsLoading, setEventsDeleted, onAlertStatusUpdateSuccess, @@ -64,13 +65,13 @@ export const updateAlertStatusAction = async ({ const queryObject = query ? { query: JSON.parse(query) } : getUpdateAlertsQuery(alertIds); - const response = await updateAlertStatus({ query: queryObject, status }); + const response = await updateAlertStatus({ query: queryObject, status: selectedStatus }); // TODO: Only delete those that were successfully updated from updatedRules setEventsDeleted({ eventIds: alertIds, isDeleted: true }); - onAlertStatusUpdateSuccess(response.updated, status); + onAlertStatusUpdateSuccess(response.updated, selectedStatus); } catch (error) { - onAlertStatusUpdateFailure(status, error); + onAlertStatusUpdateFailure(selectedStatus, error); } finally { setEventsLoading({ eventIds: alertIds, isLoading: false }); } diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/alerts_filter_group/index.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/alerts_filter_group/index.tsx index 8521170637d6f2..ba64868b70817e 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/alerts_filter_group/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/alerts_filter_group/index.tsx @@ -6,18 +6,19 @@ import { EuiFilterButton, EuiFilterGroup } from '@elastic/eui'; import React, { useCallback, useState } from 'react'; +import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import * as i18n from '../translations'; -export const FILTER_OPEN = 'open'; -export const FILTER_CLOSED = 'closed'; -export type AlertFilterOption = typeof FILTER_OPEN | typeof FILTER_CLOSED; +export const FILTER_OPEN: Status = 'open'; +export const FILTER_CLOSED: Status = 'closed'; +export const FILTER_IN_PROGRESS: Status = 'in-progress'; interface Props { - onFilterGroupChanged: (filterGroup: AlertFilterOption) => void; + onFilterGroupChanged: (filterGroup: Status) => void; } const AlertsTableFilterGroupComponent: React.FC = ({ onFilterGroupChanged }) => { - const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); + const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); const onClickOpenFilterCallback = useCallback(() => { setFilterGroup(FILTER_OPEN); @@ -29,6 +30,11 @@ const AlertsTableFilterGroupComponent: React.FC = ({ onFilterGroupChanged onFilterGroupChanged(FILTER_CLOSED); }, [setFilterGroup, onFilterGroupChanged]); + const onClickInProgressFilterCallback = useCallback(() => { + setFilterGroup(FILTER_IN_PROGRESS); + onFilterGroupChanged(FILTER_IN_PROGRESS); + }, [setFilterGroup, onFilterGroupChanged]); + return ( = ({ onFilterGroupChanged {i18n.OPEN_ALERTS} + + {i18n.IN_PROGRESS_ALERTS} + + { clearSelection={jest.fn()} totalCount={100} selectedEventIds={{}} - isFilteredToOpen={false} + currentFilter="closed" selectAll={jest.fn()} showClearSelection={true} updateAlertsStatus={jest.fn()} /> ); - expect(wrapper.find('[dataTestSubj="openCloseAlert"]')).toBeTruthy(); + expect(wrapper.find('[dataTestSubj="alertActionPopover"]')).toBeTruthy(); }); }); diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/alerts_utility_bar/index.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/alerts_utility_bar/index.tsx index 68b7039690db45..0ceb2c87dd5ea8 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/alerts_utility_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/alerts_utility_bar/index.tsx @@ -8,6 +8,10 @@ import { isEmpty } from 'lodash/fp'; import React, { useCallback } from 'react'; import numeral from '@elastic/numeral'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; +import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; +import { Link } from '../../../../common/components/link_icon'; import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants'; import { UtilityBar, @@ -20,14 +24,14 @@ import * as i18n from './translations'; import { useUiSetting$ } from '../../../../common/lib/kibana'; import { TimelineNonEcsData } from '../../../../graphql/types'; import { UpdateAlertsStatus } from '../types'; -import { FILTER_CLOSED, FILTER_OPEN } from '../alerts_filter_group'; +import { FILTER_CLOSED, FILTER_IN_PROGRESS, FILTER_OPEN } from '../alerts_filter_group'; interface AlertsUtilityBarProps { canUserCRUD: boolean; hasIndexWrite: boolean; areEventsLoading: boolean; clearSelection: () => void; - isFilteredToOpen: boolean; + currentFilter: Status; selectAll: () => void; selectedEventIds: Readonly>; showClearSelection: boolean; @@ -42,25 +46,85 @@ const AlertsUtilityBarComponent: React.FC = ({ clearSelection, totalCount, selectedEventIds, - isFilteredToOpen, + currentFilter, selectAll, showClearSelection, updateAlertsStatus, }) => { const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); - const handleUpdateStatus = useCallback(async () => { - await updateAlertsStatus({ - alertIds: Object.keys(selectedEventIds), - status: isFilteredToOpen ? FILTER_CLOSED : FILTER_OPEN, - }); - }, [selectedEventIds, updateAlertsStatus, isFilteredToOpen]); + const handleUpdateStatus = useCallback( + async (selectedStatus: Status) => { + await updateAlertsStatus({ + alertIds: Object.keys(selectedEventIds), + status: currentFilter, + selectedStatus, + }); + }, + [currentFilter, selectedEventIds, updateAlertsStatus] + ); const formattedTotalCount = numeral(totalCount).format(defaultNumberFormat); const formattedSelectedEventsCount = numeral(Object.keys(selectedEventIds).length).format( defaultNumberFormat ); + const UtilityBarFlexGroup = styled(EuiFlexGroup)` + min-width: 175px; + `; + + const UtilityBarPopoverContent = (closePopover: () => void) => ( + + {currentFilter !== FILTER_OPEN && ( + + { + closePopover(); + handleUpdateStatus('open'); + }} + color="text" + data-test-subj="openSelectedAlertsButton" + > + {i18n.BATCH_ACTION_OPEN_SELECTED} + + + )} + + {currentFilter !== FILTER_CLOSED && ( + + { + closePopover(); + handleUpdateStatus('closed'); + }} + color="text" + data-test-subj="closeSelectedAlertsButton" + > + {i18n.BATCH_ACTION_CLOSE_SELECTED} + + + )} + + {currentFilter !== FILTER_IN_PROGRESS && ( + + { + closePopover(); + handleUpdateStatus('in-progress'); + }} + color="text" + data-test-subj="markSelectedAlertsInProgressButton" + > + {i18n.BATCH_ACTION_IN_PROGRESS_SELECTED} + + + )} + + ); + return ( <> @@ -82,14 +146,16 @@ const AlertsUtilityBarComponent: React.FC = ({ - {isFilteredToOpen - ? i18n.BATCH_ACTION_CLOSE_SELECTED - : i18n.BATCH_ACTION_OPEN_SELECTED} + {i18n.TAKE_ACTION} [ { meta: { alias: null, @@ -40,32 +43,12 @@ export const alertsOpenFilters: Filter[] = [ type: 'phrase', key: 'signal.status', params: { - query: 'open', + query: status, }, }, query: { - match_phrase: { - 'signal.status': 'open', - }, - }, - }, -]; - -export const alertsClosedFilters: Filter[] = [ - { - meta: { - alias: null, - negate: false, - disabled: false, - type: 'phrase', - key: 'signal.status', - params: { - query: 'closed', - }, - }, - query: { - match_phrase: { - 'signal.status': 'closed', + term: { + 'signal.status': status, }, }, }, @@ -203,36 +186,39 @@ export const getAlertActions = ({ canUserCRUD: boolean; createTimeline: CreateTimeline; hasIndexWrite: boolean; - onAlertStatusUpdateFailure: (status: string, error: Error) => void; - onAlertStatusUpdateSuccess: (count: number, status: string) => void; + onAlertStatusUpdateFailure: (status: Status, error: Error) => void; + onAlertStatusUpdateSuccess: (count: number, status: Status) => void; setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; - status: 'open' | 'closed'; + status: Status; updateTimelineIsLoading: UpdateTimelineLoading; -}): TimelineRowAction[] => [ - { - ariaLabel: 'Send alert to timeline', - content: i18n.ACTION_INVESTIGATE_IN_TIMELINE, - dataTestSubj: 'send-alert-to-timeline', - displayType: 'icon', - iconType: 'timeline', - id: 'sendAlertToTimeline', - onClick: ({ ecsData }: TimelineRowActionOnClick) => - sendAlertToTimelineAction({ - apolloClient, - createTimeline, - ecsData, - updateTimelineIsLoading, +}): TimelineRowAction[] => { + const openAlertActionComponent: TimelineRowAction = { + ariaLabel: 'Open alert', + content: {i18n.ACTION_OPEN_ALERT}, + dataTestSubj: 'open-alert-status', + displayType: 'contextMenu', + id: FILTER_OPEN, + isActionDisabled: !canUserCRUD || !hasIndexWrite, + onClick: ({ eventId }: TimelineRowActionOnClick) => + updateAlertStatusAction({ + alertIds: [eventId], + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + status, + selectedStatus: FILTER_OPEN, }), width: 26, - }, - { - ariaLabel: 'Update alert status', - content: status === FILTER_OPEN ? i18n.ACTION_OPEN_ALERT : i18n.ACTION_CLOSE_ALERT, - dataTestSubj: 'update-alert-status', - displayType: 'icon', - iconType: status === FILTER_OPEN ? 'securitySignalDetected' : 'securitySignalResolved', - id: 'updateAlertStatus', + }; + + const closeAlertActionComponent: TimelineRowAction = { + ariaLabel: 'Close alert', + content: {i18n.ACTION_CLOSE_ALERT}, + dataTestSubj: 'close-alert-status', + displayType: 'contextMenu', + id: FILTER_CLOSED, isActionDisabled: !canUserCRUD || !hasIndexWrite, onClick: ({ eventId }: TimelineRowActionOnClick) => updateAlertStatusAction({ @@ -242,7 +228,51 @@ export const getAlertActions = ({ setEventsDeleted, setEventsLoading, status, + selectedStatus: FILTER_CLOSED, }), width: 26, - }, -]; + }; + + const inProgressAlertActionComponent: TimelineRowAction = { + ariaLabel: 'Mark alert in progress', + content: {i18n.ACTION_IN_PROGRESS_ALERT}, + dataTestSubj: 'in-progress-alert-status', + displayType: 'contextMenu', + id: FILTER_IN_PROGRESS, + isActionDisabled: !canUserCRUD || !hasIndexWrite, + onClick: ({ eventId }: TimelineRowActionOnClick) => + updateAlertStatusAction({ + alertIds: [eventId], + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + status, + selectedStatus: FILTER_IN_PROGRESS, + }), + width: 26, + }; + + return [ + { + ariaLabel: 'Send alert to timeline', + content: i18n.ACTION_INVESTIGATE_IN_TIMELINE, + dataTestSubj: 'send-alert-to-timeline', + displayType: 'icon', + iconType: 'timeline', + id: 'sendAlertToTimeline', + onClick: ({ ecsData }: TimelineRowActionOnClick) => + sendAlertToTimelineAction({ + apolloClient, + createTimeline, + ecsData, + updateTimelineIsLoading, + }), + width: 26, + }, + // Context menu items + ...(FILTER_OPEN !== status ? [openAlertActionComponent] : []), + ...(FILTER_CLOSED !== status ? [closeAlertActionComponent] : []), + ...(FILTER_IN_PROGRESS !== status ? [inProgressAlertActionComponent] : []), + ]; +}; diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx index 2be20a9d47f670..0765d860618669 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Dispatch } from 'redux'; +import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { Filter, esQuery } from '../../../../../../../src/plugins/data/public'; import { useFetchIndexPatterns } from '../../../alerts/containers/detection_engine/rules/fetch_index_patterns'; import { StatefulEventsViewer } from '../../../common/components/events_viewer'; @@ -27,16 +28,10 @@ import { updateAlertStatusAction } from './actions'; import { getAlertActions, requiredFieldsForActions, - alertsClosedFilters, alertsDefaultModel, - alertsOpenFilters, + buildAlertStatusFilter, } from './default_config'; -import { - FILTER_CLOSED, - FILTER_OPEN, - AlertFilterOption, - AlertsTableFilterGroup, -} from './alerts_filter_group'; +import { FILTER_OPEN, AlertsTableFilterGroup } from './alerts_filter_group'; import { AlertsUtilityBar } from './alerts_utility_bar'; import * as i18n from './translations'; import { @@ -92,33 +87,36 @@ export const AlertsTableComponent: React.FC = ({ const apolloClient = useApolloClient(); const [showClearSelectionAction, setShowClearSelectionAction] = useState(false); - const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); + const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); const [{ browserFields, indexPatterns }] = useFetchIndexPatterns( signalsIndex !== '' ? [signalsIndex] : [] ); const kibana = useKibana(); const [, dispatchToaster] = useStateToaster(); - const getGlobalQuery = useCallback(() => { - if (browserFields != null && indexPatterns != null) { - return combineQueries({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - dataProviders: [], - indexPattern: indexPatterns, - browserFields, - filters: isEmpty(defaultFilters) - ? globalFilters - : [...(defaultFilters ?? []), ...globalFilters], - kqlQuery: globalQuery, - kqlMode: globalQuery.language, - start: from, - end: to, - isEventViewer: true, - }); - } - return null; + const getGlobalQuery = useCallback( + (customFilters: Filter[]) => { + if (browserFields != null && indexPatterns != null) { + return combineQueries({ + config: esQuery.getEsQueryConfig(kibana.services.uiSettings), + dataProviders: [], + indexPattern: indexPatterns, + browserFields, + filters: isEmpty(defaultFilters) + ? [...globalFilters, ...customFilters] + : [...(defaultFilters ?? []), ...globalFilters, ...customFilters], + kqlQuery: globalQuery, + kqlMode: globalQuery.language, + start: from, + end: to, + isEventViewer: true, + }); + } + return null; + }, // eslint-disable-next-line react-hooks/exhaustive-deps - }, [browserFields, globalFilters, globalQuery, indexPatterns, kibana, to, from]); + [browserFields, globalFilters, globalQuery, indexPatterns, kibana, to, from] + ); // Callback for creating a new timeline -- utilized by row/batch actions const createTimelineCallback = useCallback( @@ -157,21 +155,36 @@ export const AlertsTableComponent: React.FC = ({ ); const onAlertStatusUpdateSuccess = useCallback( - (count: number, status: string) => { - const title = - status === 'closed' - ? i18n.CLOSED_ALERT_SUCCESS_TOAST(count) - : i18n.OPENED_ALERT_SUCCESS_TOAST(count); - + (count: number, status: Status) => { + let title: string; + switch (status) { + case 'closed': + title = i18n.CLOSED_ALERT_SUCCESS_TOAST(count); + break; + case 'open': + title = i18n.OPENED_ALERT_SUCCESS_TOAST(count); + break; + case 'in-progress': + title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(count); + } displaySuccessToast(title, dispatchToaster); }, [dispatchToaster] ); const onAlertStatusUpdateFailure = useCallback( - (status: string, error: Error) => { - const title = - status === 'closed' ? i18n.CLOSED_ALERT_FAILED_TOAST : i18n.OPENED_ALERT_FAILED_TOAST; + (status: Status, error: Error) => { + let title: string; + switch (status) { + case 'closed': + title = i18n.CLOSED_ALERT_FAILED_TOAST; + break; + case 'open': + title = i18n.OPENED_ALERT_FAILED_TOAST; + break; + case 'in-progress': + title = i18n.IN_PROGRESS_ALERT_FAILED_TOAST; + } displayErrorToast(title, [error.message], dispatchToaster); }, [dispatchToaster] @@ -188,7 +201,7 @@ export const AlertsTableComponent: React.FC = ({ // Callback for when open/closed filter changes const onFilterGroupChangedCallback = useCallback( - (newFilterGroup: AlertFilterOption) => { + (newFilterGroup: Status) => { clearEventsLoading!({ id: ALERTS_TABLE_TIMELINE_ID }); clearEventsDeleted!({ id: ALERTS_TABLE_TIMELINE_ID }); clearSelected!({ id: ALERTS_TABLE_TIMELINE_ID }); @@ -213,11 +226,18 @@ export const AlertsTableComponent: React.FC = ({ }, [setSelectAll, setShowClearSelectionAction]); const updateAlertsStatusCallback: UpdateAlertsStatusCallback = useCallback( - async (refetchQuery: inputsModel.Refetch, { status }: UpdateAlertsStatusProps) => { + async ( + refetchQuery: inputsModel.Refetch, + { status, selectedStatus }: UpdateAlertsStatusProps + ) => { + const currentStatusFilter = buildAlertStatusFilter(status); await updateAlertStatusAction({ - query: showClearSelectionAction ? getGlobalQuery()?.filterQuery : undefined, + query: showClearSelectionAction + ? getGlobalQuery(currentStatusFilter)?.filterQuery + : undefined, alertIds: Object.keys(selectedEventIds), status, + selectedStatus, setEventsDeleted: setEventsDeletedCallback, setEventsLoading: setEventsLoadingCallback, onAlertStatusUpdateSuccess, @@ -245,7 +265,7 @@ export const AlertsTableComponent: React.FC = ({ areEventsLoading={loadingEventIds.length > 0} clearSelection={clearSelectionCallback} hasIndexWrite={hasIndexWrite} - isFilteredToOpen={filterGroup === FILTER_OPEN} + currentFilter={filterGroup} selectAll={selectAllCallback} selectedEventIds={selectedEventIds} showClearSelection={showClearSelectionAction} @@ -277,7 +297,7 @@ export const AlertsTableComponent: React.FC = ({ createTimeline: createTimelineCallback, setEventsLoading: setEventsLoadingCallback, setEventsDeleted: setEventsDeletedCallback, - status: filterGroup === FILTER_OPEN ? FILTER_CLOSED : FILTER_OPEN, + status: filterGroup, updateTimelineIsLoading, onAlertStatusUpdateSuccess, onAlertStatusUpdateFailure, @@ -298,12 +318,9 @@ export const AlertsTableComponent: React.FC = ({ const defaultIndices = useMemo(() => [signalsIndex], [signalsIndex]); const defaultFiltersMemo = useMemo(() => { if (isEmpty(defaultFilters)) { - return filterGroup === FILTER_OPEN ? alertsOpenFilters : alertsClosedFilters; + return buildAlertStatusFilter(filterGroup); } else if (defaultFilters != null && !isEmpty(defaultFilters)) { - return [ - ...defaultFilters, - ...(filterGroup === FILTER_OPEN ? alertsOpenFilters : alertsClosedFilters), - ]; + return [...defaultFilters, ...buildAlertStatusFilter(filterGroup)]; } }, [defaultFilters, filterGroup]); const { initializeTimeline, setTimelineRowActions } = useManageTimeline(); diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/translations.ts index 07cc28681387d5..390d6a8a2dd8dc 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/translations.ts @@ -38,6 +38,13 @@ export const CLOSED_ALERTS = i18n.translate( } ); +export const IN_PROGRESS_ALERTS = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.inProgressAlertsTitle', + { + defaultMessage: 'In progress alerts', + } +); + export const LOADING_ALERTS = i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.loadingAlertsTitle', { @@ -101,6 +108,13 @@ export const ACTION_CLOSE_ALERT = i18n.translate( } ); +export const ACTION_IN_PROGRESS_ALERT = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.actions.inProgressAlertTitle', + { + defaultMessage: 'Mark in progress', + } +); + export const ACTION_INVESTIGATE_IN_TIMELINE = i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.actions.investigateInTimelineTitle', { @@ -122,6 +136,16 @@ export const OPENED_ALERT_SUCCESS_TOAST = (totalAlerts: number) => 'Successfully opened {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.', }); +export const IN_PROGRESS_ALERT_SUCCESS_TOAST = (totalAlerts: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.inProgressAlertSuccessToastMessage', + { + values: { totalAlerts }, + defaultMessage: + 'Successfully marked {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}} as in progress.', + } + ); + export const CLOSED_ALERT_FAILED_TOAST = i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.closedAlertFailedToastMessage', { @@ -135,3 +159,10 @@ export const OPENED_ALERT_FAILED_TOAST = i18n.translate( defaultMessage: 'Failed to open alert(s)', } ); + +export const IN_PROGRESS_ALERT_FAILED_TOAST = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.inProgressAlertFailedToastMessage', + { + defaultMessage: 'Failed to mark alert(s) as in progress', + } +); diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/types.ts b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/types.ts index ba342ae441857f..b127ff04eca46d 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/types.ts +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/types.ts @@ -6,6 +6,7 @@ import ApolloClient from 'apollo-client'; +import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { Ecs } from '../../../graphql/types'; import { TimelineModel } from '../../../timelines/store/timeline/model'; import { inputsModel } from '../../../common/store'; @@ -22,23 +23,30 @@ export interface SetEventsDeletedProps { export interface UpdateAlertsStatusProps { alertIds: string[]; - status: 'open' | 'closed'; + status: Status; + selectedStatus: Status; } export type UpdateAlertsStatusCallback = ( refetchQuery: inputsModel.Refetch, - { alertIds, status }: UpdateAlertsStatusProps + { alertIds, status, selectedStatus }: UpdateAlertsStatusProps ) => void; -export type UpdateAlertsStatus = ({ alertIds, status }: UpdateAlertsStatusProps) => void; + +export type UpdateAlertsStatus = ({ + alertIds, + status, + selectedStatus, +}: UpdateAlertsStatusProps) => void; export interface UpdateAlertStatusActionProps { query?: string; alertIds: string[]; - status: 'open' | 'closed'; + status: Status; + selectedStatus: Status; setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; - onAlertStatusUpdateSuccess: (count: number, status: string) => void; - onAlertStatusUpdateFailure: (status: string, error: Error) => void; + onAlertStatusUpdateSuccess: (count: number, status: Status) => void; + onAlertStatusUpdateFailure: (status: Status, error: Error) => void; } export interface SendAlertToTimelineActionProps { diff --git a/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/alerts/api.ts index ccf35c96718364..3fe676fe2c7d6f 100644 --- a/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/alerts/api.ts @@ -46,7 +46,7 @@ export const fetchQueryAlerts = async ({ * Update alert status by query * * @param query of alerts to update - * @param status to update to('open' / 'closed') + * @param status to update to('open' / 'closed' / 'in-progress') * @param signal AbortSignal for cancelling request * * @throws An error if response is not OK diff --git a/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/alerts/types.ts b/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/alerts/types.ts index b425cfd54a7fdb..2eb2145c6c34d6 100644 --- a/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/alerts/types.ts +++ b/x-pack/plugins/security_solution/public/alerts/containers/detection_engine/alerts/types.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; + export interface BasicSignals { signal: AbortSignal; } @@ -36,7 +38,7 @@ export interface AlertSearchResponse export interface UpdateAlertStatusProps { query: object; - status: 'open' | 'closed'; + status: Status; signal?: AbortSignal; // TODO: implement cancelling } diff --git a/x-pack/plugins/security_solution/public/common/components/link_icon/index.tsx b/x-pack/plugins/security_solution/public/common/components/link_icon/index.tsx index bc2b6264f5ee65..75d396fe384f81 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_icon/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_icon/index.tsx @@ -18,9 +18,9 @@ interface LinkProps { ariaLabel?: string; } -const Link = styled(({ iconSide, children, ...rest }) => {children})< - LinkProps ->` +export const Link = styled(({ iconSide, children, ...rest }) => ( + {children} +))` ${({ iconSide, theme }) => css` align-items: center; display: inline-flex; diff --git a/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx b/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx index 330e7f83b5b286..250ed75f134c13 100644 --- a/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx +++ b/x-pack/plugins/security_solution/public/common/components/utility_bar/utility_bar_action.tsx @@ -11,14 +11,14 @@ import { LinkIcon, LinkIconProps } from '../link_icon'; import { BarAction } from './styles'; const Popover = React.memo( - ({ children, color, iconSide, iconSize, iconType, popoverContent }) => { + ({ children, color, iconSide, iconSize, iconType, popoverContent, disabled, ownFocus }) => { const [popoverState, setPopoverState] = useState(false); const closePopover = useCallback(() => setPopoverState(false), [setPopoverState]); return ( ( iconSize={iconSize} iconType={iconType} onClick={() => setPopoverState(!popoverState)} + disabled={disabled} > {children} @@ -44,6 +45,7 @@ Popover.displayName = 'Popover'; export interface UtilityBarActionProps extends LinkIconProps { popoverContent?: (closePopover: () => void) => React.ReactNode; dataTestSubj?: string; + ownFocus?: boolean; } export const UtilityBarAction = React.memo( @@ -56,16 +58,19 @@ export const UtilityBarAction = React.memo( iconSide, iconSize, iconType, + ownFocus, onClick, popoverContent, }) => ( {popoverContent ? ( {children} diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/store/middleware.ts b/x-pack/plugins/security_solution/public/endpoint_alerts/store/middleware.ts index dd84b4fcff5bdd..cd7ed93d22d9f3 100644 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/endpoint_alerts/store/middleware.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { eventsIndexPattern } from '../../../common/endpoint/constants'; import { IIndexPattern } from '../../../../../../src/plugins/data/public'; import { AlertResultList, AlertDetails, AlertListState, } from '../../../common/endpoint_alerts/types'; -import { AlertConstants } from '../../../common/endpoint_alerts/alert_constants'; import { ImmutableMiddlewareFactory } from '../../common/store'; import { cloneHttpFetchQuery } from '../../common/utils/clone_http_fetch_query'; import { @@ -27,14 +27,11 @@ export const alertMiddlewareFactory: ImmutableMiddlewareFactory ) => { async function fetchIndexPatterns(): Promise { const { indexPatterns } = depsStart.data; - const eventsPattern: { indexPattern: string } = await coreStart.http.get( - `${AlertConstants.INDEX_PATTERN_ROUTE}/${AlertConstants.EVENT_DATASET}` - ); const fields = await indexPatterns.getFieldsForWildcard({ - pattern: eventsPattern.indexPattern, + pattern: eventsIndexPattern, }); const indexPattern: IIndexPattern = { - title: eventsPattern.indexPattern, + title: eventsIndexPattern, fields, }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx index a3862d4454c1d1..b191bfe4effeaf 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx @@ -12,12 +12,14 @@ import { EuiLink, EuiListGroup, EuiListGroupItem, + EuiIcon, + EuiText, } from '@elastic/eui'; import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { HostMetadata } from '../../../../../../common/endpoint/types'; -import { useHostSelector, useHostLogsUrl } from '../hooks'; +import { useHostSelector, useHostLogsUrl, useHostIngestUrl } from '../hooks'; import { policyResponseStatus, uiQueryParams } from '../../store/selectors'; import { POLICY_STATUS_TO_HEALTH_COLOR } from '../host_constants'; import { FormattedDateAndTime } from '../../../../../common/components/endpoint/formatted_date_time'; @@ -32,8 +34,19 @@ const HostIds = styled(EuiListGroupItem)` } `; +const LinkToExternalApp = styled.div` + margin-top: ${(props) => props.theme.eui.ruleMargins.marginMedium}; + .linkToAppIcon { + margin-right: ${(props) => props.theme.eui.ruleMargins.marginXSmall}; + } + .linkToAppPopoutIcon { + margin-left: ${(props) => props.theme.eui.ruleMargins.marginXSmall}; + } +`; + export const HostDetails = memo(({ details }: { details: HostMetadata }) => { - const { appId, appPath, url } = useHostLogsUrl(details.host.id); + const { url: logsUrl, appId: logsAppId, appPath: logsAppPath } = useHostLogsUrl(details.host.id); + const { url: ingestUrl, appId: ingestAppId, appPath: ingestAppPath } = useHostIngestUrl(); const queryParams = useHostSelector(uiQueryParams); const policyStatus = useHostSelector( policyResponseStatus @@ -80,7 +93,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { const policyStatusClickHandler = useNavigateByRouterEventHandler(policyResponseRoutePath); - const detailsResultsLower = useMemo(() => { + const detailsResultsPolicy = useMemo(() => { return [ { title: i18n.translate('xpack.securitySolution.endpoint.host.details.policy', { @@ -103,15 +116,21 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { href={policyResponseUri} onClick={policyStatusClickHandler} > - + + + ), }, + ]; + }, [details, policyResponseUri, policyStatus, policyStatusClickHandler]); + const detailsResultsLower = useMemo(() => { + return [ { title: i18n.translate('xpack.securitySolution.endpoint.host.details.ipAddress', { defaultMessage: 'IP Address', @@ -137,15 +156,8 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { description: details.agent.version, }, ]; - }, [ - details.agent.version, - details.endpoint.policy.applied.id, - details.host.hostname, - details.host.ip, - policyStatus, - policyResponseUri, - policyStatusClickHandler, - ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [details.agent.version, details.host.hostname, details.host.ip]); return ( <> @@ -154,26 +166,49 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { listItems={detailsResultsUpper} data-test-subj="hostDetailsUpperList" /> - + + + + + + + + + + - -

+ + + + -

+ ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts index ddba6d7344ce54..51aaea20df8431 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts @@ -6,14 +6,13 @@ import { useSelector } from 'react-redux'; import { useMemo } from 'react'; +import { useKibana } from '../../../../common/lib/kibana'; import { HostState } from '../types'; import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_GLOBAL_NAMESPACE, } from '../../../common/constants'; -import { useKibana } from '../../../../common/lib/kibana'; import { State } from '../../../../common/store'; - export function useHostSelector(selector: (state: HostState) => TSelected) { return useSelector(function (state: State) { return selector( @@ -37,3 +36,18 @@ export const useHostLogsUrl = (hostId: string): { url: string; appId: string; ap }; }, [hostId, services.application]); }; + +/** + * Returns an object that contains Ingest app and URL information + */ +export const useHostIngestUrl = (): { url: string; appId: string; appPath: string } => { + const { services } = useKibana(); + return useMemo(() => { + const appPath = `#/fleet`; + return { + url: `${services.application.getUrlForApp('ingestManager')}${appPath}`, + appId: 'ingestManager', + appPath, + }; + }, [services.application]); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx index db5196bfc4eb4b..e80856f35081bf 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_datasource.tsx @@ -37,9 +37,9 @@ export const ConfigureEndpointDatasource = memo {from === 'edit' ? ( void; - content: string; + content: string | JSX.Element; width?: number; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index a9ade7b34344ed..767164967a3f42 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -102,7 +102,15 @@ export const EventColumnView = React.memo( setPopover(false); }, []); - const button = ; + const button = ( + + ); + const onClickCb = useCallback((cb: () => void) => { cb(); closePopover(); diff --git a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/alerts.test.ts b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/alerts.test.ts index 37bf4bbfcbbb2c..fd785bca4aa246 100644 --- a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/alerts.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/alerts.test.ts @@ -11,7 +11,7 @@ import { } from '../../../../../../../src/core/server/mocks'; import { registerAlertRoutes } from '../routes'; import { alertingIndexGetQuerySchema } from '../../../../common/endpoint_alerts/schema/alert_index'; -import { createMockAgentService, createMockIndexPatternRetriever } from '../../mocks'; +import { createMockAgentService } from '../../mocks'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; @@ -29,7 +29,6 @@ describe('test alerts route', () => { endpointAppContextService = new EndpointAppContextService(); endpointAppContextService.start({ - indexPatternRetriever: createMockIndexPatternRetriever('events-endpoint-*'), agentService: createMockAgentService(), }); diff --git a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/details/index.ts b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/details/index.ts index a4b07df6b8f8b8..5bb3f969807df3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/details/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/details/index.ts @@ -5,6 +5,7 @@ */ import { GetResponse } from 'elasticsearch'; import { KibanaRequest, RequestHandler } from 'kibana/server'; +import { eventsIndexPattern } from '../../../../../common/endpoint/constants'; import { AlertEvent } from '../../../../../common/endpoint/types'; import { EndpointAppContext } from '../../../types'; import { AlertDetailsRequestParams } from '../../../../../common/endpoint_alerts/types'; @@ -27,17 +28,13 @@ export const alertDetailsHandlerWrapper = function ( id: alertId.id, })) as GetResponse; - const indexPattern = await endpointAppContext.service - .getIndexPatternRetriever() - .getEventIndexPattern(ctx); - const config = await endpointAppContext.config(); const pagination: AlertDetailsPagination = new AlertDetailsPagination( config, ctx, req.params, response, - indexPattern + eventsIndexPattern ); const currentHostInfo = await getHostData( diff --git a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/index_pattern.ts b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/index_pattern.ts deleted file mode 100644 index cb40be586560fd..00000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/index_pattern.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Logger, RequestHandler } from 'kibana/server'; -import { EndpointAppContext } from '../../types'; -import { IndexPatternGetParamsResult } from '../../../../common/endpoint_alerts/types'; - -export function handleIndexPattern( - log: Logger, - endpointAppContext: EndpointAppContext -): RequestHandler { - return async (context, req, res) => { - try { - const indexRetriever = endpointAppContext.service.getIndexPatternRetriever(); - return res.ok({ - body: { - indexPattern: await indexRetriever.getIndexPattern(context, req.params.datasetPath), - }, - }); - } catch (error) { - log.warn(error); - return res.notFound({ body: error }); - } - }; -} diff --git a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/list/index.ts b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/list/index.ts index 6eda863408b288..5122dd89bba165 100644 --- a/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/list/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/alerts/handlers/list/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { RequestHandler } from 'kibana/server'; +import { eventsIndexPattern } from '../../../../../common/endpoint/constants'; import { EndpointAppContext } from '../../../types'; import { searchESForAlerts } from '../lib'; import { getRequestData, mapToAlertResultList } from './lib'; @@ -18,14 +19,11 @@ export const alertListHandlerWrapper = function ( res ) => { try { - const indexPattern = await endpointAppContext.service - .getIndexPatternRetriever() - .getEventIndexPattern(ctx); const reqData = await getRequestData(req, endpointAppContext); const response = await searchESForAlerts( ctx.core.elasticsearch.legacy.client, reqData, - indexPattern + eventsIndexPattern ); const mappedBody = await mapToAlertResultList(ctx, endpointAppContext, reqData, response); return res.ok({ body: mappedBody }); diff --git a/x-pack/plugins/security_solution/server/endpoint/alerts/index_pattern.ts b/x-pack/plugins/security_solution/server/endpoint/alerts/index_pattern.ts deleted file mode 100644 index 391aedecdd0995..00000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/alerts/index_pattern.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { Logger, LoggerFactory, RequestHandlerContext } from 'kibana/server'; -import { AlertConstants } from '../../../common/endpoint_alerts/alert_constants'; -import { ESIndexPatternService } from '../../../../ingest_manager/server'; - -export interface IndexPatternRetriever { - getIndexPattern(ctx: RequestHandlerContext, datasetPath: string): Promise; - getEventIndexPattern(ctx: RequestHandlerContext): Promise; - getMetadataIndexPattern(ctx: RequestHandlerContext): Promise; - getPolicyResponseIndexPattern(ctx: RequestHandlerContext): Promise; -} - -/** - * This class is used to retrieve an index pattern. It should be used in the server side code whenever - * an index pattern is needed to query data within ES. The index pattern is constructed by the Ingest Manager - * based on the contents of the Endpoint Package in the Package Registry. - */ -export class IngestIndexPatternRetriever implements IndexPatternRetriever { - private static endpointPackageName = 'endpoint'; - private static metadataDataset = 'metadata'; - private static policyDataset = 'policy'; - private readonly log: Logger; - constructor(private readonly service: ESIndexPatternService, loggerFactory: LoggerFactory) { - this.log = loggerFactory.get('index-pattern-retriever'); - } - - /** - * Retrieves the index pattern for querying events within elasticsearch. - * - * @param ctx a RequestHandlerContext from a route handler - * @returns a string representing the index pattern (e.g. `events-endpoint-*`) - */ - async getEventIndexPattern(ctx: RequestHandlerContext) { - return this.getIndexPattern(ctx, AlertConstants.EVENT_DATASET); - } - - /** - * Retrieves the index pattern for querying endpoint metadata within elasticsearch. - * - * @param ctx a RequestHandlerContext from a route handler - * @returns a string representing the index pattern (e.g. `metrics-endpoint-*`) - */ - async getMetadataIndexPattern(ctx: RequestHandlerContext) { - return this.getIndexPattern(ctx, IngestIndexPatternRetriever.metadataDataset); - } - - /** - * Retrieves the index pattern for a specific dataset for querying endpoint data. - * - * @param ctx a RequestHandlerContext from a route handler - * @param datasetPath a string of the path being used for a dataset within the Endpoint Package - * (e.g. `events`, `metadata`) - * @returns a string representing the index pattern (e.g. `metrics-endpoint-*`) - */ - async getIndexPattern(ctx: RequestHandlerContext, datasetPath: string) { - try { - const pattern = await this.service.getESIndexPattern( - ctx.core.savedObjects.client, - IngestIndexPatternRetriever.endpointPackageName, - datasetPath - ); - - if (!pattern) { - const msg = `Unable to retrieve the index pattern for dataset: ${datasetPath}`; - this.log.warn(msg); - throw new Error(msg); - } - return pattern; - } catch (error) { - const errMsg = `Error occurred while retrieving pattern for: ${datasetPath} error: ${error}`; - this.log.warn(errMsg); - throw new Error(errMsg); - } - } - - async getPolicyResponseIndexPattern(ctx: RequestHandlerContext): Promise { - return this.getIndexPattern(ctx, IngestIndexPatternRetriever.policyDataset); - } -} diff --git a/x-pack/plugins/security_solution/server/endpoint/alerts/routes.ts b/x-pack/plugins/security_solution/server/endpoint/alerts/routes.ts index 07cca9e80d88b1..a0d0b562bdd0db 100644 --- a/x-pack/plugins/security_solution/server/endpoint/alerts/routes.ts +++ b/x-pack/plugins/security_solution/server/endpoint/alerts/routes.ts @@ -10,8 +10,6 @@ import { alertListHandlerWrapper } from './handlers/list'; import { alertDetailsHandlerWrapper } from './handlers/details'; import { alertDetailsReqSchema } from './handlers/details/schemas'; import { alertingIndexGetQuerySchema } from '../../../common/endpoint_alerts/schema/alert_index'; -import { indexPatternGetParamsSchema } from '../../../common/endpoint_alerts/schema/index_pattern'; -import { handleIndexPattern } from './handlers/index_pattern'; export const BASE_ALERTS_ROUTE = `${AlertConstants.BASE_API_URL}/alerts`; @@ -37,15 +35,4 @@ export function registerAlertRoutes(router: IRouter, endpointAppContext: Endpoin }, alertDetailsHandlerWrapper(endpointAppContext) ); - - const log = endpointAppContext.logFactory.get('index_pattern'); - - router.get( - { - path: `${AlertConstants.INDEX_PATTERN_ROUTE}/{datasetPath}`, - validate: { params: indexPatternGetParamsSchema }, - options: { authRequired: true }, - }, - handleIndexPattern(log, endpointAppContext) - ); } diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.test.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.test.ts index 943a9c22c6aaee..8cf2ada9907d33 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.test.ts @@ -8,7 +8,6 @@ import { EndpointAppContextService } from './endpoint_app_context_services'; describe('test endpoint app context services', () => { it('should throw error if start is not called', async () => { const endpointAppContextService = new EndpointAppContextService(); - expect(() => endpointAppContextService.getIndexPatternRetriever()).toThrow(Error); expect(() => endpointAppContextService.getAgentService()).toThrow(Error); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index 5d74c75ebca5c4..cb8c913a73b8e8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { IndexPatternRetriever } from './alerts/index_pattern'; import { AgentService } from '../../../ingest_manager/server'; /** @@ -11,14 +10,9 @@ import { AgentService } from '../../../ingest_manager/server'; * of the plugin lifecycle. And stop during the stop phase, if needed. */ export class EndpointAppContextService { - private indexPatternRetriever: IndexPatternRetriever | undefined; private agentService: AgentService | undefined; - public start(dependencies: { - indexPatternRetriever: IndexPatternRetriever; - agentService: AgentService; - }) { - this.indexPatternRetriever = dependencies.indexPatternRetriever; + public start(dependencies: { agentService: AgentService }) { this.agentService = dependencies.agentService; } @@ -30,11 +24,4 @@ export class EndpointAppContextService { } return this.agentService; } - - public getIndexPatternRetriever(): IndexPatternRetriever { - if (!this.indexPatternRetriever) { - throw new Error(`must call start on ${EndpointAppContextService.name} to call getter`); - } - return this.indexPatternRetriever; - } } diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index d1873cb18ce0a9..b10e9e4dc90e77 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -7,32 +7,6 @@ import { IScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; import { xpackMocks } from '../../../../mocks'; import { AgentService, IngestManagerStartContract } from '../../../ingest_manager/server'; -import { IndexPatternRetriever } from './alerts/index_pattern'; - -/** - * Creates a mock IndexPatternRetriever for use in tests. - * - * @param indexPattern a string index pattern to return when any of the mock's public methods are called. - * @returns the same string passed in via `indexPattern` - */ -export const createMockIndexPatternRetriever = (indexPattern: string): IndexPatternRetriever => { - const mockGetFunc = jest.fn().mockResolvedValue(indexPattern); - return { - getIndexPattern: mockGetFunc, - getEventIndexPattern: mockGetFunc, - getMetadataIndexPattern: mockGetFunc, - getPolicyResponseIndexPattern: mockGetFunc, - }; -}; - -export const MetadataIndexPattern = 'metrics-endpoint-*'; - -/** - * Creates a mock IndexPatternRetriever for use in tests that returns `metrics-endpoint-*` - */ -export const createMockMetadataIndexPatternRetriever = () => { - return createMockIndexPatternRetriever(MetadataIndexPattern); -}; /** * Creates a mock AgentService diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts index d351054ca2fd89..4037f1a7cbc464 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts @@ -8,6 +8,7 @@ import { IRouter, Logger, RequestHandlerContext } from 'kibana/server'; import { SearchResponse } from 'elasticsearch'; import { schema } from '@kbn/config-schema'; +import { metadataIndexPattern } from '../../../../common/endpoint/constants'; import { getESQueryHostMetadataByID, kibanaRequestToMetadataListESQuery } from './query_builders'; import { HostInfo, @@ -67,13 +68,10 @@ export function registerEndpointRoutes(router: IRouter, endpointAppContext: Endp }, async (context, req, res) => { try { - const index = await endpointAppContext.service - .getIndexPatternRetriever() - .getMetadataIndexPattern(context); const queryParams = await kibanaRequestToMetadataListESQuery( req, endpointAppContext, - index + metadataIndexPattern ); const response = (await context.core.elasticsearch.legacy.client.callAsCurrentUser( 'search', @@ -125,10 +123,7 @@ export async function getHostData( metadataRequestContext: MetadataRequestContext, id: string ): Promise { - const index = await metadataRequestContext.endpointAppContext.service - .getIndexPatternRetriever() - .getMetadataIndexPattern(metadataRequestContext.requestHandlerContext); - const query = getESQueryHostMetadataByID(id, index); + const query = getESQueryHostMetadataByID(id, metadataIndexPattern); const response = (await metadataRequestContext.requestHandlerContext.core.elasticsearch.legacy.client.callAsCurrentUser( 'search', query diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index 9b9d4a74e5970c..80626bbdb6e7fc 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -27,11 +27,7 @@ import { } from '../../../../common/endpoint/types'; import { SearchResponse } from 'elasticsearch'; import { registerEndpointRoutes } from './index'; -import { - createMockAgentService, - createMockMetadataIndexPatternRetriever, - createRouteHandlerContext, -} from '../../mocks'; +import { createMockAgentService, createRouteHandlerContext } from '../../mocks'; import { AgentService } from '../../../../../ingest_manager/server'; import Boom from 'boom'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; @@ -63,7 +59,6 @@ describe('test endpoint route', () => { mockAgentService = createMockAgentService(); endpointAppContextService = new EndpointAppContextService(); endpointAppContextService.start({ - indexPatternRetriever: createMockMetadataIndexPatternRetriever(), agentService: mockAgentService, }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts index 7fa5a8b13db3dd..9e9eaafd0f1dea 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts @@ -5,9 +5,9 @@ */ import { httpServerMock, loggingServiceMock } from '../../../../../../../src/core/server/mocks'; import { kibanaRequestToMetadataListESQuery, getESQueryHostMetadataByID } from './query_builders'; -import { MetadataIndexPattern } from '../../mocks'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; +import { metadataIndexPattern } from '../../../../common/endpoint/constants'; describe('query builder', () => { describe('MetadataListESQuery', () => { @@ -22,7 +22,7 @@ describe('query builder', () => { service: new EndpointAppContextService(), config: () => Promise.resolve(createMockConfig()), }, - MetadataIndexPattern + metadataIndexPattern ); expect(query).toEqual({ body: { @@ -54,7 +54,7 @@ describe('query builder', () => { }, from: 0, size: 10, - index: MetadataIndexPattern, + index: metadataIndexPattern, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as Record); }); @@ -74,7 +74,7 @@ describe('query builder', () => { service: new EndpointAppContextService(), config: () => Promise.resolve(createMockConfig()), }, - MetadataIndexPattern + metadataIndexPattern ); expect(query).toEqual({ body: { @@ -119,7 +119,7 @@ describe('query builder', () => { }, from: 0, size: 10, - index: MetadataIndexPattern, + index: metadataIndexPattern, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as Record); }); @@ -128,7 +128,7 @@ describe('query builder', () => { describe('MetadataGetQuery', () => { it('searches for the correct ID', () => { const mockID = 'AABBCCDD-0011-2233-AA44-DEADBEEF8899'; - const query = getESQueryHostMetadataByID(mockID, MetadataIndexPattern); + const query = getESQueryHostMetadataByID(mockID, metadataIndexPattern); expect(query).toEqual({ body: { @@ -136,7 +136,7 @@ describe('query builder', () => { sort: [{ 'event.created': { order: 'desc' } }], size: 1, }, - index: MetadataIndexPattern, + index: metadataIndexPattern, }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts index 25bf2c45aa4218..2b94fe3576e2dc 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts @@ -4,11 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { EndpointAppContextService } from '../../endpoint_app_context_services'; -import { - createMockAgentService, - createMockIndexPatternRetriever, - createRouteHandlerContext, -} from '../../mocks'; +import { createMockAgentService, createRouteHandlerContext } from '../../mocks'; import { getHostPolicyResponseHandler } from './handlers'; import { IScopedClusterClient, @@ -41,7 +37,6 @@ describe('test policy response handler', () => { endpointAppContextService = new EndpointAppContextService(); mockAgentService = createMockAgentService(); endpointAppContextService.start({ - indexPatternRetriever: createMockIndexPatternRetriever('metrics-endpoint-policy-*'), agentService: mockAgentService, }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts index beb76a8051c357..fd685efb94aaa5 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts @@ -5,6 +5,7 @@ */ import { RequestHandler } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; +import { policyIndexPattern } from '../../../../common/endpoint/constants'; import { GetPolicyResponseSchema } from '../../../../common/endpoint/schema/policy'; import { EndpointAppContext } from '../../types'; import { getPolicyResponseByHostId } from './service'; @@ -14,12 +15,8 @@ export const getHostPolicyResponseHandler = function ( ): RequestHandler, undefined> { return async (context, request, response) => { try { - const index = await endpointAppContext.service - .getIndexPatternRetriever() - .getPolicyResponseIndexPattern(context); - const doc = await getPolicyResponseByHostId( - index, + policyIndexPattern, request.query.hostId, context.core.elasticsearch.legacy.client ); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts index 04171dd8137e54..7bfe101b920fb3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts @@ -7,6 +7,7 @@ import { TypeOf } from '@kbn/config-schema'; import { RequestHandler, Logger } from 'kibana/server'; import { validateAlerts } from '../../../../common/endpoint/schema/resolver'; +import { eventsIndexPattern } from '../../../../common/endpoint/constants'; import { Fetcher } from './utils/fetch'; import { EndpointAppContext } from '../../types'; @@ -20,11 +21,9 @@ export function handleAlerts( query: { alerts, afterAlert, legacyEndpointID: endpointID }, } = req; try { - const indexRetriever = endpointAppContext.service.getIndexPatternRetriever(); const client = context.core.elasticsearch.legacy.client; - const indexPattern = await indexRetriever.getEventIndexPattern(context); - const fetcher = new Fetcher(client, id, indexPattern, endpointID); + const fetcher = new Fetcher(client, id, eventsIndexPattern, endpointID); return res.ok({ body: await fetcher.alerts(alerts, afterAlert), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/ancestry.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/ancestry.ts index af3fe8d434cbc2..aa040638045b2c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/ancestry.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/ancestry.ts @@ -6,6 +6,7 @@ import { RequestHandler, Logger } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; +import { eventsIndexPattern } from '../../../../common/endpoint/constants'; import { validateAncestry } from '../../../../common/endpoint/schema/resolver'; import { Fetcher } from './utils/fetch'; import { EndpointAppContext } from '../../types'; @@ -20,12 +21,9 @@ export function handleAncestry( query: { ancestors, legacyEndpointID: endpointID }, } = req; try { - const indexRetriever = endpointAppContext.service.getIndexPatternRetriever(); - const client = context.core.elasticsearch.legacy.client; - const indexPattern = await indexRetriever.getEventIndexPattern(context); - const fetcher = new Fetcher(client, id, indexPattern, endpointID); + const fetcher = new Fetcher(client, id, eventsIndexPattern, endpointID); const ancestorInfo = await fetcher.ancestors(ancestors); return res.ok({ diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/children.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/children.ts index a8156e9729579e..83aed602c97a33 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/children.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/children.ts @@ -6,6 +6,7 @@ import { RequestHandler, Logger } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; +import { eventsIndexPattern } from '../../../../common/endpoint/constants'; import { validateChildren } from '../../../../common/endpoint/schema/resolver'; import { Fetcher } from './utils/fetch'; import { EndpointAppContext } from '../../types'; @@ -20,11 +21,8 @@ export function handleChildren( query: { children, generations, afterChild, legacyEndpointID: endpointID }, } = req; try { - const indexRetriever = endpointAppContext.service.getIndexPatternRetriever(); - const indexPattern = await indexRetriever.getEventIndexPattern(context); - const client = context.core.elasticsearch.legacy.client; - const fetcher = new Fetcher(client, id, indexPattern, endpointID); + const fetcher = new Fetcher(client, id, eventsIndexPattern, endpointID); return res.ok({ body: await fetcher.children(children, generations, afterChild), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts index f739f9a1ca2e6a..5018a265cd12d8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/events.ts @@ -6,6 +6,7 @@ import { TypeOf } from '@kbn/config-schema'; import { RequestHandler, Logger } from 'kibana/server'; +import { eventsIndexPattern } from '../../../../common/endpoint/constants'; import { validateEvents } from '../../../../common/endpoint/schema/resolver'; import { Fetcher } from './utils/fetch'; import { EndpointAppContext } from '../../types'; @@ -20,11 +21,9 @@ export function handleEvents( query: { events, afterEvent, legacyEndpointID: endpointID }, } = req; try { - const indexRetriever = endpointAppContext.service.getIndexPatternRetriever(); const client = context.core.elasticsearch.legacy.client; - const indexPattern = await indexRetriever.getEventIndexPattern(context); - const fetcher = new Fetcher(client, id, indexPattern, endpointID); + const fetcher = new Fetcher(client, id, eventsIndexPattern, endpointID); return res.ok({ body: await fetcher.events(events, afterEvent), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts index baad56c74b8a86..cad6d948358084 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts @@ -6,6 +6,7 @@ import { RequestHandler, Logger } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; +import { eventsIndexPattern } from '../../../../common/endpoint/constants'; import { validateTree } from '../../../../common/endpoint/schema/resolver'; import { Fetcher } from './utils/fetch'; import { Tree } from './utils/tree'; @@ -32,10 +33,8 @@ export function handleTree( } = req; try { const client = context.core.elasticsearch.legacy.client; - const indexRetriever = endpointAppContext.service.getIndexPatternRetriever(); - const indexPattern = await indexRetriever.getEventIndexPattern(context); - const fetcher = new Fetcher(client, id, indexPattern, endpointID); + const fetcher = new Fetcher(client, id, eventsIndexPattern, endpointID); const [childrenNodes, ancestry, relatedEvents, relatedAlerts] = await Promise.all([ fetcher.children(children, generations, afterChild), diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index ebb2310ac53104..9fe7307e8cb6da 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -43,7 +43,6 @@ import { registerAlertRoutes } from './endpoint/alerts/routes'; import { registerPolicyRoutes } from './endpoint/routes/policy'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; -import { IngestIndexPatternRetriever } from './endpoint/alerts/index_pattern'; export interface SetupPlugins { alerts: AlertingSetup; @@ -219,10 +218,6 @@ export class Plugin implements IPlugin - - - - - } - isInvalid={errors.index.length > 0 && index !== undefined} - error={errors.index} - helpText={ - - } - > - 0 && index !== undefined} - noSuggestions={!indexOptions.length} - options={indexOptions} - data-test-subj="thresholdIndexesComboBox" - selectedOptions={(index || []).map((anIndex: string) => { - return { - label: anIndex, - value: anIndex, - }; - })} - onChange={async (selected: EuiComboBoxOptionOption[]) => { - setAlertParams( - 'index', - selected.map((aSelected) => aSelected.value) - ); - const indices = selected.map((s) => s.value as string); + + } + isInvalid={errors.index.length > 0 && index !== undefined} + error={errors.index} + helpText={ + + } + > + 0 && index !== undefined} + noSuggestions={!indexOptions.length} + options={indexOptions} + data-test-subj="thresholdIndexesComboBox" + selectedOptions={(index || []).map((anIndex: string) => { + return { + label: anIndex, + value: anIndex, + }; + })} + onChange={async (selected: EuiComboBoxOptionOption[]) => { + setAlertParams( + 'index', + selected.map((aSelected) => aSelected.value) + ); + const indices = selected.map((s) => s.value as string); - // reset time field and expression fields if indices are deleted - if (indices.length === 0) { - setTimeFieldOptions([firstFieldOption]); - setAlertProperty('params', { - ...alertParams, - index: indices, - aggType: DEFAULT_VALUES.AGGREGATION_TYPE, - termSize: DEFAULT_VALUES.TERM_SIZE, - thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, - timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE, - timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT, - groupBy: DEFAULT_VALUES.GROUP_BY, - threshold: DEFAULT_VALUES.THRESHOLD, - timeField: '', - }); - return; - } - const currentEsFields = await getFields(http, indices); - const timeFields = getTimeFieldOptions(currentEsFields as any); + // reset time field and expression fields if indices are deleted + if (indices.length === 0) { + setTimeFieldOptions([firstFieldOption]); + setAlertProperty('params', { + ...alertParams, + index: indices, + aggType: DEFAULT_VALUES.AGGREGATION_TYPE, + termSize: DEFAULT_VALUES.TERM_SIZE, + thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, + timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT, + groupBy: DEFAULT_VALUES.GROUP_BY, + threshold: DEFAULT_VALUES.THRESHOLD, + timeField: '', + }); + return; + } + const currentEsFields = await getFields(http, indices); + const timeFields = getTimeFieldOptions(currentEsFields as any); - setEsFields(currentEsFields); - setTimeFieldOptions([firstFieldOption, ...timeFields]); - }} - onSearchChange={async (search) => { - setIsIndiciesLoading(true); - setIndexOptions(await getIndexOptions(http, search, indexPatterns)); - setIsIndiciesLoading(false); - }} - onBlur={() => { - if (!index) { - setAlertParams('index', []); - } - }} - /> - - - - + setEsFields(currentEsFields); + setTimeFieldOptions([firstFieldOption, ...timeFields]); + }} + onSearchChange={async (search) => { + setIsIndiciesLoading(true); + setIndexOptions(await getIndexOptions(http, search, indexPatterns)); + setIsIndiciesLoading(false); + }} + onBlur={() => { + if (!index) { + setAlertParams('index', []); } - isInvalid={errors.timeField.length > 0 && timeField !== undefined} - error={errors.timeField} - > - 0 && timeField !== undefined} - fullWidth - name="thresholdTimeField" - data-test-subj="thresholdAlertTimeFieldSelect" - value={timeField} - onChange={(e) => { - setAlertParams('timeField', e.target.value); - }} - onBlur={() => { - if (timeField === undefined) { - setAlertParams('timeField', ''); - } - }} - /> - - - - + }} + /> + + + } + isInvalid={errors.timeField.length > 0 && timeField !== undefined} + error={errors.timeField} + > + 0 && timeField !== undefined} + fullWidth + name="thresholdTimeField" + data-test-subj="thresholdAlertTimeFieldSelect" + value={timeField} + onChange={(e) => { + setAlertParams('timeField', e.target.value); + }} + onBlur={() => { + if (timeField === undefined) { + setAlertParams('timeField', ''); + } + }} + /> + ); - const firstSetOfSteps = [ - { - title: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.selectIndex', { - defaultMessage: 'Select an index', - }), - children: ( - <> - - - 0 ? index.join(' ') : firstFieldOption.text} - isActive={indexPopoverOpen} - onClick={() => { - setIndexPopoverOpen(true); - }} - color={index && index.length > 0 && timeField !== '' ? 'secondary' : 'danger'} - /> - } - isOpen={indexPopoverOpen} - closePopover={closeIndexPopover} - ownFocus - withTitle - anchorPosition="downLeft" - zIndex={8000} - > -
- - - - {i18n.translate( - 'xpack.triggersActionsUI.sections.alertAdd.threshold.indexButtonLabel', - { - defaultMessage: 'index', - } - )} - - - - - - - - {indexPopover} -
-
-
-
- - - - setAlertParams('aggType', selectedAggType) - } - /> - - {aggType && builtInAggregationTypes[aggType].fieldRequired ? ( - - - setAlertParams('aggField', selectedAggField) - } - /> - - ) : null} - - - - - setAlertParams('groupBy', selectedGroupBy) - } - onChangeSelectedTermField={(selectedTermField) => - setAlertParams('termField', selectedTermField) - } - onChangeSelectedTermSize={(selectedTermSize) => - setAlertParams('termSize', selectedTermSize) - } - /> - - - - ), - }, - { - title: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.conditionPrompt', { - defaultMessage: 'Define the condition', - }), - children: ( - <> - - - - setAlertParams('threshold', selectedThresholds) - } - onChangeSelectedThresholdComparator={(selectedThresholdComparator) => - setAlertParams('thresholdComparator', selectedThresholdComparator) - } - /> - - - - setAlertParams('timeWindowSize', selectedWindowSize) - } - onChangeWindowUnit={(selectedWindowUnit: any) => - setAlertParams('timeWindowUnit', selectedWindowUnit) - } - /> - - - - ), - }, - ]; + const renderIndices = (indices: string[]) => { + const rows = indices.map((s: string, i: number) => { + return ( +

+ {s} + {i < indices.length - 1 ? ',' : null} +

+ ); + }); + return
{rows}
; + }; return ( @@ -433,7 +282,145 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent ) : null} - + +
+ +
+
+ + 0 ? renderIndices(index) : firstFieldOption.text} + isActive={indexPopoverOpen} + onClick={() => { + setIndexPopoverOpen(true); + }} + isInvalid={!(index && index.length > 0 && timeField !== '')} + /> + } + isOpen={indexPopoverOpen} + closePopover={closeIndexPopover} + ownFocus + withTitle + anchorPosition="downLeft" + zIndex={8000} + display="block" + > +
+ + + + {i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.indexButtonLabel', + { + defaultMessage: 'index', + } + )} + + + + + + + + {indexPopover} +
+
+ + setAlertParams('aggType', selectedAggType) + } + /> + {aggType && builtInAggregationTypes[aggType].fieldRequired ? ( + + setAlertParams('aggField', selectedAggField) + } + /> + ) : null} + setAlertParams('groupBy', selectedGroupBy)} + onChangeSelectedTermField={(selectedTermField) => + setAlertParams('termField', selectedTermField) + } + onChangeSelectedTermSize={(selectedTermSize) => + setAlertParams('termSize', selectedTermSize) + } + /> + + +
+ +
+
+ + + setAlertParams('threshold', selectedThresholds) + } + onChangeSelectedThresholdComparator={(selectedThresholdComparator) => + setAlertParams('thresholdComparator', selectedThresholdComparator) + } + /> + + setAlertParams('timeWindowSize', selectedWindowSize) + } + onChangeWindowUnit={(selectedWindowUnit: any) => + setAlertParams('timeWindowUnit', selectedWindowUnit) + } + /> +
{canShowVizualization ? ( diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/for_the_last.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/for_the_last.tsx index f2b10f05ad0249..08339b509d5fdc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/for_the_last.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/for_the_last.tsx @@ -41,11 +41,13 @@ interface ForLastExpressionProps { | 'rightCenter' | 'rightUp' | 'rightDown'; + display?: 'fullWidth' | 'inline'; } export const ForLastExpression = ({ timeWindowSize, timeWindowUnit = 's', + display = 'inline', errors, onChangeWindowSize, onChangeWindowUnit, @@ -71,7 +73,8 @@ export const ForLastExpression = ({ onClick={() => { setAlertDurationPopoverOpen(true); }} - color={timeWindowSize ? 'secondary' : 'danger'} + display={display === 'inline' ? 'inline' : 'columns'} + isInvalid={!timeWindowSize} /> } isOpen={alertDurationPopoverOpen} @@ -79,6 +82,7 @@ export const ForLastExpression = ({ setAlertDurationPopoverOpen(false); }} ownFocus + display={display === 'fullWidth' ? 'block' : 'inlineBlock'} withTitle anchorPosition={popupPosition ?? 'downLeft'} > diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx index 8514ac3571073e..6af103be96e13e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx @@ -46,6 +46,7 @@ interface GroupByExpressionProps { | 'rightCenter' | 'rightUp' | 'rightDown'; + display?: 'fullWidth' | 'inline'; } export const GroupByExpression = ({ @@ -54,6 +55,7 @@ export const GroupByExpression = ({ onChangeSelectedTermSize, onChangeSelectedTermField, onChangeSelectedGroupBy, + display = 'inline', fields, termSize, termField, @@ -102,7 +104,8 @@ export const GroupByExpression = ({ onClick={() => { setGroupByPopoverOpen(true); }} - color={groupBy === 'all' || (termSize && termField) ? 'secondary' : 'danger'} + display={display === 'inline' ? 'inline' : 'columns'} + isInvalid={!(groupBy === 'all' || (termSize && termField))} /> } isOpen={groupByPopoverOpen} @@ -111,6 +114,7 @@ export const GroupByExpression = ({ }} ownFocus withTitle + display={display === 'fullWidth' ? 'block' : 'inlineBlock'} anchorPosition={popupPosition ?? 'downRight'} >
diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/of.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/of.tsx index c75157a182210e..9cea1d38122743 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/of.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/of.tsx @@ -43,6 +43,7 @@ interface OfExpressionProps { | 'rightCenter' | 'rightUp' | 'rightDown'; + display?: 'fullWidth' | 'inline'; } export const OfExpression = ({ @@ -51,6 +52,7 @@ export const OfExpression = ({ errors, onChangeSelectedAggField, fields, + display = 'inline', customAggTypesOptions, popupPosition, }: OfExpressionProps) => { @@ -86,12 +88,13 @@ export const OfExpression = ({ defaultMessage: 'of', } )} + display={display === 'inline' ? 'inline' : 'columns'} value={aggField || firstFieldOption.text} isActive={aggFieldPopoverOpen || !aggField} onClick={() => { setAggFieldPopoverOpen(true); }} - color={aggField ? 'secondary' : 'danger'} + isInvalid={!aggField} /> } isOpen={aggFieldPopoverOpen} @@ -99,6 +102,7 @@ export const OfExpression = ({ setAggFieldPopoverOpen(false); }} withTitle + display={display === 'fullWidth' ? 'block' : 'inlineBlock'} anchorPosition={popupPosition ?? 'downRight'} zIndex={8000} > diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/threshold.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/threshold.tsx index 32e8781b122127..09acf4fe1ef68d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/threshold.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/threshold.tsx @@ -43,6 +43,7 @@ interface ThresholdExpressionProps { | 'rightCenter' | 'rightUp' | 'rightDown'; + display?: 'fullWidth' | 'inline'; } export const ThresholdExpression = ({ @@ -51,6 +52,7 @@ export const ThresholdExpression = ({ onChangeSelectedThresholdComparator, onChangeSelectedThreshold, customComparators, + display = 'inline', threshold = [], popupPosition, }: ThresholdExpressionProps) => { @@ -81,11 +83,12 @@ export const ThresholdExpression = ({ onClick={() => { setAlertThresholdPopoverOpen(true); }} - color={ + display={display === 'inline' ? 'inline' : 'columns'} + isInvalid={ (errors.threshold0 && errors.threshold0.length) || - (errors.threshold1 && errors.threshold1.length) - ? 'danger' - : 'secondary' + (errors.threshold1 && errors.threshold1.length) > 0 + ? true + : false } /> } @@ -95,6 +98,7 @@ export const ThresholdExpression = ({ }} ownFocus withTitle + display={display === 'fullWidth' ? 'block' : 'inlineBlock'} anchorPosition={popupPosition ?? 'downLeft'} >
diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/when.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/when.tsx index 9933cec833243f..18197b6f64e436 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/when.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/when.tsx @@ -29,12 +29,14 @@ interface WhenExpressionProps { | 'rightCenter' | 'rightUp' | 'rightDown'; + display?: 'fullWidth' | 'inline'; } export const WhenExpression = ({ aggType, customAggTypesOptions, onChangeSelectedAggType, + display = 'inline', popupPosition, }: WhenExpressionProps) => { const [aggTypePopoverOpen, setAggTypePopoverOpen] = useState(false); @@ -52,6 +54,7 @@ export const WhenExpression = ({ )} value={aggregationTypes[aggType].text} isActive={aggTypePopoverOpen} + display={display === 'inline' ? 'inline' : 'columns'} onClick={() => { setAggTypePopoverOpen(true); }} @@ -62,6 +65,7 @@ export const WhenExpression = ({ setAggTypePopoverOpen(false); }} ownFocus + display={display === 'fullWidth' ? 'block' : 'inlineBlock'} withTitle anchorPosition={popupPosition ?? 'downLeft'} > diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.ts index 3e37bac0a17af4..58344026079e79 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.ts @@ -12,7 +12,10 @@ import { UiActionsActionDefinition as ActionDefinition, } from '../../../../../src/plugins/ui_actions/public'; import { defaultState, transitions, selectors, State } from './dynamic_action_manager_state'; -import { StateContainer, createStateContainer } from '../../../../../src/plugins/kibana_utils'; +import { + StateContainer, + createStateContainer, +} from '../../../../../src/plugins/kibana_utils/common'; import { StartContract } from '../plugin'; import { SerializedAction, SerializedEvent } from './types'; diff --git a/x-pack/plugins/uptime/public/components/common/uptime_date_picker.tsx b/x-pack/plugins/uptime/public/components/common/uptime_date_picker.tsx index 4254004dba4e0a..1d0dcad73795bd 100644 --- a/x-pack/plugins/uptime/public/components/common/uptime_date_picker.tsx +++ b/x-pack/plugins/uptime/public/components/common/uptime_date_picker.tsx @@ -10,17 +10,6 @@ import { useUrlParams } from '../../hooks'; import { CLIENT_DEFAULTS } from '../../../common/constants'; import { UptimeRefreshContext, UptimeSettingsContext } from '../../contexts'; -// TODO: when EUI exports types for this, this should be replaced -interface SuperDateRangePickerRangeChangedEvent { - start: string; - end: string; -} - -interface SuperDateRangePickerRefreshChangedEvent { - isPaused: boolean; - refreshInterval?: number; -} - export interface CommonlyUsedRange { from: string; to: string; @@ -52,12 +41,12 @@ export const UptimeDatePicker = () => { commonlyUsedRanges={euiCommonlyUsedRanges} isPaused={autorefreshIsPaused} refreshInterval={autorefreshInterval} - onTimeChange={({ start, end }: SuperDateRangePickerRangeChangedEvent) => { + onTimeChange={({ start, end }) => { updateUrl({ dateRangeStart: start, dateRangeEnd: end }); refreshApp(); }} onRefresh={refreshApp} - onRefreshChange={({ isPaused, refreshInterval }: SuperDateRangePickerRefreshChangedEvent) => { + onRefreshChange={({ isPaused, refreshInterval }) => { updateUrl({ autorefreshInterval: refreshInterval === undefined ? autorefreshInterval : refreshInterval, diff --git a/x-pack/test/api_integration/apis/endpoint/alerts/index.ts b/x-pack/test/api_integration/apis/endpoint/alerts/index.ts index d0580c1fdca9ab..12c5857f9db398 100644 --- a/x-pack/test/api_integration/apis/endpoint/alerts/index.ts +++ b/x-pack/test/api_integration/apis/endpoint/alerts/index.ts @@ -6,6 +6,7 @@ import expect from '@kbn/expect/expect.js'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { AlertData } from '../../../../../plugins/security_solution/common/endpoint_alerts/types'; +import { eventsIndexPattern } from '../../../../../plugins/security_solution/common/endpoint/constants'; import { deleteEventsStream, deleteMetadataStream } from '../data_stream_helper'; /** @@ -75,7 +76,7 @@ export default function ({ getService }: FtrProviderContext) { await esArchiver.load('endpoint/alerts/api_feature', { useCreate: true }); await esArchiver.load('endpoint/alerts/host_api_feature', { useCreate: true }); const res = await es.search({ - index: 'events-endpoint-*', + index: eventsIndexPattern, body: ES_QUERY_MISSING, }); nullableEventId = res.hits.hits[0]._source.event.id; diff --git a/x-pack/test/api_integration/apis/endpoint/alerts/index_pattern.ts b/x-pack/test/api_integration/apis/endpoint/alerts/index_pattern.ts deleted file mode 100644 index ad9f4463c64196..00000000000000 --- a/x-pack/test/api_integration/apis/endpoint/alerts/index_pattern.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import expect from '@kbn/expect/expect.js'; -import { FtrProviderContext } from '../../../ftr_provider_context'; - -export default function ({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - - describe('Endpoint index pattern API', () => { - it('should retrieve the index pattern for events', async () => { - const { body } = await supertest.get('/api/endpoint/index_pattern/events').expect(200); - expect(body.indexPattern).to.eql('events-endpoint-*'); - }); - - it('should retrieve the index pattern for metadata', async () => { - const { body } = await supertest.get('/api/endpoint/index_pattern/metadata').expect(200); - expect(body.indexPattern).to.eql('metrics-endpoint.metadata-*'); - }); - - it('should retrieve the index pattern for policy', async () => { - const { body } = await supertest.get('/api/endpoint/index_pattern/policy').expect(200); - expect(body.indexPattern).to.eql('metrics-endpoint.policy-*'); - }); - - it('should not retrieve the index pattern for an invalid key', async () => { - await supertest.get('/api/endpoint/index_pattern/blah').expect(404); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/endpoint/data_stream_helper.ts b/x-pack/test/api_integration/apis/endpoint/data_stream_helper.ts index bb8fbd4f296f80..8ab08ff501f5bd 100644 --- a/x-pack/test/api_integration/apis/endpoint/data_stream_helper.ts +++ b/x-pack/test/api_integration/apis/endpoint/data_stream_helper.ts @@ -5,6 +5,11 @@ */ import { Client } from '@elastic/elasticsearch'; +import { + metadataIndexPattern, + eventsIndexPattern, + policyIndexPattern, +} from '../../../../plugins/security_solution/common/endpoint/constants'; export async function deleteDataStream(getService: (serviceName: 'es') => Client, index: string) { const client = getService('es'); @@ -20,13 +25,13 @@ export async function deleteDataStream(getService: (serviceName: 'es') => Client } export async function deleteMetadataStream(getService: (serviceName: 'es') => Client) { - await deleteDataStream(getService, 'metrics-endpoint.metadata-*'); + await deleteDataStream(getService, metadataIndexPattern); } export async function deleteEventsStream(getService: (serviceName: 'es') => Client) { - await deleteDataStream(getService, 'events-endpoint-*'); + await deleteDataStream(getService, eventsIndexPattern); } export async function deletePolicyStream(getService: (serviceName: 'es') => Client) { - await deleteDataStream(getService, 'metrics-endpoint.policy-*'); + await deleteDataStream(getService, policyIndexPattern); } diff --git a/x-pack/test/api_integration/apis/endpoint/index.ts b/x-pack/test/api_integration/apis/endpoint/index.ts index f437f9a7472fb5..d3090e113324f7 100644 --- a/x-pack/test/api_integration/apis/endpoint/index.ts +++ b/x-pack/test/api_integration/apis/endpoint/index.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { FtrProviderContext } from '../../ftr_provider_context'; export default function endpointAPIIntegrationTests({ @@ -16,7 +15,6 @@ export default function endpointAPIIntegrationTests({ before(async () => { await ingestManager.setup(); }); - loadTestFile(require.resolve('./alerts/index_pattern')); loadTestFile(require.resolve('./resolver')); loadTestFile(require.resolve('./metadata')); loadTestFile(require.resolve('./alerts')); diff --git a/x-pack/test/api_integration/apis/ingest_manager/agent_config.ts b/x-pack/test/api_integration/apis/ingest_manager/agent_config.ts index 7281e6a06a0645..caa29561a12565 100644 --- a/x-pack/test/api_integration/apis/ingest_manager/agent_config.ts +++ b/x-pack/test/api_integration/apis/ingest_manager/agent_config.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('ingest_manager_agent_configs', () => { describe('POST /api/ingest_manager/agent_configs', () => { @@ -36,5 +37,70 @@ export default function ({ getService }: FtrProviderContext) { .expect(400); }); }); + + describe('POST /api/ingest_manager/agent_configs/{agentConfigId}/copy', () => { + before(async () => { + await esArchiver.loadIfNeeded('fleet/agents'); + }); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + const TEST_CONFIG_ID = 'config1'; + + it('should work with valid values', async () => { + const { + body: { success, item }, + } = await supertest + .post(`/api/ingest_manager/agent_configs/${TEST_CONFIG_ID}/copy`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Copied config', + description: 'Test', + }) + .expect(200); + const { id, updated_at, ...newConfig } = item; + + expect(success).to.be(true); + expect(newConfig).to.eql({ + name: 'Copied config', + description: 'Test', + namespace: 'default', + monitoring_enabled: ['logs', 'metrics'], + revision: 1, + updated_by: 'elastic', + datasources: [], + }); + }); + + it('should return a 500 with invalid source config', async () => { + await supertest + .post(`/api/ingest_manager/agent_configs/INVALID_CONFIG_ID/copy`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Copied config', + description: '', + }) + .expect(500); + }); + + it('should return a 400 with invalid payload', async () => { + await supertest + .post(`/api/ingest_manager/agent_configs/${TEST_CONFIG_ID}/copy`) + .set('kbn-xsrf', 'xxxx') + .send({}) + .expect(400); + }); + + it('should return a 400 with invalid name', async () => { + await supertest + .post(`/api/ingest_manager/agent_configs/${TEST_CONFIG_ID}/copy`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: '', + }) + .expect(400); + }); + }); }); } diff --git a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts index 9a8511b4331ea2..219f2471f8e68b 100644 --- a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts +++ b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts @@ -25,9 +25,16 @@ export default function ({ getService }: FtrProviderContext) { .saveComposableIndexTemplate({ name, body: { - index_patterns: ['*'], + // We need to match the names of backing indices with this template + index_patterns: [name + '*'], template: { - settings: {}, + mappings: { + properties: { + '@timestamp': { + type: 'date', + }, + }, + }, }, data_stream: { timestamp_field: '@timestamp', diff --git a/x-pack/test/api_integration/apis/metrics_ui/metrics_alerting.ts b/x-pack/test/api_integration/apis/metrics_ui/metrics_alerting.ts index 7c94cb08b2727d..f0920e25291519 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/metrics_alerting.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/metrics_alerting.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect'; -import { getElasticsearchMetricQuery } from '../../../../plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor'; +import { getElasticsearchMetricQuery } from '../../../../plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query'; import { MetricExpressionParams } from '../../../../plugins/infra/server/lib/alerting/metric_threshold/types'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/endpoint_api_integration_no_ingest/apis/alerts/index.ts b/x-pack/test/endpoint_api_integration_no_ingest/apis/alerts/index.ts index 7e092ba2b85b55..5c14bc899451ef 100644 --- a/x-pack/test/endpoint_api_integration_no_ingest/apis/alerts/index.ts +++ b/x-pack/test/endpoint_api_integration_no_ingest/apis/alerts/index.ts @@ -20,7 +20,7 @@ export default function ({ getService }: FtrProviderContext) { await esArchiver.unload('endpoint/alerts/host_api_feature'); }); - it('should return a 500', async () => { + it('should not return data', async () => { await supertest.get('/api/endpoint/alerts').expect(500); }); }); diff --git a/x-pack/test/endpoint_api_integration_no_ingest/apis/alerts/index_pattern.ts b/x-pack/test/endpoint_api_integration_no_ingest/apis/alerts/index_pattern.ts deleted file mode 100644 index faea56961b5108..00000000000000 --- a/x-pack/test/endpoint_api_integration_no_ingest/apis/alerts/index_pattern.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function ({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - - describe('Endpoint index pattern API without ingest manager initialized', () => { - it('should not retrieve the index pattern for events', async () => { - await supertest.get('/api/endpoint/index_pattern/events').expect(404); - }); - }); -} diff --git a/x-pack/test/endpoint_api_integration_no_ingest/apis/index.ts b/x-pack/test/endpoint_api_integration_no_ingest/apis/index.ts index ae72a405930457..16e3c55bafe403 100644 --- a/x-pack/test/endpoint_api_integration_no_ingest/apis/index.ts +++ b/x-pack/test/endpoint_api_integration_no_ingest/apis/index.ts @@ -9,7 +9,6 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function endpointAPIIntegrationTests({ loadTestFile }: FtrProviderContext) { describe('Endpoint plugin', function () { this.tags('ciGroup7'); - loadTestFile(require.resolve('./alerts/index_pattern')); loadTestFile(require.resolve('./metadata')); loadTestFile(require.resolve('./alerts')); }); diff --git a/x-pack/test/endpoint_api_integration_no_ingest/apis/metadata.ts b/x-pack/test/endpoint_api_integration_no_ingest/apis/metadata.ts index 3802060e6fc0df..da77a9d3bba1cf 100644 --- a/x-pack/test/endpoint_api_integration_no_ingest/apis/metadata.ts +++ b/x-pack/test/endpoint_api_integration_no_ingest/apis/metadata.ts @@ -11,7 +11,7 @@ export default function ({ getService }: FtrProviderContext) { describe('test metadata api when ingest manager is not initialized', () => { before(async () => await esArchiver.load('endpoint/metadata/api_feature')); after(async () => await esArchiver.unload('endpoint/metadata/api_feature')); - it('metadata api should return a 500', async () => { + it('metadata api should not return results', async () => { await supertest.post('/api/endpoint/metadata').set('kbn-xsrf', 'xxx').send().expect(500); }); }); diff --git a/yarn.lock b/yarn.lock index 8c795a27e10af5..20089fdb830925 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5355,9 +5355,9 @@ "@types/node" "*" "@types/node@*", "@types/node@8.10.54", "@types/node@>=10.17.17 <10.20.0", "@types/node@>=8.9.0", "@types/node@^12.0.2": - version "10.17.17" - resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.17.tgz#7a183163a9e6ff720d86502db23ba4aade5999b8" - integrity sha512-gpNnRnZP3VWzzj5k3qrpRC6Rk3H/uclhAVo1aIvwzK5p5cOrs9yEyQ8H/HBsBY0u5rrWxXEiVPQ0dEB6pkjE8Q== + version "10.17.26" + resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.26.tgz#a8a119960bff16b823be4c617da028570779bcfd" + integrity sha512-myMwkO2Cr82kirHY8uknNRHEVtn0wV3DTQfkrjx17jmkstDRZ24gNUdl8AHXVyVclTYI/bNjgTPTAWvWLqXqkw== "@types/nodemailer@^6.2.1": version "6.2.1" @@ -16148,6 +16148,11 @@ graphql-tag@2.10.1, graphql-tag@^2.9.2: resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.1.tgz#10aa41f1cd8fae5373eaf11f1f67260a3cad5e02" integrity sha512-jApXqWBzNXQ8jYa/HLkZJaVw9jgwNqZkywa2zfFn16Iv1Zb7ELNHkJaXHR7Quvd5SIGsy6Ny7SUKATgnu05uEg== +graphql-tag@^2.10.3: + version "2.10.3" + resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.3.tgz#ea1baba5eb8fc6339e4c4cf049dabe522b0edf03" + integrity sha512-4FOv3ZKfA4WdOKJeHdz6B3F/vxBLSgmBcGeAFPf4n1F64ltJUvOOerNj0rsJxONQGdhUMynQIvd6LzB+1J5oKA== + graphql-toolkit@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/graphql-toolkit/-/graphql-toolkit-0.2.0.tgz#91364b69911d51bc915269a37963f4ea2d5f335c" @@ -16194,6 +16199,13 @@ graphql@^0.13.2: dependencies: iterall "^1.2.1" +graphql@^14.0.0: + version "14.6.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.6.0.tgz#57822297111e874ea12f5cd4419616930cd83e49" + integrity sha512-VKzfvHEKybTKjQVpTFrA5yUq2S9ihcZvfJAtsDBBCuV6wauPu1xl/f9ehgVf0FcEJJs4vz6ysb/ZMkGigQZseg== + dependencies: + iterall "^1.2.2" + graphviz@^0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/graphviz/-/graphviz-0.0.8.tgz#e599e40733ef80e1653bfe89a5f031ecf2aa4aaa" @@ -18731,6 +18743,11 @@ iterall@^1.1.3, iterall@^1.2.1: resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7" integrity sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA== +iterall@^1.2.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" + integrity sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg== + jest-changed-files@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.9.0.tgz#08d8c15eb79a7fa3fc98269bc14b451ee82f8039" @@ -29202,6 +29219,14 @@ supports-hyperlinks@^1.0.1: has-flag "^2.0.0" supports-color "^5.0.0" +supports-hyperlinks@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.1.0.tgz#f663df252af5f37c5d49bbd7eeefa9e0b9e59e47" + integrity sha512-zoE5/e+dnEijk6ASB6/qrK+oYdm2do1hjoLWrqUC/8WEIW1gbxFcKuBof7sW8ArN6e+AYvsE8HBGiVRWL/F5CA== + dependencies: + has-flag "^4.0.0" + supports-color "^7.0.0" + suricata-sid-db@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/suricata-sid-db/-/suricata-sid-db-1.0.2.tgz#96ceda4db117a9f1282c8f9d785285e5ccf342b1" @@ -29554,6 +29579,14 @@ term-size@^2.1.0: resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753" integrity sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw== +terminal-link@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" + integrity sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ== + dependencies: + ansi-escapes "^4.2.1" + supports-hyperlinks "^2.0.0" + terser-webpack-plugin@^1.2.4, terser-webpack-plugin@^1.4.3: version "1.4.4" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.4.tgz#2c63544347324baafa9a56baaddf1634c8abfc2f"