diff --git a/.ci/package-testing/Jenkinsfile b/.ci/package-testing/Jenkinsfile index b749c1fe2e9a..fec7dc9ea4cd 100644 --- a/.ci/package-testing/Jenkinsfile +++ b/.ci/package-testing/Jenkinsfile @@ -1,7 +1,7 @@ #!/bin/groovy library 'kibana-pipeline-library' kibanaLibrary.load() -kibanaPipeline(timeoutMinutes: 300) { +kibanaPipeline(timeoutMinutes: 120) { slackNotifications.onFailure { ciStats.trackBuild { workers.ci(ramDisk: false, name: "package-build", size: 'l', runErrorReporter: false) { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f2d674981301..5fcb619af657 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -252,6 +252,7 @@ /src/core/server/csp/ @elastic/kibana-security @elastic/kibana-core /src/plugins/security_oss/ @elastic/kibana-security /src/plugins/spaces_oss/ @elastic/kibana-security +/src/plugins/user_setup/ @elastic/kibana-security /test/security_functional/ @elastic/kibana-security /x-pack/plugins/spaces/ @elastic/kibana-security /x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security diff --git a/dev_docs/assets/pr_details.png b/dev_docs/assets/pr_details.png new file mode 100644 index 000000000000..2d7428ef141e Binary files /dev/null and b/dev_docs/assets/pr_details.png differ diff --git a/dev_docs/assets/pr_header.png b/dev_docs/assets/pr_header.png new file mode 100644 index 000000000000..3790c68f15fc Binary files /dev/null and b/dev_docs/assets/pr_header.png differ diff --git a/dev_docs/assets/pr_open.png b/dev_docs/assets/pr_open.png new file mode 100644 index 000000000000..970283868474 Binary files /dev/null and b/dev_docs/assets/pr_open.png differ diff --git a/dev_docs/tutorials/submit_a_pull_request.mdx b/dev_docs/tutorials/submit_a_pull_request.mdx new file mode 100644 index 000000000000..2be5973bb385 --- /dev/null +++ b/dev_docs/tutorials/submit_a_pull_request.mdx @@ -0,0 +1,85 @@ +--- +id: kibDevTutorialSubmitPullRequest +slug: /kibana-dev-docs/tutorial/submit-pull-request +title: Submitting a Kibana pull request +summary: Learn how to submit a Kibana pull request +date: 2021-06-24 +tags: ['kibana', 'onboarding', 'dev', 'tutorials', 'github', 'pr', 'pull request', 'ci'] +--- + +## Create and clone a fork of Kibana + +Kibana has hundreds of developers, some of whom are outside of Elastic, so we use a fork-based approach for creating branches and pull requests. + +To create and clone a fork: + +1. Login to [GitHub](https://github.com) +2. Navigate to the [Kibana repository](https://github.com/elastic/kibana) +3. Follow the [GitHub instructions](https://docs.github.com/en/get-started/quickstart/fork-a-repo) for forking and cloning repos + +## Create a branch + +After cloning your fork and navigating to the directory containing your fork: + +```bash +# Make sure you currently have the branch checked out off of which you'd like to work +git checkout master + +# Create a new branch +git checkout -b fix-typos-in-readme + +# Edit some code +vi README.md + +# Add/commit the change +git add README.md +git commit -m "Fixed all of the typos in the README" + +# Push the branch to your fork +git push -u origin fix-typos-in-readme +``` + +If this is a new branch, you will see a link in your terminal that points you directly to a page to create a pull request for that branch. + +## Create a pull request + +1. Navigate to your fork in Github +2. If you see your branch at the top of the screen with a `Compare & pull request` button, click that. Otherwise: + 1. Navigate to your branch + 2. Click Contribute, followed by `Open pull request` +3. Fill out the details that are relevant for your change in the pull request template + 1. If your pull request relates to an open issue, you can also reference that issue here, e.g. `Closes #12345` +4. [Elastic employees only] Add any teams/people that need to review your code under Reviewers. There's a good chance one or more teams will automatically be added based on which part of the codebase in which your changes were made. +5. [Elastic employees only] Add any relevant labels + 1. Versions: Add a label for each version of Kibana in which your change will ship. For example, `v8.0.0`, `v7.14.0` + 2. Features: Add labels for any relevant feature areas, e.g. `Feature:Development` + 3. Team: Most PRs should have at least one `Team:` label. Add labels for teams that should follow or are responsible for the pull request. + 4. Release Note: Add `release_note:skip` if this pull request should not automatically get added to release notes for Kibana + 5. Auto Backport: Add `auto-backport` if you'd like your pull request automatically backported to all labeled versions. +6. Submit the pull request. If it's not quite ready for review, it can also be submitted as a Draft pull request. + +![Screenshot of Compare and pull request header](../assets/pr_header.png) + +![Screenshot of opening a pull request from the branch page](../assets/pr_open.png) + +![Screenshot of pull request details](../assets/pr_details.png) + +## Sign the Contributor Agreement + +If this is your first pull request, a bot will post a comment asking you to sign our [CLA / Contributor Agreement](https://www.elastic.co/contributor-agreement). Your pull request won't be able to be merged until you've reviewed and signed the agreement. + +## Review Process + +At this point, your pull request will be reviewed, discussed, etc. Changes will likely be requested. For complex pull requests, this process could take several weeks. Please be patient and understand we hold our code base to a high standard. + +See [Pull request review guidelines](https://www.elastic.co/guide/en/kibana/master/pr-review.html) for our general philosophy for pull request reviews. + +## Updating your PR with upstream + +If your pull request hasn't been updated with the latest code from the upstream/target branch, e.g. `master`, in the last 48 hours, it won't be able to merge until it is updated. This is to help prevent problems that could occur by merging stale code into upstream, e.g. something new was recently merged that is incompatible with something in your pull request. + +As an alternative to using `git` to manually update your branch, you can leave a comment on your pull request with the text `@elasticmachine merge upstream`. This will automatically update your branch and kick off CI for it. + +## Re-triggering CI + +The easiest way to re-trigger CI is to simply update your branch (see above) with the latest code from upstream. This has the added benefit of ensuring that your branch is up-to-date and compatible. diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index b4be27eee5ed..eee92ba43372 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -256,6 +256,10 @@ In general this plugin provides: |The Usage Collection Service defines a set of APIs for other plugins to report the usage of their features. At the same time, it provides necessary the APIs for other services (i.e.: telemetry, monitoring, ...) to consume that usage data. +|{kib-repo}blob/{branch}/src/plugins/user_setup/README.md[userSetup] +|The plugin provides UI and APIs for the interactive setup mode. + + |{kib-repo}blob/{branch}/src/plugins/vis_default_editor/README.md[visDefaultEditor] |The default editor is used in most primary visualizations, e.x. Area, Data table, Pie, etc. It acts as a container for a particular visualization and options tabs. Contains the default "Data" tab in public/components/sidebar/data_tab.tsx. @@ -481,7 +485,7 @@ using the CURL scripts in the scripts folder. |{kib-repo}blob/{branch}/x-pack/plugins/metrics_entities/README.md[metricsEntities] |This is the metrics and entities plugin where you add can add transforms for your project and group those transforms into modules. You can also re-use existing transforms in your -modules as well. +newly created modules as well. |{kib-repo}blob/{branch}/x-pack/plugins/ml/readme.md[ml] diff --git a/docs/development/core/public/kibana-plugin-core-public.corestart.executioncontext.md b/docs/development/core/public/kibana-plugin-core-public.corestart.executioncontext.md new file mode 100644 index 000000000000..66c5f3efa2d8 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.corestart.executioncontext.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [CoreStart](./kibana-plugin-core-public.corestart.md) > [executionContext](./kibana-plugin-core-public.corestart.executioncontext.md) + +## CoreStart.executionContext property + +[ExecutionContextServiceStart](./kibana-plugin-core-public.executioncontextservicestart.md) + +Signature: + +```typescript +executionContext: ExecutionContextServiceStart; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.corestart.md b/docs/development/core/public/kibana-plugin-core-public.corestart.md index 6ad9adca53ef..df1929b1f20a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.corestart.md +++ b/docs/development/core/public/kibana-plugin-core-public.corestart.md @@ -20,6 +20,7 @@ export interface CoreStart | [chrome](./kibana-plugin-core-public.corestart.chrome.md) | ChromeStart | [ChromeStart](./kibana-plugin-core-public.chromestart.md) | | [deprecations](./kibana-plugin-core-public.corestart.deprecations.md) | DeprecationsServiceStart | [DeprecationsServiceStart](./kibana-plugin-core-public.deprecationsservicestart.md) | | [docLinks](./kibana-plugin-core-public.corestart.doclinks.md) | DocLinksStart | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | +| [executionContext](./kibana-plugin-core-public.corestart.executioncontext.md) | ExecutionContextServiceStart | [ExecutionContextServiceStart](./kibana-plugin-core-public.executioncontextservicestart.md) | | [fatalErrors](./kibana-plugin-core-public.corestart.fatalerrors.md) | FatalErrorsStart | [FatalErrorsStart](./kibana-plugin-core-public.fatalerrorsstart.md) | | [http](./kibana-plugin-core-public.corestart.http.md) | HttpStart | [HttpStart](./kibana-plugin-core-public.httpstart.md) | | [i18n](./kibana-plugin-core-public.corestart.i18n.md) | I18nStart | [I18nStart](./kibana-plugin-core-public.i18nstart.md) | diff --git a/docs/development/core/public/kibana-plugin-core-public.executioncontextservicestart.create.md b/docs/development/core/public/kibana-plugin-core-public.executioncontextservicestart.create.md new file mode 100644 index 000000000000..b36f8ade848e --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.executioncontextservicestart.create.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ExecutionContextServiceStart](./kibana-plugin-core-public.executioncontextservicestart.md) > [create](./kibana-plugin-core-public.executioncontextservicestart.create.md) + +## ExecutionContextServiceStart.create property + +Creates a context container carrying the meta-data of a runtime operation. Provided meta-data will be propagated to Kibana and Elasticsearch servers. + +```js +const context = executionContext.create(...); +http.fetch('/endpoint/', { context }); + +``` + +Signature: + +```typescript +create: (context: KibanaExecutionContext) => IExecutionContextContainer; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.executioncontextservicestart.md b/docs/development/core/public/kibana-plugin-core-public.executioncontextservicestart.md new file mode 100644 index 000000000000..d3eecf601ba9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.executioncontextservicestart.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ExecutionContextServiceStart](./kibana-plugin-core-public.executioncontextservicestart.md) + +## ExecutionContextServiceStart interface + + +Signature: + +```typescript +export interface ExecutionContextServiceStart +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [create](./kibana-plugin-core-public.executioncontextservicestart.create.md) | (context: KibanaExecutionContext) => IExecutionContextContainer | Creates a context container carrying the meta-data of a runtime operation. Provided meta-data will be propagated to Kibana and Elasticsearch servers. +```js +const context = executionContext.create(...); +http.fetch('/endpoint/', { context }); + +``` + | + diff --git a/docs/development/core/public/kibana-plugin-core-public.httpfetchoptions.context.md b/docs/development/core/public/kibana-plugin-core-public.httpfetchoptions.context.md new file mode 100644 index 000000000000..6c6ce3171aae --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.httpfetchoptions.context.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [HttpFetchOptions](./kibana-plugin-core-public.httpfetchoptions.md) > [context](./kibana-plugin-core-public.httpfetchoptions.context.md) + +## HttpFetchOptions.context property + +Signature: + +```typescript +context?: IExecutionContextContainer; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.httpfetchoptions.md b/docs/development/core/public/kibana-plugin-core-public.httpfetchoptions.md index 745020bb6071..020a94118901 100644 --- a/docs/development/core/public/kibana-plugin-core-public.httpfetchoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.httpfetchoptions.md @@ -18,6 +18,7 @@ export interface HttpFetchOptions extends HttpRequestInit | --- | --- | --- | | [asResponse](./kibana-plugin-core-public.httpfetchoptions.asresponse.md) | boolean | When true the return type of [HttpHandler](./kibana-plugin-core-public.httphandler.md) will be an [HttpResponse](./kibana-plugin-core-public.httpresponse.md) with detailed request and response information. When false, the return type will just be the parsed response body. Defaults to false. | | [asSystemRequest](./kibana-plugin-core-public.httpfetchoptions.assystemrequest.md) | boolean | Whether or not the request should include the "system request" header to differentiate an end user request from Kibana internal request. Can be read on the server-side using KibanaRequest\#isSystemRequest. Defaults to false. | +| [context](./kibana-plugin-core-public.httpfetchoptions.context.md) | IExecutionContextContainer | | | [headers](./kibana-plugin-core-public.httpfetchoptions.headers.md) | HttpHeadersInit | Headers to send with the request. See [HttpHeadersInit](./kibana-plugin-core-public.httpheadersinit.md). | | [prependBasePath](./kibana-plugin-core-public.httpfetchoptions.prependbasepath.md) | boolean | Whether or not the request should automatically prepend the basePath. Defaults to true. | | [query](./kibana-plugin-core-public.httpfetchoptions.query.md) | HttpFetchQuery | The query string for an HTTP request. See [HttpFetchQuery](./kibana-plugin-core-public.httpfetchquery.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.iexecutioncontextcontainer.md b/docs/development/core/public/kibana-plugin-core-public.iexecutioncontextcontainer.md new file mode 100644 index 000000000000..413b4aaf46b5 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexecutioncontextcontainer.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExecutionContextContainer](./kibana-plugin-core-public.iexecutioncontextcontainer.md) + +## IExecutionContextContainer interface + + +Signature: + +```typescript +export interface IExecutionContextContainer +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [toHeader](./kibana-plugin-core-public.iexecutioncontextcontainer.toheader.md) | () => Record<string, string> | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexecutioncontextcontainer.toheader.md b/docs/development/core/public/kibana-plugin-core-public.iexecutioncontextcontainer.toheader.md new file mode 100644 index 000000000000..03132d24bcca --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexecutioncontextcontainer.toheader.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExecutionContextContainer](./kibana-plugin-core-public.iexecutioncontextcontainer.md) > [toHeader](./kibana-plugin-core-public.iexecutioncontextcontainer.toheader.md) + +## IExecutionContextContainer.toHeader property + +Signature: + +```typescript +toHeader: () => Record; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.description.md b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.description.md new file mode 100644 index 000000000000..ea8c543c6789 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.description.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [KibanaExecutionContext](./kibana-plugin-core-public.kibanaexecutioncontext.md) > [description](./kibana-plugin-core-public.kibanaexecutioncontext.description.md) + +## KibanaExecutionContext.description property + +human readable description. For example, a vis title, action name + +Signature: + +```typescript +readonly description: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.id.md b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.id.md new file mode 100644 index 000000000000..1b50d2909458 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.id.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [KibanaExecutionContext](./kibana-plugin-core-public.kibanaexecutioncontext.md) > [id](./kibana-plugin-core-public.kibanaexecutioncontext.id.md) + +## KibanaExecutionContext.id property + +unique value to indentify find the source + +Signature: + +```typescript +readonly id: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md new file mode 100644 index 000000000000..41724f491426 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [KibanaExecutionContext](./kibana-plugin-core-public.kibanaexecutioncontext.md) + +## KibanaExecutionContext interface + + +Signature: + +```typescript +export interface KibanaExecutionContext +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [description](./kibana-plugin-core-public.kibanaexecutioncontext.description.md) | string | human readable description. For example, a vis title, action name | +| [id](./kibana-plugin-core-public.kibanaexecutioncontext.id.md) | string | unique value to indentify find the source | +| [name](./kibana-plugin-core-public.kibanaexecutioncontext.name.md) | string | public name of a user-facing feature | +| [type](./kibana-plugin-core-public.kibanaexecutioncontext.type.md) | string | Kibana application initated an operation. Can be narrowed to an enum later. | +| [url](./kibana-plugin-core-public.kibanaexecutioncontext.url.md) | string | in browser - url to navigate to a current page, on server - endpoint path, for task: task SO url | + diff --git a/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.name.md b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.name.md new file mode 100644 index 000000000000..21dde32e21ce --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.name.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [KibanaExecutionContext](./kibana-plugin-core-public.kibanaexecutioncontext.md) > [name](./kibana-plugin-core-public.kibanaexecutioncontext.name.md) + +## KibanaExecutionContext.name property + +public name of a user-facing feature + +Signature: + +```typescript +readonly name: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.type.md b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.type.md new file mode 100644 index 000000000000..ca339ddd9d64 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.type.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [KibanaExecutionContext](./kibana-plugin-core-public.kibanaexecutioncontext.md) > [type](./kibana-plugin-core-public.kibanaexecutioncontext.type.md) + +## KibanaExecutionContext.type property + +Kibana application initated an operation. Can be narrowed to an enum later. + +Signature: + +```typescript +readonly type: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.url.md b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.url.md new file mode 100644 index 000000000000..47ad7604b473 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.url.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [KibanaExecutionContext](./kibana-plugin-core-public.kibanaexecutioncontext.md) > [url](./kibana-plugin-core-public.kibanaexecutioncontext.url.md) + +## KibanaExecutionContext.url property + +in browser - url to navigate to a current page, on server - endpoint path, for task: task SO url + +Signature: + +```typescript +readonly url?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index a13438ff48e0..d743508e046e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -63,6 +63,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | | [DomainDeprecationDetails](./kibana-plugin-core-public.domaindeprecationdetails.md) | | | [ErrorToastOptions](./kibana-plugin-core-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) error APIs. | +| [ExecutionContextServiceStart](./kibana-plugin-core-public.executioncontextservicestart.md) | | | [FatalErrorInfo](./kibana-plugin-core-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | | [FatalErrorsSetup](./kibana-plugin-core-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | | [HttpFetchOptions](./kibana-plugin-core-public.httpfetchoptions.md) | All options that may be used with a [HttpHandler](./kibana-plugin-core-public.httphandler.md). | @@ -79,12 +80,14 @@ The plugin integrates with the core system via lifecycle events: `setup` | [I18nStart](./kibana-plugin-core-public.i18nstart.md) | I18nStart.Context is required by any localizable React component from @kbn/i18n and @elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. | | [IAnonymousPaths](./kibana-plugin-core-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | | [IBasePath](./kibana-plugin-core-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | +| [IExecutionContextContainer](./kibana-plugin-core-public.iexecutioncontextcontainer.md) | | | [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) | APIs for working with external URLs. | | [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) | A policy describing whether access to an external destination is allowed. | | [IHttpFetchError](./kibana-plugin-core-public.ihttpfetcherror.md) | | | [IHttpInterceptController](./kibana-plugin-core-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-core-public.httpinterceptor.md). | | [IHttpResponseInterceptorOverrides](./kibana-plugin-core-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | | [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) | Client-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) | +| [KibanaExecutionContext](./kibana-plugin-core-public.kibanaexecutioncontext.md) | | | [NavigateToAppOptions](./kibana-plugin-core-public.navigatetoappoptions.md) | Options for the [navigateToApp API](./kibana-plugin-core-public.applicationstart.navigatetoapp.md) | | [NotificationsSetup](./kibana-plugin-core-public.notificationssetup.md) | | | [NotificationsStart](./kibana-plugin-core-public.notificationsstart.md) | | diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.executioncontext.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.executioncontext.md new file mode 100644 index 000000000000..847b353aee44 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.executioncontext.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreSetup](./kibana-plugin-core-server.coresetup.md) > [executionContext](./kibana-plugin-core-server.coresetup.executioncontext.md) + +## CoreSetup.executionContext property + +[ExecutionContextSetup](./kibana-plugin-core-server.executioncontextsetup.md) + +Signature: + +```typescript +executionContext: ExecutionContextSetup; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.md index b37ac80db87d..a66db46adf0f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.md @@ -20,6 +20,7 @@ export interface CoreSetupContextSetup | [ContextSetup](./kibana-plugin-core-server.contextsetup.md) | | [deprecations](./kibana-plugin-core-server.coresetup.deprecations.md) | DeprecationsServiceSetup | [DeprecationsServiceSetup](./kibana-plugin-core-server.deprecationsservicesetup.md) | | [elasticsearch](./kibana-plugin-core-server.coresetup.elasticsearch.md) | ElasticsearchServiceSetup | [ElasticsearchServiceSetup](./kibana-plugin-core-server.elasticsearchservicesetup.md) | +| [executionContext](./kibana-plugin-core-server.coresetup.executioncontext.md) | ExecutionContextSetup | [ExecutionContextSetup](./kibana-plugin-core-server.executioncontextsetup.md) | | [getStartServices](./kibana-plugin-core-server.coresetup.getstartservices.md) | StartServicesAccessor<TPluginsStart, TStart> | [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | | [http](./kibana-plugin-core-server.coresetup.http.md) | HttpServiceSetup & {
resources: HttpResources;
} | [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) | | [i18n](./kibana-plugin-core-server.coresetup.i18n.md) | I18nServiceSetup | [I18nServiceSetup](./kibana-plugin-core-server.i18nservicesetup.md) | diff --git a/docs/development/core/server/kibana-plugin-core-server.corestart.executioncontext.md b/docs/development/core/server/kibana-plugin-core-server.corestart.executioncontext.md new file mode 100644 index 000000000000..e58f4dc4afa3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.corestart.executioncontext.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreStart](./kibana-plugin-core-server.corestart.md) > [executionContext](./kibana-plugin-core-server.corestart.executioncontext.md) + +## CoreStart.executionContext property + +[ExecutionContextStart](./kibana-plugin-core-server.executioncontextstart.md) + +Signature: + +```typescript +executionContext: ExecutionContextStart; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.corestart.md b/docs/development/core/server/kibana-plugin-core-server.corestart.md index f98088648689..d7aaba9149cf 100644 --- a/docs/development/core/server/kibana-plugin-core-server.corestart.md +++ b/docs/development/core/server/kibana-plugin-core-server.corestart.md @@ -18,6 +18,7 @@ export interface CoreStart | --- | --- | --- | | [capabilities](./kibana-plugin-core-server.corestart.capabilities.md) | CapabilitiesStart | [CapabilitiesStart](./kibana-plugin-core-server.capabilitiesstart.md) | | [elasticsearch](./kibana-plugin-core-server.corestart.elasticsearch.md) | ElasticsearchServiceStart | [ElasticsearchServiceStart](./kibana-plugin-core-server.elasticsearchservicestart.md) | +| [executionContext](./kibana-plugin-core-server.corestart.executioncontext.md) | ExecutionContextStart | [ExecutionContextStart](./kibana-plugin-core-server.executioncontextstart.md) | | [http](./kibana-plugin-core-server.corestart.http.md) | HttpServiceStart | [HttpServiceStart](./kibana-plugin-core-server.httpservicestart.md) | | [metrics](./kibana-plugin-core-server.corestart.metrics.md) | MetricsServiceStart | [MetricsServiceStart](./kibana-plugin-core-server.metricsservicestart.md) | | [savedObjects](./kibana-plugin-core-server.corestart.savedobjects.md) | SavedObjectsServiceStart | [SavedObjectsServiceStart](./kibana-plugin-core-server.savedobjectsservicestart.md) | diff --git a/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.get.md b/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.get.md new file mode 100644 index 000000000000..d152b9a0c5df --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.get.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ExecutionContextSetup](./kibana-plugin-core-server.executioncontextsetup.md) > [get](./kibana-plugin-core-server.executioncontextsetup.get.md) + +## ExecutionContextSetup.get() method + +Retrieves an opearation meta-data for the current async context. + +Signature: + +```typescript +get(): IExecutionContextContainer | undefined; +``` +Returns: + +`IExecutionContextContainer | undefined` + diff --git a/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.md b/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.md new file mode 100644 index 000000000000..137df77769c8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ExecutionContextSetup](./kibana-plugin-core-server.executioncontextsetup.md) + +## ExecutionContextSetup interface + + +Signature: + +```typescript +export interface ExecutionContextSetup +``` + +## Methods + +| Method | Description | +| --- | --- | +| [get()](./kibana-plugin-core-server.executioncontextsetup.get.md) | Retrieves an opearation meta-data for the current async context. | +| [set(context)](./kibana-plugin-core-server.executioncontextsetup.set.md) | Stores the meta-data of a runtime operation. Data are carried over all async operations automatically. The sequential calls merge provided "context" object shallowly. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.set.md b/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.set.md new file mode 100644 index 000000000000..4c8ba4d21b8c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.executioncontextsetup.set.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ExecutionContextSetup](./kibana-plugin-core-server.executioncontextsetup.md) > [set](./kibana-plugin-core-server.executioncontextsetup.set.md) + +## ExecutionContextSetup.set() method + +Stores the meta-data of a runtime operation. Data are carried over all async operations automatically. The sequential calls merge provided "context" object shallowly. + +Signature: + +```typescript +set(context: Partial): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| context | Partial<KibanaServerExecutionContext> | | + +Returns: + +`void` + diff --git a/docs/development/core/server/kibana-plugin-core-server.executioncontextstart.md b/docs/development/core/server/kibana-plugin-core-server.executioncontextstart.md new file mode 100644 index 000000000000..115c09471b3f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.executioncontextstart.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ExecutionContextStart](./kibana-plugin-core-server.executioncontextstart.md) + +## ExecutionContextStart type + + +Signature: + +```typescript +export declare type ExecutionContextStart = ExecutionContextSetup; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.iexecutioncontextcontainer.md b/docs/development/core/server/kibana-plugin-core-server.iexecutioncontextcontainer.md new file mode 100644 index 000000000000..2ab3f52b9b55 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexecutioncontextcontainer.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExecutionContextContainer](./kibana-plugin-core-server.iexecutioncontextcontainer.md) + +## IExecutionContextContainer interface + + +Signature: + +```typescript +export interface IExecutionContextContainer +``` + +## Methods + +| Method | Description | +| --- | --- | +| [toJSON()](./kibana-plugin-core-server.iexecutioncontextcontainer.tojson.md) | | +| [toString()](./kibana-plugin-core-server.iexecutioncontextcontainer.tostring.md) | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.iexecutioncontextcontainer.tojson.md b/docs/development/core/server/kibana-plugin-core-server.iexecutioncontextcontainer.tojson.md new file mode 100644 index 000000000000..f67aa88862fe --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexecutioncontextcontainer.tojson.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExecutionContextContainer](./kibana-plugin-core-server.iexecutioncontextcontainer.md) > [toJSON](./kibana-plugin-core-server.iexecutioncontextcontainer.tojson.md) + +## IExecutionContextContainer.toJSON() method + +Signature: + +```typescript +toJSON(): Readonly; +``` +Returns: + +`Readonly` + diff --git a/docs/development/core/server/kibana-plugin-core-server.iexecutioncontextcontainer.tostring.md b/docs/development/core/server/kibana-plugin-core-server.iexecutioncontextcontainer.tostring.md new file mode 100644 index 000000000000..60f9f499cf36 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexecutioncontextcontainer.tostring.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExecutionContextContainer](./kibana-plugin-core-server.iexecutioncontextcontainer.md) > [toString](./kibana-plugin-core-server.iexecutioncontextcontainer.tostring.md) + +## IExecutionContextContainer.toString() method + +Signature: + +```typescript +toString(): string; +``` +Returns: + +`string` + diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.description.md b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.description.md new file mode 100644 index 000000000000..00c907b578cf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.description.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaExecutionContext](./kibana-plugin-core-server.kibanaexecutioncontext.md) > [description](./kibana-plugin-core-server.kibanaexecutioncontext.description.md) + +## KibanaExecutionContext.description property + +human readable description. For example, a vis title, action name + +Signature: + +```typescript +readonly description: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.id.md b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.id.md new file mode 100644 index 000000000000..d86f62123121 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.id.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaExecutionContext](./kibana-plugin-core-server.kibanaexecutioncontext.md) > [id](./kibana-plugin-core-server.kibanaexecutioncontext.id.md) + +## KibanaExecutionContext.id property + +unique value to indentify find the source + +Signature: + +```typescript +readonly id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md new file mode 100644 index 000000000000..ebc2aeb419a7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaExecutionContext](./kibana-plugin-core-server.kibanaexecutioncontext.md) + +## KibanaExecutionContext interface + + +Signature: + +```typescript +export interface KibanaExecutionContext +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [description](./kibana-plugin-core-server.kibanaexecutioncontext.description.md) | string | human readable description. For example, a vis title, action name | +| [id](./kibana-plugin-core-server.kibanaexecutioncontext.id.md) | string | unique value to indentify find the source | +| [name](./kibana-plugin-core-server.kibanaexecutioncontext.name.md) | string | public name of a user-facing feature | +| [type](./kibana-plugin-core-server.kibanaexecutioncontext.type.md) | string | Kibana application initated an operation. Can be narrowed to an enum later. | +| [url](./kibana-plugin-core-server.kibanaexecutioncontext.url.md) | string | in browser - url to navigate to a current page, on server - endpoint path, for task: task SO url | + diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.name.md b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.name.md new file mode 100644 index 000000000000..92f58c01bcc1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.name.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaExecutionContext](./kibana-plugin-core-server.kibanaexecutioncontext.md) > [name](./kibana-plugin-core-server.kibanaexecutioncontext.name.md) + +## KibanaExecutionContext.name property + +public name of a user-facing feature + +Signature: + +```typescript +readonly name: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.type.md b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.type.md new file mode 100644 index 000000000000..534b0cdea175 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.type.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaExecutionContext](./kibana-plugin-core-server.kibanaexecutioncontext.md) > [type](./kibana-plugin-core-server.kibanaexecutioncontext.type.md) + +## KibanaExecutionContext.type property + +Kibana application initated an operation. Can be narrowed to an enum later. + +Signature: + +```typescript +readonly type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.url.md b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.url.md new file mode 100644 index 000000000000..dee241cd7939 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.url.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaExecutionContext](./kibana-plugin-core-server.kibanaexecutioncontext.md) > [url](./kibana-plugin-core-server.kibanaexecutioncontext.url.md) + +## KibanaExecutionContext.url property + +in browser - url to navigate to a current page, on server - endpoint path, for task: task SO url + +Signature: + +```typescript +readonly url?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanaserverexecutioncontext.md b/docs/development/core/server/kibana-plugin-core-server.kibanaserverexecutioncontext.md new file mode 100644 index 000000000000..f309e4fd0006 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanaserverexecutioncontext.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaServerExecutionContext](./kibana-plugin-core-server.kibanaserverexecutioncontext.md) + +## KibanaServerExecutionContext interface + + +Signature: + +```typescript +export interface KibanaServerExecutionContext extends Partial +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [requestId](./kibana-plugin-core-server.kibanaserverexecutioncontext.requestid.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanaserverexecutioncontext.requestid.md b/docs/development/core/server/kibana-plugin-core-server.kibanaserverexecutioncontext.requestid.md new file mode 100644 index 000000000000..dff3fd7f2e9f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanaserverexecutioncontext.requestid.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaServerExecutionContext](./kibana-plugin-core-server.kibanaserverexecutioncontext.md) > [requestId](./kibana-plugin-core-server.kibanaserverexecutioncontext.requestid.md) + +## KibanaServerExecutionContext.requestId property + +Signature: + +```typescript +requestId: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index ac8930c52ac5..4a203f10e7cd 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -77,6 +77,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ElasticsearchServiceStart](./kibana-plugin-core-server.elasticsearchservicestart.md) | | | [ElasticsearchStatusMeta](./kibana-plugin-core-server.elasticsearchstatusmeta.md) | | | [ErrorHttpResponseOptions](./kibana-plugin-core-server.errorhttpresponseoptions.md) | HTTP response parameters | +| [ExecutionContextSetup](./kibana-plugin-core-server.executioncontextsetup.md) | | | [FakeRequest](./kibana-plugin-core-server.fakerequest.md) | Fake request object created manually by Kibana plugins. | | [GetDeprecationsContext](./kibana-plugin-core-server.getdeprecationscontext.md) | | | [GetResponse](./kibana-plugin-core-server.getresponse.md) | | @@ -93,6 +94,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IContextContainer](./kibana-plugin-core-server.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | | [ICspConfig](./kibana-plugin-core-server.icspconfig.md) | CSP configuration for use in Kibana. | | [ICustomClusterClient](./kibana-plugin-core-server.icustomclusterclient.md) | See [IClusterClient](./kibana-plugin-core-server.iclusterclient.md) | +| [IExecutionContextContainer](./kibana-plugin-core-server.iexecutioncontextcontainer.md) | | | [IExternalUrlConfig](./kibana-plugin-core-server.iexternalurlconfig.md) | External Url configuration for use in Kibana. | | [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) | A policy describing whether access to an external destination is allowed. | | [IKibanaResponse](./kibana-plugin-core-server.ikibanaresponse.md) | A response data object, expected to returned as a result of [RequestHandler](./kibana-plugin-core-server.requesthandler.md) execution | @@ -103,8 +105,10 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ISavedObjectsPointInTimeFinder](./kibana-plugin-core-server.isavedobjectspointintimefinder.md) | | | [IScopedClusterClient](./kibana-plugin-core-server.iscopedclusterclient.md) | Serves the same purpose as the normal [cluster client](./kibana-plugin-core-server.iclusterclient.md) but exposes an additional asCurrentUser method that doesn't use credentials of the Kibana internal user (as asInternalUser does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead. | | [IUiSettingsClient](./kibana-plugin-core-server.iuisettingsclient.md) | Server-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. | +| [KibanaExecutionContext](./kibana-plugin-core-server.kibanaexecutioncontext.md) | | | [KibanaRequestEvents](./kibana-plugin-core-server.kibanarequestevents.md) | Request events. | | [KibanaRequestRoute](./kibana-plugin-core-server.kibanarequestroute.md) | Request specific route information exposed to a handler. | +| [KibanaServerExecutionContext](./kibana-plugin-core-server.kibanaserverexecutioncontext.md) | | | [LegacyAPICaller](./kibana-plugin-core-server.legacyapicaller.md) | | | [LegacyCallAPIOptions](./kibana-plugin-core-server.legacycallapioptions.md) | The set of options that defines how API call should be made and result be processed. | | [LegacyElasticsearchError](./kibana-plugin-core-server.legacyelasticsearcherror.md) | @deprecated. The new elasticsearch client doesn't wrap errors anymore. 7.16 | @@ -248,6 +252,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [DestructiveRouteMethod](./kibana-plugin-core-server.destructiveroutemethod.md) | Set of HTTP methods changing the state of the server. | | [ElasticsearchClient](./kibana-plugin-core-server.elasticsearchclient.md) | Client used to query the elasticsearch cluster. | | [ElasticsearchClientConfig](./kibana-plugin-core-server.elasticsearchclientconfig.md) | Configuration options to be used to create a [cluster client](./kibana-plugin-core-server.iclusterclient.md) using the [createClient API](./kibana-plugin-core-server.elasticsearchservicestart.createclient.md) | +| [ExecutionContextStart](./kibana-plugin-core-server.executioncontextstart.md) | | | [GetAuthHeaders](./kibana-plugin-core-server.getauthheaders.md) | Get headers to authenticate a user against Elasticsearch. | | [GetAuthState](./kibana-plugin-core-server.getauthstate.md) | Gets authentication state for a request. Returned by auth interceptor. | | [HandlerContextType](./kibana-plugin-core-server.handlercontexttype.md) | Extracts the type of the first argument of a [HandlerFunction](./kibana-plugin-core-server.handlerfunction.md) to represent the type of the context. | diff --git a/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.getmigrationversions.md b/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.getallmigrations.md similarity index 60% rename from docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.getmigrationversions.md rename to docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.getallmigrations.md index 0c8b29c85c05..6612683aee51 100644 --- a/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.getmigrationversions.md +++ b/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.getallmigrations.md @@ -1,11 +1,11 @@ -[Home](./index.md) > [kibana-plugin-plugins-embeddable-server](./kibana-plugin-plugins-embeddable-server.md) > [EmbeddableSetup](./kibana-plugin-plugins-embeddable-server.embeddablesetup.md) > [getMigrationVersions](./kibana-plugin-plugins-embeddable-server.embeddablesetup.getmigrationversions.md) +[Home](./index.md) > [kibana-plugin-plugins-embeddable-server](./kibana-plugin-plugins-embeddable-server.md) > [EmbeddableSetup](./kibana-plugin-plugins-embeddable-server.embeddablesetup.md) > [getAllMigrations](./kibana-plugin-plugins-embeddable-server.embeddablesetup.getallmigrations.md) -## EmbeddableSetup.getMigrationVersions property +## EmbeddableSetup.getAllMigrations property Signature: ```typescript -getMigrationVersions: () => string[]; +getAllMigrations: () => MigrateFunctionsObject; ``` diff --git a/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md b/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md index 9ecf966ece65..74e2951105b9 100644 --- a/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md +++ b/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md @@ -14,7 +14,7 @@ export interface EmbeddableSetup extends PersistableStateService() => string[] | | +| [getAllMigrations](./kibana-plugin-plugins-embeddable-server.embeddablesetup.getallmigrations.md) | () => MigrateFunctionsObject | | | [registerEmbeddableFactory](./kibana-plugin-plugins-embeddable-server.embeddablesetup.registerembeddablefactory.md) | (factory: EmbeddableRegistryDefinition) => void | | | [registerEnhancement](./kibana-plugin-plugins-embeddable-server.embeddablesetup.registerenhancement.md) | (enhancement: EnhancementRegistryDefinition) => void | | diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index d9a48835553c..ba333deeb160 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -557,11 +557,10 @@ deprecation warning at startup. This setting cannot end in a slash (`/`). proxy sitting in front of it. This determines whether HTTP compression may be used for responses, based on the request `Referer` header. This setting may not be used when <> is set to `false`. *Default: `none`* -[[server-securityResponseHeaders-strictTransportSecurity]] a| `server.securityResponseHeaders:` `strictTransportSecurity:` -| Controls whether the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security[`Strict-Transport-Security`] +| [[server-securityResponseHeaders-strictTransportSecurity]] Controls whether the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security[`Strict-Transport-Security`] header is used in all responses to the client from the {kib} server, and specifies what value is used. Allowed values are any text value or `null`. To disable, set to `null`. *Default:* `null` @@ -627,7 +626,7 @@ identifies this {kib} instance. *Default: `"your-hostname"`* setting specifies the port to use. *Default: `5601`* |[[server-requestId-allowFromAnyIp]] `server.requestId.allowFromAnyIp:` - | Sets whether or not the X-Opaque-Id header should be trusted from any IP address for identifying requests in logs and forwarded to Elasticsearch. + | Sets whether or not the `X-Opaque-Id` header should be trusted from any IP address for identifying requests in logs and forwarded to Elasticsearch. | `server.requestId.ipAllowlist:` | A list of IPv4 and IPv6 address which the `X-Opaque-Id` header should be trusted from. Normally this would be set to the IP addresses of the load balancers or reverse-proxy that end users use to access Kibana. If any are set, <> must also be set to `false.` diff --git a/docs/siem/index.asciidoc b/docs/siem/index.asciidoc index 05b1ec0b5b79..6afddb1dd5fb 100644 --- a/docs/siem/index.asciidoc +++ b/docs/siem/index.asciidoc @@ -27,7 +27,7 @@ and detection rules The following diagram provides a comprehensive illustration of the Elastic Security workflow. [role="screenshot"] -image::../siem/images/workflow.png[] +image::images/workflow.png[] Here's an overview of the flow and its components: diff --git a/docs/siem/siem-ui.asciidoc b/docs/siem/siem-ui.asciidoc deleted file mode 100644 index 1d07e9038667..000000000000 --- a/docs/siem/siem-ui.asciidoc +++ /dev/null @@ -1,160 +0,0 @@ -[role="xpack"] -[[siem-ui]] -== Elastic Security Overview - -Elastic Security combines SIEM threat detection features with endpoint -prevention and response capabilities in one solution. These analytical and -protection capabilities, leveraged by the speed and extensibility of -Elasticsearch, enable analysts to defend their organization from threats before -damage and loss occur. - -Elastic Security provides the following security benefits and capabilities: - -* A detection engine to identify attacks and system misconfigurations -* A workspace for event triage and investigations -* Interactive visualizations to investigate process relationships -* Inbuilt case management with automated actions -* Detection of signatureless attacks with prebuilt machine learning anomaly jobs -and detection rules - -[discrete] -== Elastic Security components and workflow - -The following diagram provides a comprehensive illustration of the Elastic Security workflow. - -[role="screenshot"] -image::../siem/images/workflow.png[Elastic Security workflow] - -Here's an overview of the flow and its components: - -* Data is shipped from your hosts to {es} via beat modules and the Elastic https://www.elastic.co/endpoint-security/[Endpoint Security agent integration]. This integration provides capabilities such as collecting events, detecting and preventing {security-guide}/detection-engine-overview.html#malware-prevention[malicious activity], and artifact delivery. The {fleet-guide}/fleet-overview.html[{fleet}] app is used to -install and manage agents and integrations on your hosts. -+ -The Endpoint Security integration ships the following data sets: -+ -*** *Windows*: Process, network, file, DNS, registry, DLL and driver loads, -malware security detections -*** *Linux/macOS*: Process, network, file -+ -* https://www.elastic.co/integrations?solution=security[Beat modules]: {beats} -are lightweight data shippers. Beat modules provide a way of collecting and -parsing specific data sets from common sources, such as cloud and OS events, -logs, and metrics. Common security-related modules are listed {security-guide}/ingest-data.html#enable-beat-modules[here]. -* The {security-app} in {kib} is used to manage the *Detection engine*, -*Cases*, and *Timeline*, as well as administer hosts running Endpoint Security: -** Detection engine: Automatically searches for suspicious host and network -activity via the following: -*** {security-guide}/detection-engine-overview.html#detection-engine-overview[Detection rules]: Periodically search the data -({es} indices) sent from your hosts for suspicious events. When a suspicious -event is discovered, a detection alert is generated. External systems, such as -Slack and email, can be used to send notifications when alerts are generated. -You can create your own rules and make use of our {security-guide}/prebuilt-rules.html[prebuilt ones]. -*** {security-guide}/detections-ui-exceptions.html[Exceptions]: Reduce noise and the number of -false positives. Exceptions are associated with rules and prevent alerts when -an exception's conditions are met. *Value lists* contain source event -values that can be used as part of an exception's conditions. When -Elastic {endpoint-sec} is installed on your hosts, you can add malware exceptions -directly to the endpoint from the Security app. -*** {security-guide}/machine-learning.html#included-jobs[{ml-cap} jobs]: Automatic anomaly detection of host and -network events. Anomaly scores are provided per host and can be used with -detection rules. -** {security-guide}/timelines-ui.html[Timeline]: Workspace for investigating alerts and events. -Timelines use queries and filters to drill down into events related to -a specific incident. Timeline templates are attached to rules and use predefined -queries when alerts are investigated. Timelines can be saved and shared with -others, as well as attached to Cases. -** {security-guide}/cases-overview.html[Cases]: An internal system for opening, tracking, and sharing -security issues directly in the Security app. Cases can be integrated with -external ticketing systems. -** {security-guide}/admin-page-ov.html[Administration]: View and manage hosts running {endpoint-sec}. - -{security-guide}/ingest-data.html[Ingest data to Elastic Security] and {security-guide}/install-endpoint.html[Configure and install the Elastic Endpoint integration] describe how to ship security-related -data to {es}. - - -For more background information, see: - -* https://www.elastic.co/products/elasticsearch[{es}]: A real-time, -distributed storage, search, and analytics engine. {es} excels at indexing -streams of semi-structured data, such as logs or metrics. -* https://www.elastic.co/products/kibana[{kib}]: An open-source analytics and -visualization platform designed to work with {es}. You use {kib} to search, -view, and interact with data stored in {es} indices. You can easily compile -advanced data analysis and visualize your data in a variety of charts, tables, -and maps. - -[discrete] -=== Compatibility with cold tier nodes - -Cold tier is a {ref}/data-tiers.html[data tier] that holds time series data that is accessed only occasionally. In {stack} version >=7.11.0, {elastic-sec} supports cold tier data for the following {es} indices: - -* Index patterns specified in `securitySolution:defaultIndex` -* Index patterns specified in the definitions of detection rules, except for indicator match rules -* Index patterns specified in the data sources selector on various {security-app} pages - -{elastic-sec} does NOT support cold tier data for the following {es} indices: - -* Index patterns controlled by {elastic-sec}, including signals and list indices -* Index patterns specified in indicator match rules - -Using cold tier data for unsupported indices may result in detection rule timeouts and overall performance degradation. - -[discrete] -[[self-protection]] -==== Elastic Endpoint self-protection - -Self-protection means that {elastic-endpoint} has guards against users and attackers that may try to interfere with its functionality. This protection feature is consistently enhanced to prevent attackers who may attempt to use newer, more sophisticated tactics to interfere with the {elastic-endpoint}. Self-protection is enabled by default when {elastic-endpoint} installs on supported platforms, listed below. - -Self-protection is enabled on the following 64-bit Windows versions: - -* Windows 8.1 -* Windows 10 -* Windows Server 2012 R2 -* Windows Server 2016 -* Windows Server 2019 - -And on the following macOS versions: - -* macOS 10.15 (Catalina) -* macOS 11 (Big Sur) - -NOTE: Other Windows and macOS variants (and all Linux distributions) do not have self-protection. - -For {stack} version >= 7.11.0, self-protection defines the following permissions: - -* Users -- even Administrator/root -- *cannot* delete {elastic-endpoint} files (located at `c:\Program Files\Elastic\Endpoint` on Windows, and `/Library/Elastic/Endpoint` on macOS). -* Users *cannot* terminate the {elastic-endpoint} program or service. -* Administrator/root users *can* read the endpoint's files. On Windows, the easiest way to read Endpoint files is to start an Administrator `cmd.exe` prompt. On macOS, an Administrator can use the `sudo` command. -* Administrator/root users *can* stop the {elastic-agent}'s service. On Windows, run the `sc stop "Elastic Agent"` command. On macOS, run the `sudo launchctl stop elastic-agent` command. - - -[discrete] -[[siem-integration]] -=== Integration with other Elastic products - -You can use {elastic-sec} with other Elastic products and features to help you -identify and investigate suspicious activity: - -* https://www.elastic.co/products/stack/machine-learning[{ml-cap}] -* https://www.elastic.co/products/stack/alerting[Alerting] -* https://www.elastic.co/products/stack/canvas[Canvas] - -[discrete] -[[data-sources]] -=== APM transaction data sources - -By default, {elastic-sec} monitors {apm-app-ref}/apm-getting-started.html[APM] -`apm-*-transaction*` indices. To add additional APM indices, update the -index patterns in the `securitySolution:defaultIndex` setting ({kib} -> Stack Management -> Advanced Settings -> `securitySolution:defaultIndex`). - -[discrete] -[[ecs-compliant-reqs]] -=== ECS compliance data requirements - -The {ecs-ref}[Elastic Common Schema (ECS)] defines a common set of fields to be used for -storing event data in Elasticsearch. ECS helps users normalize their event data -to better analyze, visualize, and correlate the data represented in their -events. {elastic-sec} supports events and indicator index data from any ECS-compliant data source. - -IMPORTANT: {elastic-sec} requires {ecs-ref}[ECS-compliant data]. If you use third-party data collectors to ship data to {es}, the data must be mapped to ECS. -{security-guide}/siem-field-reference.html[Elastic Security ECS field reference] lists ECS fields used in {elastic-sec}. diff --git a/docs/spaces/index.asciidoc b/docs/spaces/index.asciidoc index 8eea3b1ee455..9501043eb24b 100644 --- a/docs/spaces/index.asciidoc +++ b/docs/spaces/index.asciidoc @@ -28,7 +28,7 @@ Kibana supports spaces in several ways. You can: [float] ==== Required permissions -The `kibana_admin` role or equivilent is required to manage **Spaces**. +The `kibana_admin` role or equivalent is required to manage **Spaces**. TIP: Looking to support multiple tenants? See <> for more information. diff --git a/docs/user/ml/index.asciidoc b/docs/user/ml/index.asciidoc index 3c463da842fa..b3606b122d75 100644 --- a/docs/user/ml/index.asciidoc +++ b/docs/user/ml/index.asciidoc @@ -80,7 +80,7 @@ browser so that it does not block pop-up windows or create an exception for your For more information about the {anomaly-detect} feature, see https://www.elastic.co/what-is/elastic-stack-machine-learning[{ml-cap} in the {stack}] -and {ml-docs}/xpack-ml.html[{ml-cap} {anomaly-detect}]. +and {ml-docs}/ml-ad-overview.html[{ml-cap} {anomaly-detect}]. [[xpack-ml-dfanalytics]] == {dfanalytics-cap} diff --git a/docs/user/production-considerations/alerting-production-considerations.asciidoc b/docs/user/production-considerations/alerting-production-considerations.asciidoc index bd19a11435a9..57cc2a72a889 100644 --- a/docs/user/production-considerations/alerting-production-considerations.asciidoc +++ b/docs/user/production-considerations/alerting-production-considerations.asciidoc @@ -36,7 +36,7 @@ For detailed guidance, see < [float] [[alerting-scaling-guidance]] -=== Scaling Guidance +=== Scaling guidance As rules and actions leverage background tasks to perform the majority of work, scaling Alerting is possible by following the <>. @@ -58,7 +58,7 @@ Alerts and actions log activity in a set of "event log" indices. These indices The name of the index policy is `kibana-event-log-policy`. {kib} creates the index policy on startup, if it doesn't already exist. The index policy can be customized for your environment, but {kib} never modifies the index policy after creating it. -Because Kibana uses the documents to display historic data, you should set the delete phase longer than you would like the historic data to be shown. For example, if you would like to see one month's worth of historic data, you should set the delete phase to at least one month. +Because {kib} uses the documents to display historic data, you should set the delete phase longer than you would like the historic data to be shown. For example, if you would like to see one month's worth of historic data, you should set the delete phase to at least one month. For more information on index lifecycle management, see: {ref}/index-lifecycle-management.html[Index Lifecycle Policies]. diff --git a/docs/user/production-considerations/index.asciidoc b/docs/user/production-considerations/index.asciidoc index 198e8324af3e..22307f639424 100644 --- a/docs/user/production-considerations/index.asciidoc +++ b/docs/user/production-considerations/index.asciidoc @@ -1,4 +1,5 @@ include::production.asciidoc[] +include::security-production-considerations.asciidoc[] include::alerting-production-considerations.asciidoc[] include::reporting-production-considerations.asciidoc[] include::task-manager-production-considerations.asciidoc[] diff --git a/docs/user/production-considerations/production.asciidoc b/docs/user/production-considerations/production.asciidoc index b75b556588cf..455e07e45280 100644 --- a/docs/user/production-considerations/production.asciidoc +++ b/docs/user/production-considerations/production.asciidoc @@ -5,74 +5,24 @@ Production considerations ++++ -* <> -* <> -* <> -* <> -* <> -* <> -* <> - -How you deploy Kibana largely depends on your use case. If you are the only user, -you can run Kibana on your local machine and configure it to point to whatever -Elasticsearch instance you want to interact with. Conversely, if you have a large -number of heavy Kibana users, you might need to load balance across multiple -Kibana instances that are all connected to the same Elasticsearch instance. - -While Kibana isn't terribly resource intensive, we still recommend running Kibana -separate from your Elasticsearch data or master nodes. To distribute Kibana -traffic across the nodes in your Elasticsearch cluster, -you can configure Kibana to use a list of Elasticsearch hosts. - -[float] -[[configuring-kibana-shield]] -=== Use {stack} {security-features} - -You can use {stack} {security-features} to control what {es} data users can -access through Kibana. - -When {security-features} are enabled, Kibana users have to log in. They need to -have a role granting <> as well as access -to the indices they will be working with in Kibana. - -If a user loads a Kibana dashboard that accesses data in an index that they -are not authorized to view, they get an error that indicates the index does -not exist. - -For more information on granting access to Kibana, see <>. - -[float] -[[csp-strict-mode]] -=== Require Content Security Policy - -Kibana uses a Content Security Policy to help prevent the browser from allowing -unsafe scripting, but older browsers will silently ignore this policy. If your -organization does not need to support Internet Explorer 11 or much older -versions of our other supported browsers, we recommend that you enable Kibana's -`strict` mode for content security policy, which will block access to Kibana -for any browser that does not enforce even a rudimentary set of CSP -protections. - -To do this, set `csp.strict` to `true` in your `kibana.yml`: - -[source,js] --------- -csp.strict: true --------- +How you deploy {kib} largely depends on your use case. If you are the only user, +you can run {kib} on your local machine and configure it to point to whatever +{es} instance you want to interact with. Conversely, if you have a large +number of heavy {kib} users, you might need to load balance across multiple +{kib} instances that are all connected to the same {es} instance. - -[float] -[[enabling-ssl]] -=== Enable SSL - -See <>. +While {kib} isn't terribly resource intensive, we still recommend running {kib} +separate from your {es} data or master nodes. To distribute {kib} +traffic across the nodes in your {es} cluster, +you can configure {kib} to use a list of {es} hosts. [float] [[load-balancing-kibana]] -=== Load balancing across multiple Kibana instances -To serve multiple Kibana installations behind a load balancer, you must change the configuration. See {kibana-ref}/settings.html[Configuring Kibana] for details on each setting. +=== Load balancing across multiple {kib} instances +To serve multiple {kib} installations behind a load balancer, you must change the configuration. +See {kibana-ref}/settings.html[Configuring {kib}] for details on each setting. -Settings unique across each Kibana instance: +Settings unique across each {kib} instance: [source,js] -------- server.uuid @@ -119,8 +69,8 @@ active in case of failure from the currently used instance. [float] [[high-availability]] === High availability across multiple {es} nodes -Kibana can be configured to connect to multiple Elasticsearch nodes in the same cluster. In situations where a node becomes unavailable, -Kibana will transparently connect to an available node and continue operating. Requests to available hosts will be routed in a round robin fashion. +{kib} can be configured to connect to multiple {es} nodes in the same cluster. In situations where a node becomes unavailable, +{kib} will transparently connect to an available node and continue operating. Requests to available hosts will be routed in a round robin fashion. In kibana.yml: [source,js] @@ -136,10 +86,11 @@ These can be used to automatically update the list of hosts as a cluster is resi [float] [[memory]] === Memory -Kibana has a default maximum memory limit of 1.4 GB, and in most cases, we recommend leaving this unconfigured. In some scenarios, such as large reporting jobs, + +Kibana has a default memory limit that scales based on total memory available. In some scenarios, such as large reporting jobs, it may make sense to tweak limits to meet more specific requirements. -You can modify this limit by setting `--max-old-space-size` in the `node.options` config file that can be found inside `kibana/config` folder or any other configured with the environment variable `KBN_PATH_CONF` (for example in debian based system would be `/etc/kibana`). +A limit can be defined by setting `--max-old-space-size` in the `node.options` config file found inside the `kibana/config` folder or any other folder configured with the environment variable `KBN_PATH_CONF`. For example, in the Debian-based system, the folder is `/etc/kibana`. The option accepts a limit in MB: [source,js] diff --git a/docs/user/production-considerations/security-production-considerations.asciidoc b/docs/user/production-considerations/security-production-considerations.asciidoc new file mode 100644 index 000000000000..a23911375639 --- /dev/null +++ b/docs/user/production-considerations/security-production-considerations.asciidoc @@ -0,0 +1,88 @@ +[role="xpack"] +[[Security-production-considerations]] +== Security production considerations + +++++ +Security +++++ +:keywords: administrator, analyst, concept, setup, security +:description: Consider the production components for {kib} security. + +To secure your {kib} installation in production, consider these high-priority topics to ensure +that only authorized users can access {kib}. +For more information on {kib}'s security controls, see <>. + +[float] +[[enabling-ssl]] +=== Enable SSL/TLS + +You should use SSL/TLS encryption to ensure that traffic between browsers and the {kib} server cannot be viewed or tampered with by third +parties. See <>. + +[float] +[[configuring-kibana-shield]] +=== Use {stack} {security-features} + +You can use {stack} {security-features} to control what {es} data users can +access through {kib}. + +When {security-features} are enabled, {kib} users have to log in. They must +have a role granting <> and access +to the indices that they will be working with in {kib}. + +If a user loads a {kib} dashboard that accesses data in an index that they +are not authorized to view, they get an error that indicates the index does +not exist. + +For more information on granting access to {kib}, see <>. + +[float] +[[configuring-security-headers]] +=== Use secure HTTP headers + +The {kib} server can instruct browsers to enable additional security controls using HTTP headers. + +1. Enable HTTP Strict-Transport-Security. ++ +Use <> to ensure that browsers will only attempt +to access {kib} with SSL/TLS encryption. This is designed to prevent manipulator-in-the-middle attacks. +To configure this with a lifetime of one +year in your `kibana.yml`: ++ +[source,js] +-------- +server.securityResponseHeaders.strictTransportSecurity: "max-age=31536000" +-------- ++ +WARNING: This header will block unencrypted connections for the entire domain. If you host more than one web application on the same domain +using different ports or paths, all of them will be affected. + +2. Disable embedding. ++ +Use <> to ensure +that {kib} cannot be embedded in other websites. +To configure this in your `kibana.yml`: ++ +[source,js] +-------- +server.securityResponseHeaders.disableEmbedding: true +-------- + +[float] +[[csp-strict-mode]] +=== Require a Content Security Policy + +{kib} uses a Content Security Policy (CSP) to prevent the browser from allowing +unsafe scripting, but older browsers will silently ignore this policy. If your +organization does not need to support very old +versions of our supported browsers, we recommend that you enable {kib}'s +`strict` mode for the CSP. This will block access to {kib} +for any browser that does not enforce even a rudimentary set of CSP +protections. + +To do this, set `csp.strict` to `true` in your `kibana.yml`: + +[source,js] +-------- +csp.strict: true +-------- diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index faa980fe833c..5506e7ab375a 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -65,6 +65,10 @@ image::user/security/images/kibana-login.png["Login Selector UI"] For more information, refer to <>. +TIP: If you have multiple authentication providers configured, you can use the `auth_provider_hint` URL query parameter to create a deep +link to any provider and bypass the Login Selector UI. Using the `kibana.yml` above as an example, you can add `?auth_provider_hint=basic1` +to the login page URL, which will take you directly to the basic login page. + [[basic-authentication]] ==== Basic authentication diff --git a/examples/embeddable_examples/public/index.ts b/examples/embeddable_examples/public/index.ts index 8f267feeeaf6..365c00155984 100644 --- a/examples/embeddable_examples/public/index.ts +++ b/examples/embeddable_examples/public/index.ts @@ -17,6 +17,8 @@ export { TODO_EMBEDDABLE, TodoEmbeddableFactory } from './todo'; export { BOOK_EMBEDDABLE } from './book'; +export { SIMPLE_EMBEDDABLE } from './migrations'; + import { EmbeddableExamplesPlugin } from './plugin'; export { diff --git a/examples/embeddable_examples/public/migrations/index.ts b/examples/embeddable_examples/public/migrations/index.ts new file mode 100644 index 000000000000..0cb619642f34 --- /dev/null +++ b/examples/embeddable_examples/public/migrations/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './migrations_embeddable'; +export * from './migrations_embeddable_factory'; diff --git a/examples/embeddable_examples/public/migrations/migration.7.3.0.ts b/examples/embeddable_examples/public/migrations/migration.7.3.0.ts new file mode 100644 index 000000000000..5b31cfe2b4e6 --- /dev/null +++ b/examples/embeddable_examples/public/migrations/migration.7.3.0.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { MigrateFunction } from '../../../../src/plugins/kibana_utils/common/persistable_state'; +import { SimpleEmbeddableInput } from './migrations_embeddable_factory'; +import { EmbeddableInput } from '../../../../src/plugins/embeddable/common'; + +// before 7.3.0 this embeddable received a very simple input with a variable named `number` +// eslint-disable-next-line @typescript-eslint/naming-convention +type SimpleEmbeddableInput_pre7_3_0 = EmbeddableInput & { + number: number; +}; + +type SimpleEmbeddable730MigrateFn = MigrateFunction< + SimpleEmbeddableInput_pre7_3_0, + SimpleEmbeddableInput +>; + +// when migrating old state we'll need to set a default title, or we should make title optional in the new state +const defaultTitle = 'no title'; + +export const migration730: SimpleEmbeddable730MigrateFn = (state) => { + const newState: SimpleEmbeddableInput = { ...state, title: defaultTitle, value: state.number }; + return newState; +}; diff --git a/examples/embeddable_examples/public/migrations/migrations_embeddable.tsx b/examples/embeddable_examples/public/migrations/migrations_embeddable.tsx new file mode 100644 index 000000000000..871994ce2aaa --- /dev/null +++ b/examples/embeddable_examples/public/migrations/migrations_embeddable.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SIMPLE_EMBEDDABLE, SimpleEmbeddableInput } from '.'; +import { Embeddable, IContainer } from '../../../../src/plugins/embeddable/public'; + +export class SimpleEmbeddable extends Embeddable { + // The type of this embeddable. This will be used to find the appropriate factory + // to instantiate this kind of embeddable. + public readonly type = SIMPLE_EMBEDDABLE; + + constructor(initialInput: SimpleEmbeddableInput, parent?: IContainer) { + super( + // Input state is irrelevant to this embeddable, just pass it along. + initialInput, + // Initial output state - this embeddable does not do anything with output, so just + // pass along an empty object. + {}, + // Optional parent component, this embeddable can optionally be rendered inside a container. + parent + ); + } + + /** + * Render yourself at the dom node using whatever framework you like, angular, react, or just plain + * vanilla js. + * @param node + */ + public render(node: HTMLElement) { + const input = this.getInput(); + // eslint-disable-next-line no-unsanitized/property + node.innerHTML = `
${input.title} ${input.value}
`; + } + + /** + * This is mostly relevant for time based embeddables which need to update data + * even if EmbeddableInput has not changed at all. + */ + public reload() {} +} diff --git a/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts b/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts new file mode 100644 index 000000000000..508ed780038c --- /dev/null +++ b/examples/embeddable_examples/public/migrations/migrations_embeddable_factory.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { + IContainer, + EmbeddableInput, + EmbeddableFactoryDefinition, + EmbeddableFactory, +} from '../../../../src/plugins/embeddable/public'; +import { SimpleEmbeddable } from './migrations_embeddable'; +import { migration730 } from './migration.7.3.0'; + +export const SIMPLE_EMBEDDABLE = 'SIMPLE_EMBEDDABLE'; + +// in 7.3.0 we added `title` to the input and renamed the `number` variable to `value` +export type SimpleEmbeddableInput = EmbeddableInput & { + title: string; + value: number; +}; + +export type SimpleEmbeddableFactory = EmbeddableFactory; +export class SimpleEmbeddableFactoryDefinition + implements EmbeddableFactoryDefinition { + public readonly type = SIMPLE_EMBEDDABLE; + + // we need to provide migration function every time we change the interface of our state + public readonly migrations = { + '7.3.0': migration730, + }; + + /** + * In our simple example, we let everyone have permissions to edit this. Most + * embeddables should check the UI Capabilities service to be sure of + * the right permissions. + */ + public async isEditable() { + return true; + } + + public async create(initialInput: SimpleEmbeddableInput, parent?: IContainer) { + return new SimpleEmbeddable(initialInput, parent); + } + + public getDisplayName() { + return i18n.translate('embeddableExamples.migrations.displayName', { + defaultMessage: 'hello world', + }); + } +} diff --git a/examples/embeddable_examples/public/plugin.ts b/examples/embeddable_examples/public/plugin.ts index 0ce8e5b63c2e..248072064e14 100644 --- a/examples/embeddable_examples/public/plugin.ts +++ b/examples/embeddable_examples/public/plugin.ts @@ -49,6 +49,11 @@ import { import { UiActionsStart } from '../../../src/plugins/ui_actions/public'; import { createAddBookToLibraryAction } from './book/add_book_to_library_action'; import { createUnlinkBookFromLibraryAction } from './book/unlink_book_from_library_action'; +import { + SIMPLE_EMBEDDABLE, + SimpleEmbeddableFactory, + SimpleEmbeddableFactoryDefinition, +} from './migrations'; export interface EmbeddableExamplesSetupDependencies { embeddable: EmbeddableSetup; @@ -68,6 +73,7 @@ interface ExampleEmbeddableFactories { getTodoEmbeddableFactory: () => TodoEmbeddableFactory; getTodoRefEmbeddableFactory: () => TodoRefEmbeddableFactory; getBookEmbeddableFactory: () => BookEmbeddableFactory; + getMigrationsEmbeddableFactory: () => SimpleEmbeddableFactory; } export interface EmbeddableExamplesStart { @@ -94,6 +100,11 @@ export class EmbeddableExamplesPlugin new HelloWorldEmbeddableFactoryDefinition() ); + this.exampleEmbeddableFactories.getMigrationsEmbeddableFactory = deps.embeddable.registerEmbeddableFactory( + SIMPLE_EMBEDDABLE, + new SimpleEmbeddableFactoryDefinition() + ); + this.exampleEmbeddableFactories.getMultiTaskTodoEmbeddableFactory = deps.embeddable.registerEmbeddableFactory( MULTI_TASK_TODO_EMBEDDABLE, new MultiTaskTodoEmbeddableFactoryDefinition() diff --git a/examples/embeddable_examples/server/plugin.ts b/examples/embeddable_examples/server/plugin.ts index dff376de9232..a3c3d4b5284f 100644 --- a/examples/embeddable_examples/server/plugin.ts +++ b/examples/embeddable_examples/server/plugin.ts @@ -9,11 +9,19 @@ import { Plugin, CoreSetup, CoreStart } from 'kibana/server'; import { todoSavedObject } from './todo_saved_object'; import { bookSavedObject } from './book_saved_object'; +import { searchableListSavedObject } from './searchable_list_saved_object'; +import { EmbeddableSetup } from '../../../src/plugins/embeddable/server'; -export class EmbeddableExamplesPlugin implements Plugin { - public setup(core: CoreSetup) { +export interface EmbeddableExamplesSetupDependencies { + embeddable: EmbeddableSetup; +} + +export class EmbeddableExamplesPlugin + implements Plugin { + public setup(core: CoreSetup, { embeddable }: EmbeddableExamplesSetupDependencies) { core.savedObjects.registerType(todoSavedObject); core.savedObjects.registerType(bookSavedObject); + core.savedObjects.registerType(searchableListSavedObject(embeddable)); } public start(core: CoreStart) {} diff --git a/examples/embeddable_examples/server/searchable_list_saved_object.ts b/examples/embeddable_examples/server/searchable_list_saved_object.ts new file mode 100644 index 000000000000..ac4656c7c2b7 --- /dev/null +++ b/examples/embeddable_examples/server/searchable_list_saved_object.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { mapValues } from 'lodash'; +import { SavedObjectsType, SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { EmbeddableSetup } from '../../../src/plugins/embeddable/server'; + +export const searchableListSavedObject = (embeddable: EmbeddableSetup) => { + return { + name: 'searchableList', + hidden: false, + namespaceType: 'single', + management: { + icon: 'visualizeApp', + defaultSearchField: 'title', + importableAndExportable: true, + getTitle(obj: any) { + return obj.attributes.title; + }, + }, + mappings: { + properties: { + title: { type: 'text' }, + version: { type: 'integer' }, + }, + }, + migrations: () => { + // we assume all the migration will be done by embeddables service and that saved object holds no extra state besides that of searchable list embeddable input\ + // if saved object would hold additional information we would need to merge the response from embeddables.getAllMigrations with our custom migrations. + return mapValues(embeddable.getAllMigrations(), (migrate) => { + return (state: SavedObjectUnsanitizedDoc) => ({ + ...state, + attributes: migrate(state.attributes), + }); + }); + }, + } as SavedObjectsType; +}; diff --git a/examples/locator_explorer/public/app.tsx b/examples/locator_explorer/public/app.tsx index 440e16302dff..8e38c097a847 100644 --- a/examples/locator_explorer/public/app.tsx +++ b/examples/locator_explorer/public/app.tsx @@ -19,7 +19,7 @@ import { EuiFieldText } from '@elastic/eui'; import { EuiPageHeader } from '@elastic/eui'; import { EuiLink } from '@elastic/eui'; import { AppMountParameters } from '../../../src/core/public'; -import { SharePluginSetup } from '../../../src/plugins/share/public'; +import { formatSearchParams, SharePluginSetup } from '../../../src/plugins/share/public'; import { HelloLocatorV1Params, HelloLocatorV2Params, @@ -34,6 +34,7 @@ interface MigratedLink { linkText: string; link: string; version: string; + params: HelloLocatorV1Params | HelloLocatorV2Params; } const ActionsExplorer = ({ share }: Props) => { @@ -93,6 +94,7 @@ const ActionsExplorer = ({ share }: Props) => { linkText: savedLink.linkText, link, version: savedLink.version, + params: savedLink.params, } as MigratedLink; }) ); @@ -157,7 +159,24 @@ const ActionsExplorer = ({ share }: Props) => { target="_blank" > {link.linkText} + {' '} + ( + + through redirect app + )
)) diff --git a/package.json b/package.json index de7df7fea3d8..9888f2195489 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "**/minimist": "^1.2.5", "**/node-jose/node-forge": "^0.10.0", "**/pdfkit/crypto-js": "4.0.0", - "**/prismjs": "1.23.0", + "**/prismjs": "1.24.0", "**/react-syntax-highlighter": "^15.3.1", "**/react-syntax-highlighter/**/highlight.js": "^10.4.1", "**/refractor": "^3.3.1", @@ -99,7 +99,7 @@ "dependencies": { "@elastic/apm-rum": "^5.8.0", "@elastic/apm-rum-react": "^1.2.11", - "@elastic/charts": "31.0.0", + "@elastic/charts": "31.1.0", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.13", "@elastic/ems-client": "7.14.0", @@ -310,7 +310,7 @@ "nock": "12.0.3", "node-fetch": "^2.6.1", "node-forge": "^0.10.0", - "nodemailer": "^6.4.16", + "nodemailer": "^6.6.2", "normalize-path": "^3.0.0", "object-hash": "^1.3.1", "object-path-immutable": "^3.1.1", diff --git a/packages/kbn-apm-config-loader/src/config.test.ts b/packages/kbn-apm-config-loader/src/config.test.ts index d6509b694a1d..e3cfbf43f841 100644 --- a/packages/kbn-apm-config-loader/src/config.test.ts +++ b/packages/kbn-apm-config-loader/src/config.test.ts @@ -17,10 +17,11 @@ import { import { ApmConfiguration } from './config'; -const initialEnv = { ...process.env }; - describe('ApmConfiguration', () => { beforeEach(() => { + // start with an empty env to avoid CI from spoiling snapshots, env is unique for each jest file + process.env = {}; + packageMock.raw = { version: '8.0.0', build: { @@ -30,7 +31,6 @@ describe('ApmConfiguration', () => { }); afterEach(() => { - process.env = { ...initialEnv }; resetAllMocks(); }); @@ -46,7 +46,7 @@ describe('ApmConfiguration', () => { it('sets the git revision from `git rev-parse` command in non distribution mode', () => { gitRevExecMock.mockReturnValue('some-git-rev'); const config = new ApmConfiguration(mockedRootDir, {}, false); - expect(config.getConfig('serviceName').globalLabels.git_rev).toBe('some-git-rev'); + expect(config.getConfig('serviceName').globalLabels?.git_rev).toBe('some-git-rev'); }); it('sets the git revision from `pkg.build.sha` in distribution mode', () => { @@ -58,13 +58,13 @@ describe('ApmConfiguration', () => { }, }; const config = new ApmConfiguration(mockedRootDir, {}, true); - expect(config.getConfig('serviceName').globalLabels.git_rev).toBe('distribution-sha'); + expect(config.getConfig('serviceName').globalLabels?.git_rev).toBe('distribution-sha'); }); it('reads the kibana uuid from the uuid file', () => { readUuidFileMock.mockReturnValue('instance-uuid'); const config = new ApmConfiguration(mockedRootDir, {}, false); - expect(config.getConfig('serviceName').globalLabels.kibana_uuid).toBe('instance-uuid'); + expect(config.getConfig('serviceName').globalLabels?.kibana_uuid).toBe('instance-uuid'); }); it('uses the uuid from the kibana config if present', () => { @@ -75,23 +75,51 @@ describe('ApmConfiguration', () => { }, }; const config = new ApmConfiguration(mockedRootDir, kibanaConfig, false); - expect(config.getConfig('serviceName').globalLabels.kibana_uuid).toBe('uuid-from-config'); + expect(config.getConfig('serviceName').globalLabels?.kibana_uuid).toBe('uuid-from-config'); }); - it('uses the correct default config depending on the `isDistributable` parameter', () => { + it('overrides metricsInterval, breakdownMetrics, captureHeaders, and captureBody when `isDistributable` is true', () => { let config = new ApmConfiguration(mockedRootDir, {}, false); - expect(config.getConfig('serviceName')).toEqual( - expect.objectContaining({ - breakdownMetrics: true, - }) - ); + expect(config.getConfig('serviceName')).toMatchInlineSnapshot(` + Object { + "active": false, + "breakdownMetrics": true, + "captureSpanStackTraces": false, + "centralConfig": false, + "environment": "development", + "globalLabels": Object {}, + "logUncaughtExceptions": true, + "metricsInterval": "30s", + "secretToken": "ZQHYvrmXEx04ozge8F", + "serverUrl": "https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io", + "serviceName": "serviceName", + "serviceVersion": "8.0.0", + "transactionSampleRate": 1, + } + `); config = new ApmConfiguration(mockedRootDir, {}, true); - expect(config.getConfig('serviceName')).toEqual( - expect.objectContaining({ - breakdownMetrics: false, - }) - ); + expect(config.getConfig('serviceName')).toMatchInlineSnapshot(` + Object { + "active": false, + "breakdownMetrics": false, + "captureBody": "off", + "captureHeaders": false, + "captureSpanStackTraces": false, + "centralConfig": false, + "environment": "development", + "globalLabels": Object { + "git_rev": "sha", + }, + "logUncaughtExceptions": true, + "metricsInterval": "120s", + "secretToken": "ZQHYvrmXEx04ozge8F", + "serverUrl": "https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io", + "serviceName": "serviceName", + "serviceVersion": "8.0.0", + "transactionSampleRate": 1, + } + `); }); it('loads the configuration from the kibana config file', () => { @@ -119,7 +147,7 @@ describe('ApmConfiguration', () => { active: true, serverUrl: 'https://dev-url.co', }; - const config = new ApmConfiguration(mockedRootDir, {}, true); + const config = new ApmConfiguration(mockedRootDir, {}, false); expect(config.getConfig('serviceName')).toEqual( expect.objectContaining({ active: true, @@ -128,7 +156,20 @@ describe('ApmConfiguration', () => { ); }); - it('respect the precedence of the dev config', () => { + it('does not load the configuration from the dev config in distributable', () => { + devConfigMock.raw = { + active: true, + serverUrl: 'https://dev-url.co', + }; + const config = new ApmConfiguration(mockedRootDir, {}, true); + expect(config.getConfig('serviceName')).toEqual( + expect.objectContaining({ + active: false, + }) + ); + }); + + it('overwrites the standard config file with the dev config', () => { const kibanaConfig = { elastic: { apm: { @@ -142,7 +183,7 @@ describe('ApmConfiguration', () => { active: true, serverUrl: 'https://dev-url.co', }; - const config = new ApmConfiguration(mockedRootDir, kibanaConfig, true); + const config = new ApmConfiguration(mockedRootDir, kibanaConfig, false); expect(config.getConfig('serviceName')).toEqual( expect.objectContaining({ active: true, @@ -152,7 +193,7 @@ describe('ApmConfiguration', () => { ); }); - it('correctly sets environment', () => { + it('correctly sets environment by reading env vars', () => { delete process.env.ELASTIC_APM_ENVIRONMENT; delete process.env.NODE_ENV; diff --git a/packages/kbn-apm-config-loader/src/config.ts b/packages/kbn-apm-config-loader/src/config.ts index 8a3da17bc2bd..d33d4b71841c 100644 --- a/packages/kbn-apm-config-loader/src/config.ts +++ b/packages/kbn-apm-config-loader/src/config.ts @@ -7,45 +7,47 @@ */ import { join } from 'path'; -import { merge, get } from 'lodash'; +import { merge } from 'lodash'; import { execSync } from 'child_process'; // deep import to avoid loading the whole package import { getDataPath } from '@kbn/utils/target/path'; import { readFileSync } from 'fs'; import { ApmAgentConfig } from './types'; -const getDefaultConfig = (isDistributable: boolean): ApmAgentConfig => { - // https://www.elastic.co/guide/en/apm/agent/nodejs/current/configuration.html - - return { - active: process.env.ELASTIC_APM_ACTIVE === 'true' || false, - environment: process.env.ELASTIC_APM_ENVIRONMENT || process.env.NODE_ENV || 'development', - - serverUrl: 'https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io', - - // The secretToken below is intended to be hardcoded in this file even though - // it makes it public. This is not a security/privacy issue. Normally we'd - // instead disable the need for a secretToken in the APM Server config where - // the data is transmitted to, but due to how it's being hosted, it's easier, - // for now, to simply leave it in. - secretToken: 'ZQHYvrmXEx04ozge8F', - - logUncaughtExceptions: true, - globalLabels: {}, - centralConfig: false, - metricsInterval: isDistributable ? '120s' : '30s', - captureSpanStackTraces: false, - transactionSampleRate: process.env.ELASTIC_APM_TRANSACTION_SAMPLE_RATE - ? parseFloat(process.env.ELASTIC_APM_TRANSACTION_SAMPLE_RATE) - : 1.0, - - // Can be performance intensive, disabling by default - breakdownMetrics: isDistributable ? false : true, - }; +// https://www.elastic.co/guide/en/apm/agent/nodejs/current/configuration.html +const DEFAULT_CONFIG: ApmAgentConfig = { + active: false, + environment: 'development', + logUncaughtExceptions: true, + globalLabels: {}, +}; + +const CENTRALIZED_SERVICE_BASE_CONFIG: ApmAgentConfig = { + serverUrl: 'https://38b80fbd79fb4c91bae06b4642d4d093.apm.us-east-1.aws.cloud.es.io', + + // The secretToken below is intended to be hardcoded in this file even though + // it makes it public. This is not a security/privacy issue. Normally we'd + // instead disable the need for a secretToken in the APM Server config where + // the data is transmitted to, but due to how it's being hosted, it's easier, + // for now, to simply leave it in. + secretToken: 'ZQHYvrmXEx04ozge8F', + + centralConfig: false, + metricsInterval: '30s', + captureSpanStackTraces: false, + transactionSampleRate: 1.0, + breakdownMetrics: true, +}; + +const CENTRALIZED_SERVICE_DIST_CONFIG: ApmAgentConfig = { + metricsInterval: '120s', + captureBody: 'off', + captureHeaders: false, + breakdownMetrics: false, }; export class ApmConfiguration { - private baseConfig?: any; + private baseConfig?: ApmAgentConfig; private kibanaVersion: string; private pkgBuild: Record; @@ -69,52 +71,77 @@ export class ApmConfiguration { private getBaseConfig() { if (!this.baseConfig) { - const apmConfig = merge( - getDefaultConfig(this.isDistributable), + this.baseConfig = merge( + { + serviceVersion: this.kibanaVersion, + }, + DEFAULT_CONFIG, + this.getUuidConfig(), + this.getGitConfig(), + this.getCiConfig(), this.getConfigFromKibanaConfig(), this.getDevConfig(), - this.getDistConfig(), - this.getCIConfig() + this.getConfigFromEnv() ); - const rev = this.getGitRev(); - if (rev !== null) { - apmConfig.globalLabels.git_rev = rev; - } - - const uuid = this.getKibanaUuid(); - if (uuid) { - apmConfig.globalLabels.kibana_uuid = uuid; + /** + * When the user doesn't override the serverUrl we define our central APM service + * as the serverUrl along with a few other overrides to prevent potentially + * sensitive data from being sent to this service. + */ + const centralizedConfig = this.isDistributable + ? merge({}, CENTRALIZED_SERVICE_BASE_CONFIG, CENTRALIZED_SERVICE_DIST_CONFIG) + : CENTRALIZED_SERVICE_BASE_CONFIG; + + if ( + !this.baseConfig?.serverUrl || + this.baseConfig.serverUrl === centralizedConfig.serverUrl + ) { + this.baseConfig = merge(this.baseConfig, centralizedConfig); } - - apmConfig.serviceVersion = this.kibanaVersion; - this.baseConfig = apmConfig; } return this.baseConfig; } - private getConfigFromKibanaConfig(): ApmAgentConfig { - return get(this.rawKibanaConfig, 'elastic.apm', {}); - } + /** + * Override some config values when specific environment variables are used + */ + private getConfigFromEnv(): ApmAgentConfig { + const config: ApmAgentConfig = {}; - private getKibanaUuid() { - // try to access the `server.uuid` value from the config file first. - // if not manually defined, we will then read the value from the `{DATA_FOLDER}/uuid` file. - // note that as the file is created by the platform AFTER apm init, the file - // will not be present at first startup, but there is nothing we can really do about that. - if (get(this.rawKibanaConfig, 'server.uuid')) { - return this.rawKibanaConfig.server.uuid; + if (process.env.ELASTIC_APM_ACTIVE === 'true') { + config.active = true; } - const dataPath: string = get(this.rawKibanaConfig, 'path.data') || getDataPath(); - try { - const filename = join(dataPath, 'uuid'); - return readFileSync(filename, 'utf-8'); - } catch (e) {} // eslint-disable-line no-empty + if (process.env.ELASTIC_APM_ENVIRONMENT || process.env.NODE_ENV) { + config.environment = process.env.ELASTIC_APM_ENVIRONMENT || process.env.NODE_ENV; + } + + if (process.env.ELASTIC_APM_TRANSACTION_SAMPLE_RATE) { + config.transactionSampleRate = parseFloat(process.env.ELASTIC_APM_TRANSACTION_SAMPLE_RATE); + } + + return config; + } + + /** + * Get the elastic.apm configuration from the --config file, supersedes the + * default config. + */ + private getConfigFromKibanaConfig(): ApmAgentConfig { + return this.rawKibanaConfig?.elastic?.apm ?? {}; } + /** + * Get the configuration from the apm.dev.js file, supersedes config + * from the --config file, disabled when running the distributable + */ private getDevConfig(): ApmAgentConfig { + if (this.isDistributable) { + return {}; + } + try { const apmDevConfigPath = join(this.rootDir, 'config', 'apm.dev.js'); return require(apmDevConfigPath); @@ -123,20 +150,51 @@ export class ApmConfiguration { } } - /** Config keys that cannot be overridden in production builds */ - private getDistConfig(): ApmAgentConfig { - if (!this.isDistributable) { - return {}; + /** + * Determine the Kibana UUID, initialized the value of `globalLabels.kibana_uuid` + * when the UUID can be determined. + */ + private getUuidConfig(): ApmAgentConfig { + // try to access the `server.uuid` value from the config file first. + // if not manually defined, we will then read the value from the `{DATA_FOLDER}/uuid` file. + // note that as the file is created by the platform AFTER apm init, the file + // will not be present at first startup, but there is nothing we can really do about that. + const uuidFromConfig = this.rawKibanaConfig?.server?.uuid; + if (uuidFromConfig) { + return { + globalLabels: { + kibana_uuid: uuidFromConfig, + }, + }; } - return { - // Headers & body may contain sensitive info - captureHeaders: false, - captureBody: 'off', - }; + const dataPath: string = this.rawKibanaConfig?.path?.data || getDataPath(); + try { + const filename = join(dataPath, 'uuid'); + const uuid = readFileSync(filename, 'utf-8'); + if (!uuid) { + return {}; + } + + return { + globalLabels: { + kibana_uuid: uuid, + }, + }; + } catch (e) { + if (e.code === 'ENOENT') { + return {}; + } + + throw e; + } } - private getCIConfig(): ApmAgentConfig { + /** + * When running Kibana with ELASTIC_APM_ENVIRONMENT=ci we attempt to grab + * some environment variables we populate in CI related to the build under test + */ + private getCiConfig(): ApmAgentConfig { if (process.env.ELASTIC_APM_ENVIRONMENT !== 'ci') { return {}; } @@ -152,17 +210,30 @@ export class ApmConfiguration { }; } - private getGitRev() { + /** + * When running from the distributable pull the build sha from the package.json + * file. Otherwise attempt to read the current HEAD sha using `git`. + */ + private getGitConfig() { if (this.isDistributable) { - return this.pkgBuild.sha; + return { + globalLabels: { + git_rev: this.pkgBuild.sha, + }, + }; } + try { - return execSync('git rev-parse --short HEAD', { - encoding: 'utf-8' as BufferEncoding, - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - } catch (e) { - return null; + return { + globalLabels: { + git_rev: execSync('git rev-parse --short HEAD', { + encoding: 'utf-8' as BufferEncoding, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(), + }, + }; + } catch { + return {}; } } } diff --git a/packages/kbn-apm-config-loader/src/types.ts b/packages/kbn-apm-config-loader/src/types.ts index f79db252edde..2e80631638e4 100644 --- a/packages/kbn-apm-config-loader/src/types.ts +++ b/packages/kbn-apm-config-loader/src/types.ts @@ -10,4 +10,20 @@ // but it's not exported, and using ts tricks to retrieve the type via Parameters[0] // causes errors in the generated .d.ts file because of esModuleInterop and the fact that the apm module // is just exporting an instance of the `ApmAgent` type. -export type ApmAgentConfig = Record; +export interface ApmAgentConfig { + active?: boolean; + environment?: string; + serviceName?: string; + serviceVersion?: string; + serverUrl?: string; + secretToken?: string; + logUncaughtExceptions?: boolean; + globalLabels?: Record; + centralConfig?: boolean; + metricsInterval?: string; + captureSpanStackTraces?: boolean; + transactionSampleRate?: number; + breakdownMetrics?: boolean; + captureHeaders?: boolean; + captureBody?: 'off' | 'all' | 'errors' | 'transactions'; +} diff --git a/packages/kbn-dev-utils/src/certs.ts b/packages/kbn-dev-utils/src/certs.ts index ca1e2d69b132..9d1a6077d53c 100644 --- a/packages/kbn-dev-utils/src/certs.ts +++ b/packages/kbn-dev-utils/src/certs.ts @@ -8,7 +8,7 @@ import { resolve } from 'path'; -export const CA_CERT_PATH = resolve(__dirname, '../certs/ca.crt'); +export const CA_CERT_PATH = process.env.TEST_CA_CERT_PATH || resolve(__dirname, '../certs/ca.crt'); export const ES_KEY_PATH = resolve(__dirname, '../certs/elasticsearch.key'); export const ES_CERT_PATH = resolve(__dirname, '../certs/elasticsearch.crt'); export const ES_P12_PATH = resolve(__dirname, '../certs/elasticsearch.p12'); diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 6627b644daec..4524cbe8912c 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -5,7 +5,7 @@ pageLoadAssetSize: apmOss: 18996 bfetch: 51874 canvas: 1066647 - charts: 195358 + charts: 95000 cloud: 21076 console: 46091 core: 432925 @@ -16,7 +16,7 @@ pageLoadAssetSize: data: 900000 dataEnhanced: 50420 devTools: 38637 - discover: 105145 + discover: 99999 discoverEnhanced: 42730 embeddable: 312874 embeddableEnhanced: 41145 @@ -112,3 +112,4 @@ pageLoadAssetSize: visTypePie: 35583 expressionRevealImage: 25675 cases: 144442 + userSetup: 18532 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 1f1e33d3dda7..a83ca8ed2e92 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 @@ -59,6 +59,61 @@ OptimizerConfig { ], "cache": true, "dist": false, + "filteredBundles": Array [ + Bundle { + "banner": undefined, + "cache": BundleCache { + "path": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public/.kbn-optimizer-cache, + "state": undefined, + }, + "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar, + "id": "bar", + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/kibana.json, + "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public, + "pageLoadAssetSizeLimit": undefined, + "publicDirNames": Array [ + "public", + ], + "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, + "type": "plugin", + }, + Bundle { + "banner": undefined, + "cache": BundleCache { + "path": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public/.kbn-optimizer-cache, + "state": undefined, + }, + "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo, + "id": "foo", + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/kibana.json, + "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public, + "pageLoadAssetSizeLimit": undefined, + "publicDirNames": Array [ + "public", + ], + "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, + "type": "plugin", + }, + Bundle { + "banner": "/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. + * Licensed under the Elastic License 2.0; you may not use this file except in compliance with the Elastic License 2.0. */ +", + "cache": BundleCache { + "path": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/target/public/.kbn-optimizer-cache, + "state": undefined, + }, + "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz, + "id": "baz", + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/kibana.json, + "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/target/public, + "pageLoadAssetSizeLimit": undefined, + "publicDirNames": Array [ + "public", + ], + "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, + "type": "plugin", + }, + ], "inspectWorkers": false, "maxWorkerCount": 1, "plugins": Array [ diff --git a/packages/kbn-optimizer/src/limits.ts b/packages/kbn-optimizer/src/limits.ts index 4479e0acc097..b2f8b6fda6c6 100644 --- a/packages/kbn-optimizer/src/limits.ts +++ b/packages/kbn-optimizer/src/limits.ts @@ -86,7 +86,7 @@ export function updateBundleLimits({ limitsPath, }: UpdateBundleLimitsOptions) { const limits = readLimits(limitsPath); - const metrics: CiStatsMetric[] = config.bundles + const metrics: CiStatsMetric[] = config.filteredBundles .map((bundle) => JSON.parse(Fs.readFileSync(Path.resolve(bundle.outputDir, 'metrics.json'), 'utf-8')) ) diff --git a/packages/kbn-optimizer/src/optimizer/bundle_cache.ts b/packages/kbn-optimizer/src/optimizer/bundle_cache.ts index c63ef2ff2fe8..375e6a9a3d2b 100644 --- a/packages/kbn-optimizer/src/optimizer/bundle_cache.ts +++ b/packages/kbn-optimizer/src/optimizer/bundle_cache.ts @@ -45,7 +45,7 @@ export function getBundleCacheEvent$( const eligibleBundles: Bundle[] = []; const bundleRefs = BundleRefs.fromBundles(config.bundles); - for (const bundle of config.bundles) { + for (const bundle of config.filteredBundles) { if (!config.cache) { events.push({ type: 'bundle not cached', diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts b/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts index 8becc76a23ca..55d267b72951 100644 --- a/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts +++ b/packages/kbn-optimizer/src/optimizer/optimizer_config.test.ts @@ -432,9 +432,10 @@ describe('OptimizerConfig::create()', () => { expect(config).toMatchInlineSnapshot(` OptimizerConfig { - "bundles": Symbol(filtered bundles), + "bundles": Symbol(focused bundles), "cache": Symbol(parsed cache), "dist": Symbol(parsed dist), + "filteredBundles": Symbol(filtered bundles), "inspectWorkers": Symbol(parsed inspect workers), "maxWorkerCount": Symbol(parsed max worker count), "plugins": Symbol(new platform plugins), diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_config.ts b/packages/kbn-optimizer/src/optimizer/optimizer_config.ts index 2dbe48c15483..13c82260d34f 100644 --- a/packages/kbn-optimizer/src/optimizer/optimizer_config.ts +++ b/packages/kbn-optimizer/src/optimizer/optimizer_config.ts @@ -225,8 +225,12 @@ export class OptimizerConfig { ...getPluginBundles(plugins, options.repoRoot, options.outputRoot, limits), ]; + const focusedBundles = focusBundles(options.focus, bundles); + const filteredBundles = filterById(options.filters, focusedBundles); + return new OptimizerConfig( - filterById(options.filters, focusBundles(options.focus, bundles)), + focusedBundles, + filteredBundles, options.cache, options.watch, options.inspectWorkers, @@ -241,6 +245,7 @@ export class OptimizerConfig { constructor( public readonly bundles: Bundle[], + public readonly filteredBundles: Bundle[], public readonly cache: boolean, public readonly watch: boolean, public readonly inspectWorkers: boolean, diff --git a/packages/kbn-optimizer/src/optimizer/optimizer_state.ts b/packages/kbn-optimizer/src/optimizer/optimizer_state.ts index 16351339bceb..98cf2f0be406 100644 --- a/packages/kbn-optimizer/src/optimizer/optimizer_state.ts +++ b/packages/kbn-optimizer/src/optimizer/optimizer_state.ts @@ -143,7 +143,7 @@ export function createOptimizerStateSummarizer( } const offlineBundles: Bundle[] = []; - for (const bundle of config.bundles) { + for (const bundle of config.filteredBundles) { if (!onlineBundles.includes(bundle)) { offlineBundles.push(bundle); } diff --git a/packages/kbn-optimizer/src/report_optimizer_timings.ts b/packages/kbn-optimizer/src/report_optimizer_timings.ts index dcb3a0fba77b..e2eb06bd2b70 100644 --- a/packages/kbn-optimizer/src/report_optimizer_timings.ts +++ b/packages/kbn-optimizer/src/report_optimizer_timings.ts @@ -50,10 +50,10 @@ export function reportOptimizerTimings(log: ToolingLog, config: OptimizerConfig) id: 'overall time', ms: time, meta: { - optimizerBundleCount: config.bundles.length, + optimizerBundleCount: config.filteredBundles.length, optimizerBundleCacheCount: cachedBundles.size, optimizerBundleCachePct: Math.floor( - (cachedBundles.size / config.bundles.length) * 100 + (cachedBundles.size / config.filteredBundles.length) * 100 ), optimizerWatch: config.watch, optimizerProduction: config.dist, diff --git a/packages/kbn-plugin-helpers/src/tasks/optimize.ts b/packages/kbn-plugin-helpers/src/tasks/optimize.ts index 2478947e79f1..c0f984eb03fc 100644 --- a/packages/kbn-plugin-helpers/src/tasks/optimize.ts +++ b/packages/kbn-plugin-helpers/src/tasks/optimize.ts @@ -31,7 +31,7 @@ export async function optimize({ log, plugin, sourceDir, buildDir }: BuildContex pluginPaths: [sourceDir], cache: false, dist: true, - pluginScanDirs: [], + filter: [plugin.manifest.id], }); const target = Path.resolve(sourceDir, 'target'); diff --git a/rfcs/text/0013_saved_object_migrations.md b/rfcs/text/0013_saved_object_migrations.md index e5ff7616ccf8..0ca183f1fee4 100644 --- a/rfcs/text/0013_saved_object_migrations.md +++ b/rfcs/text/0013_saved_object_migrations.md @@ -257,9 +257,11 @@ Note: 6. Set a write block on the source index. This prevents any further writes from outdated nodes. 7. Create a new temporary index `.kibana_7.10.0_reindex_temp` with `dynamic: false` on the top-level mappings so that any kind of document can be written to the index. This allows us to write untransformed documents to the index which might have fields which have been removed from the latest mappings defined by the plugin. Define minimal mappings for the `migrationVersion` and `type` fields so that we're still able to search for outdated documents that need to be transformed. 1. Ignore errors if the target index already exists. -8. Reindex the source index into the new temporary index. - 1. Use `op_type=create` `conflicts=proceed` and `wait_for_completion=false` so that multiple instances can perform the reindex in parallel but only one write per document will succeed. - 2. Wait for the reindex task to complete. If reindexing doesn’t complete within the 60s timeout, log a warning for visibility and poll again. +8. Reindex the source index into the new temporary index using a 'client-side' reindex, by reading batches of documents from the source, migrating them, and indexing them into the temp index. + 1. Use `op_type=index` so that multiple instances can perform the reindex in parallel (last node running will override the documents, with no effect as the input data is the same) + 2. Ignore `version_conflict_engine_exception` exceptions as they just mean that another node was indexing the same documents + 3. If a `target_index_had_write_block` exception is encountered for all document of a batch, assume that another node already completed the temporary index reindex, and jump to the next step + 4. If a document transform throws an exception, add the document to a failure list and continue trying to transform all other documents (without writing them to the temp index). If any failures occured, log the complete list of documents that failed to transform, then fail the migration. 9. Clone the temporary index into the target index `.kibana_7.10.0_001`. Since any further writes will only happen against the cloned target index this prevents a lost delete from occuring where one instance finishes the migration and deletes a document and another instance's reindex operation re-creates the deleted document. 1. Set a write block on the temporary index 2. Clone the temporary index into the target index while specifying that the target index should have writes enabled. diff --git a/src/core/public/core_system.test.mocks.ts b/src/core/public/core_system.test.mocks.ts index afb8aec31ccc..c80c2e3f4977 100644 --- a/src/core/public/core_system.test.mocks.ts +++ b/src/core/public/core_system.test.mocks.ts @@ -19,6 +19,7 @@ import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { docLinksServiceMock } from './doc_links/doc_links_service.mock'; import { renderingServiceMock } from './rendering/rendering_service.mock'; import { integrationsServiceMock } from './integrations/integrations_service.mock'; +import { executionContextServiceMock } from './execution_context/execution_context_service.mock'; import { coreAppMock } from './core_app/core_app.mock'; export const MockInjectedMetadataService = injectedMetadataServiceMock.create(); @@ -111,6 +112,14 @@ jest.doMock('./integrations', () => ({ IntegrationsService: IntegrationsServiceConstructor, })); +export const MockExecutionContextService = executionContextServiceMock.create(); +export const ExecutionContextServiceConstructor = jest + .fn() + .mockImplementation(() => MockExecutionContextService); +jest.doMock('./execution_context', () => ({ + ExecutionContextService: ExecutionContextServiceConstructor, +})); + export const MockCoreApp = coreAppMock.create(); export const CoreAppConstructor = jest.fn().mockImplementation(() => MockCoreApp); jest.doMock('./core_app', () => ({ diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 8ead0f50785b..efafb25da27e 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -30,6 +30,7 @@ import { RenderingServiceConstructor, IntegrationsServiceConstructor, MockIntegrationsService, + MockExecutionContextService, CoreAppConstructor, MockCoreApp, } from './core_system.test.mocks'; @@ -182,6 +183,11 @@ describe('#setup()', () => { await setupCore(); expect(MockCoreApp.setup).toHaveBeenCalledTimes(1); }); + + it('calls executionContext.setup()', async () => { + await setupCore(); + expect(MockExecutionContextService.setup).toHaveBeenCalledTimes(1); + }); }); describe('#start()', () => { @@ -269,6 +275,11 @@ describe('#start()', () => { await startCore(); expect(MockCoreApp.start).toHaveBeenCalledTimes(1); }); + + it('calls executionContext.start()', async () => { + await startCore(); + expect(MockExecutionContextService.start).toHaveBeenCalledTimes(1); + }); }); describe('#stop()', () => { @@ -327,6 +338,14 @@ describe('#stop()', () => { expect(MockCoreApp.stop).toHaveBeenCalled(); }); + it('calls executionContext.stop()', () => { + const coreSystem = createCoreSystem(); + + expect(MockExecutionContextService.stop).not.toHaveBeenCalled(); + coreSystem.stop(); + expect(MockExecutionContextService.stop).toHaveBeenCalled(); + }); + it('clears the rootDomElement', async () => { const rootDomElement = document.createElement('div'); const coreSystem = createCoreSystem({ diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index e5dcd8f817a0..43e7d443f5c0 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -29,6 +29,7 @@ import { SavedObjectsService } from './saved_objects'; import { IntegrationsService } from './integrations'; import { DeprecationsService } from './deprecations'; import { CoreApp } from './core_app'; +import { ExecutionContextService } from './execution_context'; import type { InternalApplicationSetup, InternalApplicationStart } from './application/types'; interface Params { @@ -83,6 +84,7 @@ export class CoreSystem { private readonly integrations: IntegrationsService; private readonly coreApp: CoreApp; private readonly deprecations: DeprecationsService; + private readonly executionContext: ExecutionContextService; private readonly rootDomElement: HTMLElement; private readonly coreContext: CoreContext; private fatalErrorsSetup: FatalErrorsSetup | null = null; @@ -118,6 +120,7 @@ export class CoreSystem { this.application = new ApplicationService(); this.integrations = new IntegrationsService(); this.deprecations = new DeprecationsService(); + this.executionContext = new ExecutionContextService(); this.plugins = new PluginsService(this.coreContext, injectedMetadata.uiPlugins); this.coreApp = new CoreApp(this.coreContext); @@ -137,6 +140,7 @@ export class CoreSystem { const http = this.http.setup({ injectedMetadata, fatalErrors: this.fatalErrorsSetup }); const uiSettings = this.uiSettings.setup({ http, injectedMetadata }); const notifications = this.notifications.setup({ uiSettings }); + this.executionContext.setup(); const application = this.application.setup({ http }); this.coreApp.setup({ application, http, injectedMetadata, notifications }); @@ -201,6 +205,7 @@ export class CoreSystem { notifications, }); const deprecations = this.deprecations.start({ http }); + const executionContext = this.executionContext.start(); this.coreApp.start({ application, docLinks, http, notifications, uiSettings }); @@ -217,6 +222,7 @@ export class CoreSystem { uiSettings, fatalErrors, deprecations, + executionContext, }; await this.plugins.start(core); @@ -260,6 +266,7 @@ export class CoreSystem { this.i18n.stop(); this.application.stop(); this.deprecations.stop(); + this.executionContext.stop(); this.rootDomElement.textContent = ''; } } diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 6bb714e91383..305a06e60bc0 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -231,7 +231,7 @@ export class DocLinksService { ml: { guide: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/index.html`, aggregations: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-aggregation.html`, - anomalyDetection: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/xpack-ml.html`, + anomalyDetection: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-overview.html`, anomalyDetectionJobs: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-jobs.html`, anomalyDetectionConfiguringCategories: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-categories.html`, anomalyDetectionBucketSpan: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#bucket-span`, @@ -249,7 +249,7 @@ export class DocLinksService { customUrls: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-url.html`, dataFrameAnalytics: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics.html`, featureImportance: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-feature-importance.html`, - outlierDetectionRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-roc`, + outlierDetectionRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfa-finding-outliers.html#ml-dfanalytics-roc`, regressionEvaluation: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-regression-evaluation`, classificationAucRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-class-aucroc`, }, diff --git a/src/core/public/execution_context/execution_context_container.test.ts b/src/core/public/execution_context/execution_context_container.test.ts new file mode 100644 index 000000000000..a4ee355ab40a --- /dev/null +++ b/src/core/public/execution_context/execution_context_container.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { KibanaExecutionContext } from '../../types'; +import { + ExecutionContextContainer, + BAGGAGE_MAX_PER_NAME_VALUE_PAIRS, +} from './execution_context_container'; + +describe('KibanaExecutionContext', () => { + describe('toHeader', () => { + it('returns an escaped string representation of provided execution context', () => { + const context: KibanaExecutionContext = { + type: 'test-type', + name: 'test-name', + id: '42', + description: 'test-descripton', + }; + + const value = new ExecutionContextContainer(context).toHeader(); + expect(value).toMatchInlineSnapshot(` + Object { + "x-kbn-context": "%7B%22type%22%3A%22test-type%22%2C%22name%22%3A%22test-name%22%2C%22id%22%3A%2242%22%2C%22description%22%3A%22test-descripton%22%7D", + } + `); + }); + + it('trims a string representation of provided execution context if it is bigger max allowed size', () => { + const context: KibanaExecutionContext = { + type: 'test-type', + name: 'test-name', + id: '42', + description: 'long long test-descripton,'.repeat(1000), + }; + + const value = new ExecutionContextContainer(context).toHeader(); + expect(value).toMatchInlineSnapshot(` + Object { + "x-kbn-context": "%7B%22type%22%3A%22test-type%22%2C%22name%22%3A%22test-name%22%2C%22id%22%3A%2242%22%2C%22description%22%3A%22long%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test-descripton%2Clong%20long%20test", + } + `); + + expect(new Blob(Object.values(value)).size).toBeLessThanOrEqual( + BAGGAGE_MAX_PER_NAME_VALUE_PAIRS + ); + }); + + it('escapes the string representation of provided execution context', () => { + const context: KibanaExecutionContext = { + type: 'test-type', + name: 'test-name', + id: '42', + description: 'описание', + }; + + const value = new ExecutionContextContainer(context).toHeader(); + expect(value).toMatchInlineSnapshot(` + Object { + "x-kbn-context": "%7B%22type%22%3A%22test-type%22%2C%22name%22%3A%22test-name%22%2C%22id%22%3A%2242%22%2C%22description%22%3A%22%D0%BE%D0%BF%D0%B8%D1%81%D0%B0%D0%BD%D0%B8%D0%B5%22%7D", + } + `); + }); + }); +}); diff --git a/src/core/public/execution_context/execution_context_container.ts b/src/core/public/execution_context/execution_context_container.ts new file mode 100644 index 000000000000..9c8e3e269ec8 --- /dev/null +++ b/src/core/public/execution_context/execution_context_container.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { KibanaExecutionContext } from '../../types'; + +// Switch to the standard Baggage header +// https://github.com/elastic/apm-agent-rum-js/issues/1040 +const BAGGAGE_HEADER = 'x-kbn-context'; + +// Maximum number of bytes per a single name-value pair allowed by w3c spec +// https://w3c.github.io/baggage/ +export const BAGGAGE_MAX_PER_NAME_VALUE_PAIRS = 4096; + +// a single character can use up to 4 bytes +const MAX_BAGGAGE_LENGTH = BAGGAGE_MAX_PER_NAME_VALUE_PAIRS / 4; + +// Limits the header value to max allowed "baggage" header property name-value pair +// It will help us switch to the "baggage" header when it becomes the standard. +// The trimmed value in the logs is better than nothing. +function enforceMaxLength(header: string): string { + return header.slice(0, MAX_BAGGAGE_LENGTH); +} + +/** + * @public + */ +export interface IExecutionContextContainer { + toHeader: () => Record; +} + +export class ExecutionContextContainer implements IExecutionContextContainer { + readonly #context: Readonly; + constructor(context: Readonly) { + this.#context = context; + } + private toString(): string { + const value = JSON.stringify(this.#context); + // escape content as the description property might contain non-ASCII symbols + return enforceMaxLength(encodeURIComponent(value)); + } + toHeader() { + return { [BAGGAGE_HEADER]: this.toString() }; + } +} diff --git a/src/core/public/execution_context/execution_context_service.mock.ts b/src/core/public/execution_context/execution_context_service.mock.ts new file mode 100644 index 000000000000..d8148b0af807 --- /dev/null +++ b/src/core/public/execution_context/execution_context_service.mock.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { Plugin } from 'src/core/public'; +import type { ExecutionContextServiceStart } from './execution_context_service'; +import type { ExecutionContextContainer } from './execution_context_container'; + +const createContainerMock = () => { + const mock: jest.Mocked> = { + toHeader: jest.fn(), + }; + return mock; +}; +const createStartContractMock = () => { + const mock: jest.Mocked = { + create: jest.fn().mockReturnValue(createContainerMock()), + }; + return mock; +}; + +const createMock = (): jest.Mocked => ({ + setup: jest.fn(), + start: jest.fn(), + stop: jest.fn(), +}); + +export const executionContextServiceMock = { + create: createMock, + createStartContract: createStartContractMock, + createContainer: createContainerMock, +}; diff --git a/src/core/public/execution_context/execution_context_service.ts b/src/core/public/execution_context/execution_context_service.ts new file mode 100644 index 000000000000..934e68d15be0 --- /dev/null +++ b/src/core/public/execution_context/execution_context_service.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { CoreService, KibanaExecutionContext } from '../../types'; +import { + ExecutionContextContainer, + IExecutionContextContainer, +} from './execution_context_container'; + +/** + * @public + */ +export interface ExecutionContextServiceStart { + /** + * Creates a context container carrying the meta-data of a runtime operation. + * Provided meta-data will be propagated to Kibana and Elasticsearch servers. + * ```js + * const context = executionContext.create(...); + * http.fetch('/endpoint/', { context }); + * ``` + */ + create: (context: KibanaExecutionContext) => IExecutionContextContainer; +} + +export class ExecutionContextService implements CoreService { + setup() {} + start(): ExecutionContextServiceStart { + return { + create(context: KibanaExecutionContext) { + return new ExecutionContextContainer(context); + }, + }; + } + stop() {} +} diff --git a/src/core/public/execution_context/index.ts b/src/core/public/execution_context/index.ts new file mode 100644 index 000000000000..d0c8348d864e --- /dev/null +++ b/src/core/public/execution_context/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { KibanaExecutionContext } from '../../types'; +export { ExecutionContextService } from './execution_context_service'; +export type { ExecutionContextServiceStart } from './execution_context_service'; +export type { IExecutionContextContainer } from './execution_context_container'; diff --git a/src/core/public/http/fetch.test.ts b/src/core/public/http/fetch.test.ts index 1208df032ff6..67ec816d0843 100644 --- a/src/core/public/http/fetch.test.ts +++ b/src/core/public/http/fetch.test.ts @@ -15,6 +15,7 @@ import { first } from 'rxjs/operators'; import { Fetch } from './fetch'; import { BasePath } from './base_path'; import { HttpResponse, HttpFetchOptionsWithPath } from './types'; +import { executionContextServiceMock } from '../execution_context/execution_context_service.mock'; function delay(duration: number) { return new Promise((r) => setTimeout(r, duration)); @@ -227,6 +228,19 @@ describe('Fetch', () => { ); }); + it('should inject context headers if provided', async () => { + fetchMock.get('*', {}); + const executionContainerMock = executionContextServiceMock.createContainer(); + executionContainerMock.toHeader.mockReturnValueOnce({ 'x-kbn-context': 'value' }); + await fetchInstance.fetch('/my/path', { + context: executionContainerMock, + }); + + expect(fetchMock.lastOptions()!.headers).toMatchObject({ + 'x-kbn-context': 'value', + }); + }); + // Deprecated header used by legacy platform pre-7.7. Remove in 8.x. it('should not allow overwriting of kbn-system-api when asSystemRequest: true', async () => { fetchMock.get('*', {}); diff --git a/src/core/public/http/fetch.ts b/src/core/public/http/fetch.ts index 345fcecbda44..fb178a937e18 100644 --- a/src/core/public/http/fetch.ts +++ b/src/core/public/http/fetch.ts @@ -30,6 +30,7 @@ interface Params { const JSON_CONTENT = /^(application\/(json|x-javascript)|text\/(x-)?javascript|x-json)(;.*)?$/; const NDJSON_CONTENT = /^(application\/ndjson)(;.*)?$/; +const ZIP_CONTENT = /^(application\/zip)(;.*)?$/; const removedUndefined = (obj: Record | undefined) => { return omitBy(obj, (v) => v === undefined); @@ -123,6 +124,7 @@ export class Fetch { 'Content-Type': 'application/json', ...options.headers, 'kbn-version': this.params.kibanaVersion, + ...options.context?.toHeader(), }), }; @@ -153,7 +155,7 @@ export class Fetch { const contentType = response.headers.get('Content-Type') || ''; try { - if (NDJSON_CONTENT.test(contentType)) { + if (NDJSON_CONTENT.test(contentType) || ZIP_CONTENT.test(contentType)) { body = await response.blob(); } else if (JSON_CONTENT.test(contentType)) { body = await response.json(); diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index 73418eafccd7..ccf68201bc20 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -8,6 +8,7 @@ import { Observable } from 'rxjs'; import { MaybePromise } from '@kbn/utility-types'; +import type { IExecutionContextContainer } from '../execution_context'; /** @public */ export interface HttpSetup { @@ -270,6 +271,8 @@ export interface HttpFetchOptions extends HttpRequestInit { * response information. When `false`, the return type will just be the parsed response body. Defaults to `false`. */ asResponse?: boolean; + + context?: IExecutionContextContainer; } /** diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 9bf1a05abc34..b3dd3827352b 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -65,6 +65,7 @@ import { ApplicationSetup, Capabilities, ApplicationStart } from './application' import { DocLinksStart } from './doc_links'; import { SavedObjectsStart } from './saved_objects'; import { DeprecationsServiceStart } from './deprecations'; +import type { ExecutionContextServiceStart } from './execution_context'; export type { PackageInfo, @@ -185,6 +186,12 @@ export type { export type { DeprecationsServiceStart, ResolveDeprecationResponse } from './deprecations'; +export type { + IExecutionContextContainer, + ExecutionContextServiceStart, + KibanaExecutionContext, +} from './execution_context'; + export type { MountPoint, UnmountCallback, PublicUiSettingsParams } from './types'; export { URL_MAX_LENGTH } from './core_app'; @@ -271,6 +278,8 @@ export interface CoreStart { fatalErrors: FatalErrorsStart; /** {@link DeprecationsServiceStart} */ deprecations: DeprecationsServiceStart; + /** {@link ExecutionContextServiceStart} */ + executionContext: ExecutionContextServiceStart; /** * exposed temporarily until https://github.com/elastic/kibana/issues/41990 done * use *only* to retrieve config values. There is no way to set injected values diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index bd7623beba65..63b94ea4ac4e 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -25,6 +25,7 @@ import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; import { injectedMetadataServiceMock } from './injected_metadata/injected_metadata_service.mock'; import { deprecationsServiceMock } from './deprecations/deprecations_service.mock'; +import { executionContextServiceMock } from './execution_context/execution_context_service.mock'; export { chromeServiceMock } from './chrome/chrome_service.mock'; export { docLinksServiceMock } from './doc_links/doc_links_service.mock'; @@ -39,6 +40,7 @@ export { savedObjectsServiceMock } from './saved_objects/saved_objects_service.m export { scopedHistoryMock } from './application/scoped_history.mock'; export { applicationServiceMock } from './application/application_service.mock'; export { deprecationsServiceMock } from './deprecations/deprecations_service.mock'; +export { executionContextServiceMock } from './execution_context/execution_context_service.mock'; function createCoreSetupMock({ basePath = '', @@ -84,6 +86,7 @@ function createCoreStartMock({ basePath = '' } = {}) { getInjectedVar: injectedMetadataServiceMock.createStartContract().getInjectedVar, }, fatalErrors: fatalErrorsServiceMock.createStartContract(), + executionContext: executionContextServiceMock.createStartContract(), }; return mock; diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 49c895aa80fc..be3cff54aca8 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -140,5 +140,6 @@ export function createPluginStartContext< }, fatalErrors: deps.fatalErrors, deprecations: deps.deprecations, + executionContext: deps.executionContext, }; } diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index d7114f14e2f0..d62a4bcdd1e5 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -35,6 +35,7 @@ import { CoreSetup, CoreStart, PluginInitializerContext } from '..'; import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock'; import { deprecationsServiceMock } from '../deprecations/deprecations_service.mock'; +import { executionContextServiceMock } from '../execution_context/execution_context_service.mock'; export let mockPluginInitializers: Map; @@ -103,6 +104,7 @@ describe('PluginsService', () => { savedObjects: savedObjectsServiceMock.createStartContract(), fatalErrors: fatalErrorsServiceMock.createStartContract(), deprecations: deprecationsServiceMock.createStartContract(), + executionContext: executionContextServiceMock.createStartContract(), }; mockStartContext = { ...mockStartDeps, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index f18dfb02fd41..d23980ff55a2 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -433,6 +433,8 @@ export interface CoreStart { // (undocumented) docLinks: DocLinksStart; // (undocumented) + executionContext: ExecutionContextServiceStart; + // (undocumented) fatalErrors: FatalErrorsStart; // (undocumented) http: HttpStart; @@ -714,6 +716,11 @@ export interface ErrorToastOptions extends ToastOptions { toastMessage?: string; } +// @public (undocumented) +export interface ExecutionContextServiceStart { + create: (context: KibanaExecutionContext) => IExecutionContextContainer; +} + // @public export interface FatalErrorInfo { // (undocumented) @@ -752,6 +759,8 @@ export class HttpFetchError extends Error implements IHttpFetchError { export interface HttpFetchOptions extends HttpRequestInit { asResponse?: boolean; asSystemRequest?: boolean; + // (undocumented) + context?: IExecutionContextContainer; headers?: HttpHeadersInit; prependBasePath?: boolean; query?: HttpFetchQuery; @@ -887,6 +896,12 @@ export interface IBasePath { readonly serverBasePath: string; } +// @public (undocumented) +export interface IExecutionContextContainer { + // (undocumented) + toHeader: () => Record; +} + // @public export interface IExternalUrl { validateUrl(relativeOrAbsoluteUrl: string): URL | null; @@ -949,6 +964,15 @@ export interface IUiSettingsClient { set: (key: string, value: any) => Promise; } +// @public (undocumented) +export interface KibanaExecutionContext { + readonly description: string; + readonly id: string; + readonly name: string; + readonly type: string; + readonly url?: string; +} + // @public export type MountPoint = (element: T) => UnmountCallback; @@ -1663,6 +1687,6 @@ export interface UserProvidedValues { // Warnings were encountered during analysis: // -// src/core/public/core_system.ts:168:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts +// src/core/public/core_system.ts:172:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts ``` diff --git a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts index 4e4bc4a51a7a..ac793d960d03 100644 --- a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts +++ b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts @@ -10,6 +10,7 @@ import supertest from 'supertest'; import { REPO_ROOT } from '@kbn/dev-utils'; import { HttpService, InternalHttpServiceSetup } from '../../http'; import { contextServiceMock } from '../../context/context_service.mock'; +import { executionContextServiceMock } from '../../execution_context/execution_context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { Env } from '../../config'; import { getEnvOptions } from '../../config/mocks'; @@ -31,6 +32,7 @@ describe('CapabilitiesService', () => { server = createHttpServer(); httpSetup = await server.setup({ context: contextServiceMock.createSetupContract(), + executionContext: executionContextServiceMock.createInternalSetupContract(), }); service = new CapabilitiesService({ coreId, diff --git a/src/core/server/core_app/integration_tests/bundle_routes.test.ts b/src/core/server/core_app/integration_tests/bundle_routes.test.ts index fbe2e9285ba2..7c50e09b1246 100644 --- a/src/core/server/core_app/integration_tests/bundle_routes.test.ts +++ b/src/core/server/core_app/integration_tests/bundle_routes.test.ts @@ -10,6 +10,7 @@ import { resolve } from 'path'; import { readFile } from 'fs/promises'; import supertest from 'supertest'; import { contextServiceMock } from '../../context/context_service.mock'; +import { executionContextServiceMock } from '../../execution_context/execution_context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { HttpService, IRouter } from '../../http'; import { createHttpServer } from '../../http/test_utils'; @@ -53,6 +54,7 @@ describe('bundle routes', () => { it('serves images inside from the bundle path', async () => { const { server: innerServer, createRouter } = await server.setup({ context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), }); registerFooPluginRoute(createRouter('')); @@ -70,6 +72,7 @@ describe('bundle routes', () => { it('serves uncompressed js files', async () => { const { server: innerServer, createRouter } = await server.setup({ context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), }); registerFooPluginRoute(createRouter('')); @@ -87,6 +90,7 @@ describe('bundle routes', () => { it('returns 404 for files outside of the bundlePath', async () => { const { server: innerServer, createRouter } = await server.setup({ context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), }); registerFooPluginRoute(createRouter('')); @@ -100,6 +104,7 @@ describe('bundle routes', () => { it('returns 404 for non-existing files', async () => { const { server: innerServer, createRouter } = await server.setup({ context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), }); registerFooPluginRoute(createRouter('')); @@ -113,6 +118,7 @@ describe('bundle routes', () => { it('returns gzip version if present', async () => { const { server: innerServer, createRouter } = await server.setup({ context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), }); registerFooPluginRoute(createRouter('')); @@ -137,6 +143,7 @@ describe('bundle routes', () => { it('uses max-age cache-control', async () => { const { server: innerServer, createRouter } = await server.setup({ context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), }); registerFooPluginRoute(createRouter(''), { isDist: true }); @@ -155,6 +162,7 @@ describe('bundle routes', () => { it('uses etag cache-control', async () => { const { server: innerServer, createRouter } = await server.setup({ context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), }); registerFooPluginRoute(createRouter(''), { isDist: false }); diff --git a/src/core/server/elasticsearch/client/cluster_client.test.ts b/src/core/server/elasticsearch/client/cluster_client.test.ts index befad222030f..f96f39349887 100644 --- a/src/core/server/elasticsearch/client/cluster_client.test.ts +++ b/src/core/server/elasticsearch/client/cluster_client.test.ts @@ -55,14 +55,19 @@ describe('ClusterClient', () => { it('creates a single internal and scoped client during initialization', () => { const config = createConfig(); - - new ClusterClient(config, logger, 'custom-type', getAuthHeaders); + const getExecutionContextMock = jest.fn(); + new ClusterClient(config, logger, 'custom-type', getAuthHeaders, getExecutionContextMock); expect(configureClientMock).toHaveBeenCalledTimes(2); - expect(configureClientMock).toHaveBeenCalledWith(config, { logger, type: 'custom-type' }); expect(configureClientMock).toHaveBeenCalledWith(config, { logger, type: 'custom-type', + getExecutionContext: getExecutionContextMock, + }); + expect(configureClientMock).toHaveBeenCalledWith(config, { + logger, + type: 'custom-type', + getExecutionContext: getExecutionContextMock, scoped: true, }); }); diff --git a/src/core/server/elasticsearch/client/cluster_client.ts b/src/core/server/elasticsearch/client/cluster_client.ts index 278bc6f3b0a6..d164736cead0 100644 --- a/src/core/server/elasticsearch/client/cluster_client.ts +++ b/src/core/server/elasticsearch/client/cluster_client.ts @@ -10,6 +10,7 @@ import { Client } from '@elastic/elasticsearch'; import { Logger } from '../../logging'; import { GetAuthHeaders, Headers, isKibanaRequest, isRealRequest } from '../../http'; import { ensureRawRequest, filterHeaders } from '../../http/router'; +import type { IExecutionContextContainer } from '../../execution_context'; import { ScopeableRequest } from '../types'; import { ElasticsearchClient } from './types'; import { configureClient } from './configure_client'; @@ -54,6 +55,7 @@ export interface ICustomClusterClient extends IClusterClient { export class ClusterClient implements ICustomClusterClient { public readonly asInternalUser: Client; private readonly rootScopedClient: Client; + private readonly allowListHeaders: string[]; private isClosed = false; @@ -61,10 +63,18 @@ export class ClusterClient implements ICustomClusterClient { private readonly config: ElasticsearchClientConfig, logger: Logger, type: string, - private readonly getAuthHeaders: GetAuthHeaders = noop + private readonly getAuthHeaders: GetAuthHeaders = noop, + getExecutionContext: () => IExecutionContextContainer | undefined = noop ) { - this.asInternalUser = configureClient(config, { logger, type }); - this.rootScopedClient = configureClient(config, { logger, type, scoped: true }); + this.asInternalUser = configureClient(config, { logger, type, getExecutionContext }); + this.rootScopedClient = configureClient(config, { + logger, + type, + getExecutionContext, + scoped: true, + }); + + this.allowListHeaders = ['x-opaque-id', ...this.config.requestHeadersWhitelist]; } asScoped(request: ScopeableRequest) { @@ -90,10 +100,10 @@ export class ClusterClient implements ICustomClusterClient { const requestIdHeaders = isKibanaRequest(request) ? { 'x-opaque-id': request.id } : {}; const authHeaders = this.getAuthHeaders(request); - scopedHeaders = filterHeaders({ ...requestHeaders, ...requestIdHeaders, ...authHeaders }, [ - 'x-opaque-id', - ...this.config.requestHeadersWhitelist, - ]); + scopedHeaders = filterHeaders( + { ...requestHeaders, ...requestIdHeaders, ...authHeaders }, + this.allowListHeaders + ); } else { scopedHeaders = filterHeaders(request?.headers ?? {}, this.config.requestHeadersWhitelist); } diff --git a/src/core/server/elasticsearch/client/configure_client.test.ts b/src/core/server/elasticsearch/client/configure_client.test.ts index 2c3a203dbc49..924f1584c5f8 100644 --- a/src/core/server/elasticsearch/client/configure_client.test.ts +++ b/src/core/server/elasticsearch/client/configure_client.test.ts @@ -98,7 +98,7 @@ describe('configureClient', () => { const client = configureClient(config, { logger, type: 'test', scoped: false }); expect(ClientMock).toHaveBeenCalledTimes(1); - expect(ClientMock).toHaveBeenCalledWith(parsedOptions); + expect(ClientMock).toHaveBeenCalledWith(expect.objectContaining(parsedOptions)); expect(client).toBe(ClientMock.mock.results[0].value); }); diff --git a/src/core/server/elasticsearch/client/configure_client.ts b/src/core/server/elasticsearch/client/configure_client.ts index ae1a2f67b74c..ce4bd6fa2c59 100644 --- a/src/core/server/elasticsearch/client/configure_client.ts +++ b/src/core/server/elasticsearch/client/configure_client.ts @@ -8,18 +8,46 @@ import { Buffer } from 'buffer'; import { stringify } from 'querystring'; -import { ApiError, Client, RequestEvent, errors } from '@elastic/elasticsearch'; -import type { RequestBody } from '@elastic/elasticsearch/lib/Transport'; +import { ApiError, Client, RequestEvent, errors, Transport } from '@elastic/elasticsearch'; +import type { + RequestBody, + TransportRequestParams, + TransportRequestOptions, +} from '@elastic/elasticsearch/lib/Transport'; +import type { IExecutionContextContainer } from '../../execution_context'; import { Logger } from '../../logging'; import { parseClientOptions, ElasticsearchClientConfig } from './client_config'; +const noop = () => undefined; + export const configureClient = ( config: ElasticsearchClientConfig, - { logger, type, scoped = false }: { logger: Logger; type: string; scoped?: boolean } + { + logger, + type, + scoped = false, + getExecutionContext = noop, + }: { + logger: Logger; + type: string; + scoped?: boolean; + getExecutionContext?: () => IExecutionContextContainer | undefined; + } ): Client => { const clientOptions = parseClientOptions(config, scoped); + class KibanaTransport extends Transport { + request(params: TransportRequestParams, options?: TransportRequestOptions) { + const opts = options || {}; + const opaqueId = getExecutionContext()?.toString(); + if (opaqueId && !opts.opaqueId) { + // rewrites headers['x-opaque-id'] if it presents + opts.opaqueId = opaqueId; + } + return super.request(params, opts); + } + } - const client = new Client(clientOptions); + const client = new Client({ ...clientOptions, Transport: KibanaTransport }); addLogging(client, logger.get('query', type)); return client; diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index 8a6da8d251e3..791ae2ab7aba 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -15,6 +15,7 @@ import { configServiceMock, getEnvOptions } from '../config/mocks'; import { CoreContext } from '../core_context'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { httpServiceMock } from '../http/http_service.mock'; +import { executionContextServiceMock } from '../execution_context/execution_context_service.mock'; import { ElasticsearchConfig } from './elasticsearch_config'; import { ElasticsearchService } from './elasticsearch_service'; import { elasticsearchServiceMock } from './elasticsearch_service.mock'; @@ -28,6 +29,7 @@ let elasticsearchService: ElasticsearchService; const configService = configServiceMock.create(); const setupDeps = { http: httpServiceMock.createInternalSetupContract(), + executionContext: executionContextServiceMock.createInternalSetupContract(), }; configService.atPath.mockReturnValue( new BehaviorSubject({ @@ -274,12 +276,7 @@ describe('#start', () => { expect(clusterClient).toBe(mockClusterClientInstance); expect(MockClusterClient).toHaveBeenCalledTimes(1); - expect(MockClusterClient).toHaveBeenCalledWith( - expect.objectContaining(customConfig), - expect.objectContaining({ context: ['elasticsearch'] }), - 'custom-type', - expect.any(Function) - ); + expect(MockClusterClient.mock.calls[0][0]).toEqual(expect.objectContaining(customConfig)); }); it('creates a new client on each call', async () => { await elasticsearchService.setup(setupDeps); diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index 7da83145ccd4..deb2d49f7081 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -20,13 +20,15 @@ import { } from './legacy'; import { ClusterClient, ICustomClusterClient, ElasticsearchClientConfig } from './client'; import { ElasticsearchConfig, ElasticsearchConfigType } from './elasticsearch_config'; -import { InternalHttpServiceSetup, GetAuthHeaders } from '../http/'; +import type { InternalHttpServiceSetup, GetAuthHeaders } from '../http/'; +import type { InternalExecutionContextSetup, IExecutionContext } from '../execution_context'; import { InternalElasticsearchServiceSetup, InternalElasticsearchServiceStart } from './types'; import { pollEsNodesVersion } from './version_check/ensure_es_version'; import { calculateStatus$ } from './status'; interface SetupDeps { http: InternalHttpServiceSetup; + executionContext: InternalExecutionContextSetup; } /** @internal */ @@ -37,6 +39,7 @@ export class ElasticsearchService private stop$ = new Subject(); private kibanaVersion: string; private getAuthHeaders?: GetAuthHeaders; + private executionContextClient?: IExecutionContext; private createLegacyCustomClient?: ( type: string, @@ -60,6 +63,7 @@ export class ElasticsearchService const config = await this.config$.pipe(first()).toPromise(); this.getAuthHeaders = deps.http.getAuthHeaders; + this.executionContextClient = deps.executionContext; this.legacyClient = this.createLegacyClusterClient('data', config); this.client = this.createClusterClient('data', config); @@ -128,7 +132,8 @@ export class ElasticsearchService config, this.coreContext.logger.get('elasticsearch'), type, - this.getAuthHeaders + this.getAuthHeaders, + () => this.executionContextClient?.get() ); } diff --git a/src/core/server/execution_context/execution_context_config.ts b/src/core/server/execution_context/execution_context_config.ts new file mode 100644 index 000000000000..af6e7253433f --- /dev/null +++ b/src/core/server/execution_context/execution_context_config.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { TypeOf, schema } from '@kbn/config-schema'; +import { ServiceConfigDescriptor } from '../internal_types'; + +const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), +}); + +/** + * @internal + */ +export type ExecutionContextConfigType = TypeOf; + +export const config: ServiceConfigDescriptor = { + path: 'execution_context', + schema: configSchema, +}; diff --git a/src/core/server/execution_context/execution_context_container.test.ts b/src/core/server/execution_context/execution_context_container.test.ts new file mode 100644 index 000000000000..46a688c8abdf --- /dev/null +++ b/src/core/server/execution_context/execution_context_container.test.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { KibanaServerExecutionContext } from './execution_context_service'; +import { + ExecutionContextContainer, + getParentContextFrom, + BAGGAGE_HEADER, + BAGGAGE_MAX_PER_NAME_VALUE_PAIRS, +} from './execution_context_container'; + +describe('KibanaExecutionContext', () => { + describe('toString', () => { + it('returns a string representation of provided execution context', () => { + const context: KibanaServerExecutionContext = { + type: 'test-type', + name: 'test-name', + id: '42', + description: 'test-descripton', + requestId: '1234-5678', + }; + + const value = new ExecutionContextContainer(context).toString(); + expect(value).toMatchInlineSnapshot(`"1234-5678;kibana:test-type:42"`); + }); + + it('returns a limited representation if optional properties are omitted', () => { + const context: KibanaServerExecutionContext = { + requestId: '1234-5678', + }; + + const value = new ExecutionContextContainer(context).toString(); + expect(value).toMatchInlineSnapshot(`"1234-5678"`); + }); + + it('trims a string representation of provided execution context if it is bigger max allowed size', () => { + expect( + new Blob([ + new ExecutionContextContainer({ + requestId: '1234-5678'.repeat(1000), + }).toString(), + ]).size + ).toBeLessThanOrEqual(BAGGAGE_MAX_PER_NAME_VALUE_PAIRS); + + expect( + new Blob([ + new ExecutionContextContainer({ + type: 'test-type'.repeat(1000), + name: 'test-name', + id: '42'.repeat(1000), + description: 'test-descripton', + requestId: '1234-5678', + }).toString(), + ]).size + ).toBeLessThanOrEqual(BAGGAGE_MAX_PER_NAME_VALUE_PAIRS); + }); + }); + + describe('toJSON', () => { + it('returns a context object', () => { + const context: KibanaServerExecutionContext = { + type: 'test-type', + name: 'test-name', + id: '42', + description: 'test-descripton', + requestId: '1234-5678', + }; + + const value = new ExecutionContextContainer(context).toJSON(); + expect(value).toBe(context); + }); + }); +}); + +describe('getParentContextFrom', () => { + it('decodes provided header', () => { + const ctx = { id: '42' }; + const header = encodeURIComponent(JSON.stringify(ctx)); + expect(getParentContextFrom({ [BAGGAGE_HEADER]: header })).toEqual(ctx); + }); + + it('does not throw an exception if given not a valid value', () => { + expect(getParentContextFrom({ [BAGGAGE_HEADER]: 'value' })).toBeUndefined(); + expect(getParentContextFrom({ [BAGGAGE_HEADER]: '' })).toBeUndefined(); + expect(getParentContextFrom({})).toBeUndefined(); + + const ctx = { id: '42' }; + const header = encodeURIComponent(JSON.stringify(ctx)); + expect(getParentContextFrom({ [BAGGAGE_HEADER]: header.slice(0, -2) })).toBeUndefined(); + }); +}); diff --git a/src/core/server/execution_context/execution_context_container.ts b/src/core/server/execution_context/execution_context_container.ts new file mode 100644 index 000000000000..71bf4bb96e1b --- /dev/null +++ b/src/core/server/execution_context/execution_context_container.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { KibanaServerExecutionContext } from './execution_context_service'; +import type { KibanaExecutionContext } from '../../types'; + +// Switch to the standard Baggage header. blocked by +// https://github.com/elastic/apm-agent-nodejs/issues/2102 +export const BAGGAGE_HEADER = 'x-kbn-context'; + +export function getParentContextFrom( + headers: Record +): KibanaExecutionContext | undefined { + const header = headers[BAGGAGE_HEADER]; + return parseHeader(header); +} + +function parseHeader(header?: string): KibanaExecutionContext | undefined { + if (!header) return undefined; + try { + return JSON.parse(decodeURIComponent(header)); + } catch (e) { + return undefined; + } +} + +// Maximum number of bytes per a single name-value pair allowed by w3c spec +// https://w3c.github.io/baggage/ +export const BAGGAGE_MAX_PER_NAME_VALUE_PAIRS = 4096; + +// a single character can use up to 4 bytes +const MAX_BAGGAGE_LENGTH = BAGGAGE_MAX_PER_NAME_VALUE_PAIRS / 4; + +// Limits the header value to max allowed "baggage" header property name-value pair +// It will help us switch to the "baggage" header when it becomes the standard. +// The trimmed value in the logs is better than nothing. +function enforceMaxLength(header: string): string { + return header.slice(0, MAX_BAGGAGE_LENGTH); +} + +/** + * @public + */ +export interface IExecutionContextContainer { + toString(): string; + toJSON(): Readonly; +} + +export class ExecutionContextContainer implements IExecutionContextContainer { + readonly #context: Readonly; + constructor(context: Readonly) { + this.#context = context; + } + toString(): string { + const ctx = this.#context; + const contextStringified = ctx.type && ctx.id ? `kibana:${ctx.type}:${ctx.id}` : ''; + const result = contextStringified ? `${ctx.requestId};${contextStringified}` : ctx.requestId; + return enforceMaxLength(result); + } + toJSON(): Readonly { + return this.#context; + } +} diff --git a/src/core/server/execution_context/execution_context_service.mock.ts b/src/core/server/execution_context/execution_context_service.mock.ts new file mode 100644 index 000000000000..657805df273c --- /dev/null +++ b/src/core/server/execution_context/execution_context_service.mock.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + IExecutionContext, + InternalExecutionContextSetup, + ExecutionContextSetup, +} from './execution_context_service'; + +const createExecutionContextMock = () => { + const mock: jest.Mocked = { + set: jest.fn(), + reset: jest.fn(), + get: jest.fn(), + getParentContextFrom: jest.fn(), + }; + return mock; +}; +const createInternalSetupContractMock = () => { + const setupContract: jest.Mocked = createExecutionContextMock(); + return setupContract; +}; + +const createSetupContractMock = () => { + const mock: jest.Mocked = { + set: jest.fn(), + get: jest.fn(), + }; + return mock; +}; + +export const executionContextServiceMock = { + createInternalSetupContract: createInternalSetupContractMock, + createInternalStartContract: createInternalSetupContractMock, + createSetupContract: createSetupContractMock, + createStartContract: createSetupContractMock, +}; diff --git a/src/core/server/execution_context/execution_context_service.test.ts b/src/core/server/execution_context/execution_context_service.test.ts new file mode 100644 index 000000000000..9b9ab0f48bac --- /dev/null +++ b/src/core/server/execution_context/execution_context_service.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { BehaviorSubject } from 'rxjs'; +import { + ExecutionContextService, + InternalExecutionContextSetup, +} from './execution_context_service'; +import { mockCoreContext } from '../core_context.mock'; + +const delay = (ms: number = 100) => new Promise((resolve) => setTimeout(resolve, ms)); +describe('ExecutionContextService', () => { + describe('setup', () => { + let service: InternalExecutionContextSetup; + const core = mockCoreContext.create(); + core.configService.atPath.mockReturnValue(new BehaviorSubject({ enabled: true })); + beforeEach(() => { + service = new ExecutionContextService(core).setup(); + }); + + it('sets and gets a value in async context', async () => { + const chainA = Promise.resolve().then(async () => { + service.set({ + requestId: '0000', + }); + await delay(500); + return service.get(); + }); + + const chainB = Promise.resolve().then(async () => { + service.set({ + requestId: '1111', + }); + await delay(100); + return service.get(); + }); + + expect( + await Promise.all([chainA, chainB]).then((results) => + results.map((result) => result?.toJSON()) + ) + ).toEqual([ + { + requestId: '0000', + }, + { + requestId: '1111', + }, + ]); + }); + + it('sets and resets a value in async context', async () => { + const chainA = Promise.resolve().then(async () => { + service.set({ + requestId: '0000', + }); + await delay(500); + service.reset(); + return service.get(); + }); + + const chainB = Promise.resolve().then(async () => { + service.set({ + requestId: '1111', + }); + await delay(100); + return service.get(); + }); + + expect( + await Promise.all([chainA, chainB]).then((results) => + results.map((result) => result?.toJSON()) + ) + ).toEqual([ + undefined, + { + requestId: '1111', + }, + ]); + }); + }); + + describe('config', () => { + it('can be disabled', async () => { + const core = mockCoreContext.create(); + core.configService.atPath.mockReturnValue(new BehaviorSubject({ enabled: false })); + const service = new ExecutionContextService(core).setup(); + const chainA = await Promise.resolve().then(async () => { + service.set({ + requestId: '0000', + }); + await delay(100); + return service.get(); + }); + + expect(chainA).toBeUndefined(); + }); + + it('reacts to config changes', async () => { + const core = mockCoreContext.create(); + const config$ = new BehaviorSubject({ enabled: false }); + core.configService.atPath.mockReturnValue(config$); + const service = new ExecutionContextService(core).setup(); + function exec() { + return Promise.resolve().then(async () => { + service.set({ + requestId: '0000', + }); + await delay(100); + return service.get(); + }); + } + expect(await exec()).toBeUndefined(); + + config$.next({ + enabled: true, + }); + expect(await exec()).toBeDefined(); + + config$.next({ + enabled: false, + }); + + expect(await exec()).toBeUndefined(); + }); + }); +}); diff --git a/src/core/server/execution_context/execution_context_service.ts b/src/core/server/execution_context/execution_context_service.ts new file mode 100644 index 000000000000..95a854f84d14 --- /dev/null +++ b/src/core/server/execution_context/execution_context_service.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { AsyncLocalStorage } from 'async_hooks'; +import type { Subscription } from 'rxjs'; + +import type { CoreService, KibanaExecutionContext } from '../../types'; +import type { CoreContext } from '../core_context'; +import type { Logger } from '../logging'; +import type { ExecutionContextConfigType } from './execution_context_config'; + +import { + ExecutionContextContainer, + IExecutionContextContainer, + getParentContextFrom, +} from './execution_context_container'; + +/** + * @public + */ +export interface KibanaServerExecutionContext extends Partial { + requestId: string; +} + +/** + * @internal + */ +export interface IExecutionContext { + getParentContextFrom(headers: Record): KibanaExecutionContext | undefined; + set(context: Partial): void; + reset(): void; + get(): IExecutionContextContainer | undefined; +} + +/** + * @internal + */ +export type InternalExecutionContextSetup = IExecutionContext; + +/** + * @internal + */ +export type InternalExecutionContextStart = IExecutionContext; + +/** + * @public + */ +export interface ExecutionContextSetup { + /** + * Stores the meta-data of a runtime operation. + * Data are carried over all async operations automatically. + * The sequential calls merge provided "context" object shallowly. + **/ + set(context: Partial): void; + /** + * Retrieves an opearation meta-data for the current async context. + **/ + get(): IExecutionContextContainer | undefined; +} + +/** + * @public + */ +export type ExecutionContextStart = ExecutionContextSetup; + +export class ExecutionContextService + implements CoreService { + private readonly log: Logger; + private readonly asyncLocalStorage: AsyncLocalStorage; + private enabled = false; + private configSubscription?: Subscription; + + constructor(private readonly coreContext: CoreContext) { + this.log = coreContext.logger.get('execution_context'); + this.asyncLocalStorage = new AsyncLocalStorage(); + } + + setup(): InternalExecutionContextSetup { + this.configSubscription = this.coreContext.configService + .atPath('execution_context') + .subscribe((config) => { + this.enabled = config.enabled; + }); + + return { + getParentContextFrom, + set: this.set.bind(this), + reset: this.reset.bind(this), + get: this.get.bind(this), + }; + } + + start(): InternalExecutionContextStart { + return { + getParentContextFrom, + set: this.set.bind(this), + reset: this.reset.bind(this), + get: this.get.bind(this), + }; + } + + stop() { + this.enabled = false; + if (this.configSubscription) { + this.configSubscription.unsubscribe(); + this.configSubscription = undefined; + } + } + + private set(context: KibanaServerExecutionContext) { + if (!this.enabled) return; + const prevValue = this.asyncLocalStorage.getStore(); + // merges context objects shallowly. repeats the deafult logic of apm.setCustomContext(ctx) + const contextContainer = new ExecutionContextContainer({ ...prevValue?.toJSON(), ...context }); + // we have to use enterWith since Hapi lifecycle model is built on event emitters. + // therefore if we wrapped request handler in asyncLocalStorage.run(), we would lose context in other lifecycles. + this.asyncLocalStorage.enterWith(contextContainer); + this.log.trace(`stored the execution context: ${contextContainer.toJSON()}`); + } + + private reset() { + if (!this.enabled) return; + // @ts-expect-error "undefined" is not supported in type definitions, which is wrong + this.asyncLocalStorage.enterWith(undefined); + } + + private get(): IExecutionContextContainer | undefined { + if (!this.enabled) return; + return this.asyncLocalStorage.getStore(); + } +} diff --git a/src/core/server/execution_context/index.ts b/src/core/server/execution_context/index.ts new file mode 100644 index 000000000000..f8018c75995e --- /dev/null +++ b/src/core/server/execution_context/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { KibanaExecutionContext } from '../../types'; +export { ExecutionContextService } from './execution_context_service'; +export type { + InternalExecutionContextSetup, + InternalExecutionContextStart, + ExecutionContextSetup, + ExecutionContextStart, + IExecutionContext, + KibanaServerExecutionContext, +} from './execution_context_service'; +export type { IExecutionContextContainer } from './execution_context_container'; +export { config } from './execution_context_config'; diff --git a/src/core/server/execution_context/integration_tests/tracing.test.ts b/src/core/server/execution_context/integration_tests/tracing.test.ts new file mode 100644 index 000000000000..c9de5fb98eb0 --- /dev/null +++ b/src/core/server/execution_context/integration_tests/tracing.test.ts @@ -0,0 +1,542 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExecutionContextContainer } from '../../../public/execution_context/execution_context_container'; +import * as kbnTestServer from '../../../test_helpers/kbn_server'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const parentContext = { + type: 'test-type', + name: 'test-name', + id: '42', + description: 'test-description', +}; + +describe('trace', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let root: ReturnType; + beforeAll(async () => { + const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: jest.setTimeout, + }); + esServer = await startES(); + }); + + afterAll(async () => { + await esServer.stop(); + }); + + beforeEach(async () => { + root = kbnTestServer.createRootWithCorePlugins({ + plugins: { initialize: false }, + server: { + requestId: { + allowFromAnyIp: true, + }, + }, + }); + }, 30000); + + afterEach(async () => { + await root.shutdown(); + }); + + describe('x-opaque-id', () => { + it('passed to Elasticsearch unscoped client calls', async () => { + const { http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + const { headers } = await context.core.elasticsearch.client.asInternalUser.ping(); + return res.ok({ body: headers || {} }); + }); + + await root.start(); + + const myOpaqueId = 'my-opaque-id'; + const response = await kbnTestServer.request + .get(root, '/execution-context') + .set('x-opaque-id', myOpaqueId) + .expect(200); + + const header = response.body['x-opaque-id']; + expect(header).toBe(myOpaqueId); + }); + + it('passed to Elasticsearch scoped client calls', async () => { + const { http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + const { headers } = await context.core.elasticsearch.client.asCurrentUser.ping(); + return res.ok({ body: headers || {} }); + }); + + await root.start(); + + const myOpaqueId = 'my-opaque-id'; + const response = await kbnTestServer.request + .get(root, '/execution-context') + .set('x-opaque-id', myOpaqueId) + .expect(200); + + const header = response.body['x-opaque-id']; + expect(header).toBe(myOpaqueId); + }); + + it('generated and attached to Elasticsearch unscoped client calls if not specifed', async () => { + const { http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + const { headers } = await context.core.elasticsearch.client.asInternalUser.ping(); + return res.ok({ body: headers || {} }); + }); + + await root.start(); + + const response = await kbnTestServer.request.get(root, '/execution-context').expect(200); + + const header = response.body['x-opaque-id']; + expect(header).toEqual(expect.any(String)); + }); + + it('generated and attached to Elasticsearch scoped client calls if not specifed', async () => { + const { http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + const { headers } = await context.core.elasticsearch.client.asCurrentUser.ping(); + return res.ok({ body: headers || {} }); + }); + + await root.start(); + + const response = await kbnTestServer.request.get(root, '/execution-context').expect(200); + + const header = response.body['x-opaque-id']; + expect(header).toEqual(expect.any(String)); + }); + + it('can be overriden during Elasticsearch client call', async () => { + const { http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + const { headers } = await context.core.elasticsearch.client.asInternalUser.ping( + {}, + { + opaqueId: 'new-opaque-id', + } + ); + return res.ok({ body: headers || {} }); + }); + + await root.start(); + + const myOpaqueId = 'my-opaque-id'; + const response = await kbnTestServer.request + .get(root, '/execution-context') + .set('x-opaque-id', myOpaqueId) + .expect(200); + + const header = response.body['x-opaque-id']; + expect(header).toBe('new-opaque-id'); + }); + + describe('ExecutionContext Service is disabled', () => { + let rootExecutionContextDisabled: ReturnType; + beforeEach(async () => { + rootExecutionContextDisabled = kbnTestServer.createRootWithCorePlugins({ + execution_context: { enabled: false }, + plugins: { initialize: false }, + server: { + requestId: { + allowFromAnyIp: true, + }, + }, + }); + }, 30000); + + afterEach(async () => { + await rootExecutionContextDisabled.shutdown(); + }); + it('passed to Elasticsearch scoped client calls even if ExecutionContext Service is disabled', async () => { + const { http } = await rootExecutionContextDisabled.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + const { headers } = await context.core.elasticsearch.client.asCurrentUser.ping(); + return res.ok({ body: headers || {} }); + }); + + await rootExecutionContextDisabled.start(); + + const myOpaqueId = 'my-opaque-id'; + const response = await kbnTestServer.request + .get(rootExecutionContextDisabled, '/execution-context') + .set('x-opaque-id', myOpaqueId) + .expect(200); + + const header = response.body['x-opaque-id']; + expect(header).toBe(myOpaqueId); + }); + + it('does not pass context if ExecutionContext Service is disabled', async () => { + const { http, executionContext } = await rootExecutionContextDisabled.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + executionContext.set(parentContext); + const { headers } = await context.core.elasticsearch.client.asCurrentUser.ping(); + return res.ok({ + body: { context: executionContext.get()?.toJSON(), header: headers?.['x-opaque-id'] }, + }); + }); + + await rootExecutionContextDisabled.start(); + + const myOpaqueId = 'my-opaque-id'; + const response = await kbnTestServer.request + .get(rootExecutionContextDisabled, '/execution-context') + .set('x-opaque-id', myOpaqueId) + .expect(200); + + expect(response.body).toEqual({ + header: 'my-opaque-id', + }); + }); + }); + }); + + describe('execution context', () => { + it('sets execution context for a sync request handler', async () => { + const { executionContext, http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + executionContext.set(parentContext); + return res.ok({ body: executionContext.get() }); + }); + + await root.start(); + const response = await kbnTestServer.request.get(root, '/execution-context').expect(200); + expect(response.body).toEqual({ ...parentContext, requestId: expect.any(String) }); + }); + + it('sets execution context for an async request handler', async () => { + const { executionContext, http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + executionContext.set(parentContext); + await delay(100); + return res.ok({ body: executionContext.get() }); + }); + + await root.start(); + const response = await kbnTestServer.request.get(root, '/execution-context').expect(200); + expect(response.body).toEqual({ ...parentContext, requestId: expect.any(String) }); + }); + + it('execution context is uniq for sequential requests', async () => { + const { executionContext, http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + executionContext.set(parentContext); + await delay(100); + return res.ok({ body: executionContext.get() }); + }); + + await root.start(); + const responseA = await kbnTestServer.request.get(root, '/execution-context').expect(200); + const responseB = await kbnTestServer.request.get(root, '/execution-context').expect(200); + + expect(responseA.body).toEqual({ ...parentContext, requestId: expect.any(String) }); + expect(responseB.body).toEqual({ ...parentContext, requestId: expect.any(String) }); + expect(responseA.body.requestId).not.toBe(responseB.body.requestId); + }); + + it('execution context is uniq for concurrent requests', async () => { + const { executionContext, http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + let id = 2; + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + executionContext.set(parentContext); + await delay(id-- * 100); + return res.ok({ body: executionContext.get() }); + }); + + await root.start(); + const responseA = kbnTestServer.request.get(root, '/execution-context'); + const responseB = kbnTestServer.request.get(root, '/execution-context'); + const responseC = kbnTestServer.request.get(root, '/execution-context'); + + const [{ body: bodyA }, { body: bodyB }, { body: bodyC }] = await Promise.all([ + responseA, + responseB, + responseC, + ]); + expect(bodyA.requestId).toBeDefined(); + expect(bodyB.requestId).toBeDefined(); + expect(bodyC.requestId).toBeDefined(); + + expect(bodyA.requestId).not.toBe(bodyB.requestId); + expect(bodyB.requestId).not.toBe(bodyC.requestId); + expect(bodyA.requestId).not.toBe(bodyC.requestId); + }); + + it('execution context is uniq for concurrent requests when "x-opaque-id" provided', async () => { + const { executionContext, http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + let id = 2; + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + executionContext.set(parentContext); + await delay(id-- * 100); + return res.ok({ body: executionContext.get() }); + }); + + await root.start(); + const responseA = kbnTestServer.request + .get(root, '/execution-context') + .set('x-opaque-id', 'req-1'); + const responseB = kbnTestServer.request + .get(root, '/execution-context') + .set('x-opaque-id', 'req-2'); + const responseC = kbnTestServer.request + .get(root, '/execution-context') + .set('x-opaque-id', 'req-3'); + + const [{ body: bodyA }, { body: bodyB }, { body: bodyC }] = await Promise.all([ + responseA, + responseB, + responseC, + ]); + expect(bodyA.requestId).toBe('req-1'); + expect(bodyB.requestId).toBe('req-2'); + expect(bodyC.requestId).toBe('req-3'); + }); + + it('parses the parent context if present', async () => { + const { executionContext, http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, (context, req, res) => + res.ok({ body: executionContext.get() }) + ); + + await root.start(); + const response = await kbnTestServer.request + .get(root, '/execution-context') + .set(new ExecutionContextContainer(parentContext).toHeader()) + .expect(200); + + expect(response.body).toEqual({ ...parentContext, requestId: expect.any(String) }); + }); + + it('execution context is the same for all the lifecycle events', async () => { + const { executionContext, http } = await root.setup(); + const { + createRouter, + registerOnPreRouting, + registerOnPreAuth, + registerAuth, + registerOnPostAuth, + registerOnPreResponse, + } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + return res.ok({ body: executionContext.get()?.toJSON() }); + }); + + let onPreRoutingContext; + registerOnPreRouting((request, response, t) => { + onPreRoutingContext = executionContext.get()?.toJSON(); + return t.next(); + }); + + let onPreAuthContext; + registerOnPreAuth((request, response, t) => { + onPreAuthContext = executionContext.get()?.toJSON(); + return t.next(); + }); + + let authContext; + registerAuth((request, response, t) => { + authContext = executionContext.get()?.toJSON(); + return t.authenticated(); + }); + + let onPostAuthContext; + registerOnPostAuth((request, response, t) => { + onPostAuthContext = executionContext.get()?.toJSON(); + return t.next(); + }); + + let onPreResponseContext; + registerOnPreResponse((request, response, t) => { + onPreResponseContext = executionContext.get()?.toJSON(); + return t.next(); + }); + + await root.start(); + const response = await kbnTestServer.request + .get(root, '/execution-context') + .set(new ExecutionContextContainer(parentContext).toHeader()) + .expect(200); + + expect(response.body).toEqual({ ...parentContext, requestId: expect.any(String) }); + + expect(response.body).toEqual(onPreRoutingContext); + expect(response.body).toEqual(onPreAuthContext); + expect(response.body).toEqual(authContext); + expect(response.body).toEqual(onPostAuthContext); + expect(response.body).toEqual(onPreResponseContext); + }); + + it('propagates context to Elasticsearch scoped client', async () => { + const { http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + const { headers } = await context.core.elasticsearch.client.asCurrentUser.ping(); + return res.ok({ body: headers || {} }); + }); + + await root.start(); + + const response = await kbnTestServer.request + .get(root, '/execution-context') + .set(new ExecutionContextContainer(parentContext).toHeader()) + .expect(200); + + const header = response.body['x-opaque-id']; + expect(header).toContain('kibana:test-type:42'); + }); + + it('propagates context to Elasticsearch unscoped client', async () => { + const { http } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + const { headers } = await context.core.elasticsearch.client.asInternalUser.ping(); + return res.ok({ body: headers || {} }); + }); + + await root.start(); + + const response = await kbnTestServer.request + .get(root, '/execution-context') + .set(new ExecutionContextContainer(parentContext).toHeader()) + .expect(200); + + const header = response.body['x-opaque-id']; + expect(header).toContain('kibana:test-type:42'); + }); + + it('a repeat call overwrites the old context', async () => { + const { http, executionContext } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + const newContext = { + type: 'new-type', + name: 'new-name', + id: '41', + description: 'new-description', + }; + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + executionContext.set(newContext); + const { headers } = await context.core.elasticsearch.client.asCurrentUser.ping(); + return res.ok({ body: headers || {} }); + }); + + await root.start(); + + const response = await kbnTestServer.request + .get(root, '/execution-context') + .set(new ExecutionContextContainer(parentContext).toHeader()) + .expect(200); + + const header = response.body['x-opaque-id']; + expect(header).toContain('kibana:new-type:41'); + }); + + it('does not affect "x-opaque-id" set by user', async () => { + const { http, executionContext } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + executionContext.set(parentContext); + const { headers } = await context.core.elasticsearch.client.asCurrentUser.ping(); + return res.ok({ body: headers || {} }); + }); + + await root.start(); + + const myOpaqueId = 'my-opaque-id'; + const response = await kbnTestServer.request + .get(root, '/execution-context') + .set('x-opaque-id', myOpaqueId) + .expect(200); + + const header = response.body['x-opaque-id']; + expect(header).toBe('my-opaque-id;kibana:test-type:42'); + }); + + it('does not break on non-ASCII characters within execution context', async () => { + const { http, executionContext } = await root.setup(); + const { createRouter } = http; + + const router = createRouter(''); + const ctx = { + type: 'test-type', + name: 'test-name', + id: '42', + description: 'какое-то описание', + }; + router.get({ path: '/execution-context', validate: false }, async (context, req, res) => { + executionContext.set(ctx); + const { headers } = await context.core.elasticsearch.client.asCurrentUser.ping(); + return res.ok({ body: headers || {} }); + }); + + await root.start(); + + const myOpaqueId = 'my-opaque-id'; + const response = await kbnTestServer.request + .get(root, '/execution-context') + .set('x-opaque-id', myOpaqueId) + .expect(200); + + const header = response.body['x-opaque-id']; + expect(header).toBe('my-opaque-id;kibana:test-type:42'); + }); + }); +}); diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index 55af02a08561..b09b200620fb 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -18,6 +18,7 @@ import { KibanaRequest } from './router'; import { Env } from '../config'; import { contextServiceMock } from '../context/context_service.mock'; +import { executionContextServiceMock } from '../execution_context/execution_context_service.mock'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { getEnvOptions, configServiceMock } from '../config/mocks'; import { httpServerMock } from './http_server.mocks'; @@ -34,6 +35,7 @@ const contextSetup = contextServiceMock.createSetupContract(); const setupDeps = { context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), }; configService.atPath.mockImplementation((path) => { diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index d43d86d587d0..85c035154a7a 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -22,6 +22,7 @@ import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; import { Logger, LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; +import type { InternalExecutionContextSetup } from '../execution_context'; import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth'; import { adoptToHapiOnPreAuth, OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth'; @@ -133,18 +134,23 @@ export class HttpServer { } } - public async setup(config: HttpConfig): Promise { + public async setup( + config: HttpConfig, + executionContext?: InternalExecutionContextSetup + ): Promise { const serverOptions = getServerOptions(config); const listenerOptions = getListenerOptions(config); this.server = createServer(serverOptions, listenerOptions); await this.server.register([HapiStaticFiles]); this.config = config; + // It's important to have setupRequestStateAssignment call the very first, otherwise context passing will be broken. + // That's the only reason why context initialization exists in this method. + this.setupRequestStateAssignment(config, executionContext); const basePathService = new BasePath(config.basePath, config.publicBaseUrl); this.setupBasePathRewrite(config, basePathService); this.setupConditionalCompression(config); this.setupResponseLogging(); - this.setupRequestStateAssignment(config); this.setupGracefulShutdownHandlers(); return { @@ -323,11 +329,22 @@ export class HttpServer { this.server.events.on('response', this.handleServerResponseEvent); } - private setupRequestStateAssignment(config: HttpConfig) { + private setupRequestStateAssignment( + config: HttpConfig, + executionContext?: InternalExecutionContextSetup + ) { this.server!.ext('onRequest', (request, responseToolkit) => { + const requestId = getRequestId(request, config.requestId); + + const parentContext = executionContext?.getParentContextFrom(request.headers); + executionContext?.set({ + ...parentContext, + requestId, + }); + request.app = { ...(request.app ?? {}), - requestId: getRequestId(request, config.requestId), + requestId, requestUuid: uuid.v4(), } as KibanaRequestState; return responseToolkit.continue; diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index ebb9ad971b84..d8a7b5427548 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -18,6 +18,7 @@ import { httpServerMock } from './http_server.mocks'; import { ConfigService, Env } from '../config'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { contextServiceMock } from '../context/context_service.mock'; +import { executionContextServiceMock } from '../execution_context/execution_context_service.mock'; import { config as cspConfig } from '../csp'; import { config as externalUrlConfig } from '../external_url'; @@ -45,6 +46,7 @@ const contextSetup = contextServiceMock.createSetupContract(); const setupDeps = { context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), }; const fakeHapiServer = { start: noop, diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 0d2850660768..0097aab82b21 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -11,6 +11,7 @@ import { first, map } from 'rxjs/operators'; import { pick } from '@kbn/std'; import type { RequestHandlerContext } from 'src/core/server'; +import type { InternalExecutionContextSetup } from '../execution_context'; import { CoreService } from '../../types'; import { Logger, LoggerFactory } from '../logging'; import { ContextSetup } from '../context'; @@ -41,6 +42,7 @@ import { interface SetupDeps { context: ContextSetup; + executionContext: InternalExecutionContextSetup; } /** @internal */ @@ -90,7 +92,10 @@ export class HttpService const notReadyServer = await this.setupNotReadyService({ config, context: deps.context }); - const { registerRouter, ...serverContract } = await this.httpServer.setup(config); + const { registerRouter, ...serverContract } = await this.httpServer.setup( + config, + deps.executionContext + ); registerCoreHandlers(serverContract, config, this.env); diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts index 6897160951aa..da8abe55b659 100644 --- a/src/core/server/http/integration_tests/lifecycle.test.ts +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -14,6 +14,7 @@ import { ensureRawRequest } from '../router'; import { HttpService } from '../http_service'; import { contextServiceMock } from '../../context/context_service.mock'; +import { executionContextServiceMock } from '../../execution_context/execution_context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; @@ -25,6 +26,7 @@ const contextSetup = contextServiceMock.createSetupContract(); const setupDeps = { context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), }; beforeEach(() => { diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts index c2023c5577d6..077e2f6e9c48 100644 --- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -18,6 +18,7 @@ import { IRouter, RouteRegistrar } from '../router'; import { configServiceMock } from '../../config/mocks'; import { contextServiceMock } from '../../context/context_service.mock'; +import { executionContextServiceMock } from '../../execution_context/execution_context_service.mock'; // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require('../../../../../package.json'); @@ -31,6 +32,7 @@ const xsrfDisabledTestPath = '/xsrf/test/route/disabled'; const kibanaName = 'my-kibana-name'; const setupDeps = { context: contextServiceMock.createSetupContract(), + executionContext: executionContextServiceMock.createInternalSetupContract(), }; describe('core lifecycle handlers', () => { diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts index dfc47098724c..ecacbf0bfa0c 100644 --- a/src/core/server/http/integration_tests/request.test.ts +++ b/src/core/server/http/integration_tests/request.test.ts @@ -15,6 +15,7 @@ import supertest from 'supertest'; import { HttpService } from '../http_service'; import { contextServiceMock } from '../../context/context_service.mock'; +import { executionContextServiceMock } from '../../execution_context/execution_context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; import { schema } from '@kbn/config-schema'; @@ -26,6 +27,7 @@ const contextSetup = contextServiceMock.createSetupContract(); const setupDeps = { context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), }; beforeEach(() => { diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index 354ab1c65d56..1b2b0b966d3a 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -12,6 +12,7 @@ import supertest from 'supertest'; import { schema } from '@kbn/config-schema'; import { contextServiceMock } from '../../context/context_service.mock'; +import { executionContextServiceMock } from '../../execution_context/execution_context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; import { HttpService } from '../http_service'; @@ -24,6 +25,7 @@ const contextSetup = contextServiceMock.createSetupContract(); const setupDeps = { context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), }; beforeEach(() => { diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 77946e15ef68..d2a4b4bff339 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -78,6 +78,16 @@ export type { ConfigUsageData, }; +import type { ExecutionContextSetup, ExecutionContextStart } from './execution_context'; + +export type { + ExecutionContextSetup, + ExecutionContextStart, + IExecutionContextContainer, + KibanaServerExecutionContext, + KibanaExecutionContext, +} from './execution_context'; + export { bootstrap } from './bootstrap'; export type { Capabilities, @@ -475,6 +485,8 @@ export interface CoreSetup { beforeEach(async () => { server = createHttpServer(); const contextSetup = contextServiceMock.createSetupContract(); - const httpSetup = await server.setup({ context: contextSetup }); + const httpSetup = await server.setup({ + context: contextSetup, + executionContext: executionContextServiceMock.createInternalSetupContract(), + }); hapiServer = httpSetup.server; router = httpSetup.createRouter('/'); collector = new ServerMetricsCollector(hapiServer); diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 0d52ff64499c..ff844f44aede 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -30,6 +30,7 @@ import { statusServiceMock } from './status/status_service.mock'; import { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_service.mock'; import { i18nServiceMock } from './i18n/i18n_service.mock'; import { deprecationsServiceMock } from './deprecations/deprecations_service.mock'; +import { executionContextServiceMock } from './execution_context/execution_context_service.mock'; export { configServiceMock } from './config/mocks'; export { httpServerMock } from './http/http_server.mocks'; @@ -51,6 +52,7 @@ export { capabilitiesServiceMock } from './capabilities/capabilities_service.moc export { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_service.mock'; export { i18nServiceMock } from './i18n/i18n_service.mock'; export { deprecationsServiceMock } from './deprecations/deprecations_service.mock'; +export { executionContextServiceMock } from './execution_context/execution_context_service.mock'; type MockedPluginInitializerConfig = jest.Mocked['config']>; @@ -144,6 +146,7 @@ function createCoreSetupMock({ logging: loggingServiceMock.createSetupContract(), metrics: metricsServiceMock.createSetupContract(), deprecations: deprecationsServiceMock.createSetupContract(), + executionContext: executionContextServiceMock.createInternalSetupContract(), getStartServices: jest .fn, object, any]>, []>() .mockResolvedValue([createCoreStartMock(), pluginStartDeps, pluginStartContract]), @@ -161,6 +164,7 @@ function createCoreStartMock() { savedObjects: savedObjectsServiceMock.createStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), coreUsageData: coreUsageDataServiceMock.createStartContract(), + executionContext: executionContextServiceMock.createInternalStartContract(), }; return mock; @@ -182,6 +186,7 @@ function createInternalCoreSetupMock() { logging: loggingServiceMock.createInternalSetupContract(), metrics: metricsServiceMock.createInternalSetupContract(), deprecations: deprecationsServiceMock.createInternalSetupContract(), + executionContext: executionContextServiceMock.createInternalSetupContract(), }; return setupDeps; } @@ -195,6 +200,7 @@ function createInternalCoreStartMock() { savedObjects: savedObjectsServiceMock.createInternalStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), coreUsageData: coreUsageDataServiceMock.createStartContract(), + executionContext: executionContextServiceMock.createInternalStartContract(), }; return startDeps; } diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index c466eb2b9ee0..70fd1c60efa6 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -115,6 +115,7 @@ export function createPluginSetupContext( elasticsearch: { legacy: deps.elasticsearch.legacy, }, + executionContext: deps.executionContext, http: { createCookieSessionStorageFactory: deps.http.createCookieSessionStorageFactory, registerRouteHandlerContext: < @@ -195,6 +196,7 @@ export function createPluginStartContext( createClient: deps.elasticsearch.createClient, legacy: deps.elasticsearch.legacy, }, + executionContext: deps.executionContext, http: { auth: deps.http.auth, basePath: deps.http.basePath, diff --git a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts index 8ff9591798fd..57a1f54925d4 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts @@ -6,29 +6,96 @@ * Side Public License, v 1. */ -import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import * as Either from 'fp-ts/Either'; import { errors as EsErrors } from '@elastic/elasticsearch'; -jest.mock('./catch_retryable_es_client_errors'); import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; import { bulkOverwriteTransformedDocuments } from './bulk_overwrite_transformed_documents'; +jest.mock('./catch_retryable_es_client_errors'); + describe('bulkOverwriteTransformedDocuments', () => { beforeEach(() => { jest.clearAllMocks(); }); - // Create a mock client that rejects all methods with a 503 status code - // response. - const retryableError = new EsErrors.ResponseError( - elasticsearchClientMock.createApiResponse({ - statusCode: 503, - body: { error: { type: 'es_type', reason: 'es_reason' } }, - }) - ); - const client = elasticsearchClientMock.createInternalClient( - elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) - ); + it('resolves with `right:bulk_index_succeeded` if no error is encountered', async () => { + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + items: [ + { + index: { + _index: '.dolly', + }, + }, + { + index: { + _index: '.dolly', + }, + }, + ], + }) + ); + + const task = bulkOverwriteTransformedDocuments({ + client, + index: 'new_index', + transformedDocs: [], + refresh: 'wait_for', + }); + + const result = await task(); + + expect(Either.isRight(result)).toBe(true); + expect((result as Either.Right).right).toEqual('bulk_index_succeeded'); + }); + + it('resolves with `right:bulk_index_succeeded` if version conflict errors are encountered', async () => { + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + items: [ + { + index: { + _index: '.dolly', + }, + }, + { + index: { + error: { + type: 'version_conflict_engine_exception', + reason: 'reason', + }, + }, + }, + ], + }) + ); + + const task = bulkOverwriteTransformedDocuments({ + client, + index: 'new_index', + transformedDocs: [], + refresh: 'wait_for', + }); + + const result = await task(); + + expect(Either.isRight(result)).toBe(true); + expect((result as Either.Right).right).toEqual('bulk_index_succeeded'); + }); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + // Create a mock client that rejects all methods with a 503 status code response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + const task = bulkOverwriteTransformedDocuments({ client, index: 'new_index', @@ -43,4 +110,93 @@ describe('bulkOverwriteTransformedDocuments', () => { expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); }); + + it('resolves with `left:target_index_had_write_block` if all errors are write block exceptions', async () => { + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + items: [ + { + index: { + error: { + type: 'cluster_block_exception', + reason: + 'index [.kibana_9000] blocked by: [FORBIDDEN/8/moving to block index write (api)]', + }, + }, + }, + { + index: { + error: { + type: 'cluster_block_exception', + reason: + 'index [.kibana_9000] blocked by: [FORBIDDEN/8/moving to block index write (api)]', + }, + }, + }, + ], + }) + ); + + const task = bulkOverwriteTransformedDocuments({ + client, + index: 'new_index', + transformedDocs: [], + refresh: 'wait_for', + }); + + const result = await task(); + + expect(Either.isLeft(result)).toBe(true); + expect((result as Either.Left).left).toEqual({ + type: 'target_index_had_write_block', + }); + }); + + it('throws an error if any error is not a write block exceptions', async () => { + (catchRetryableEsClientErrors as jest.Mock).mockImplementation((e) => { + throw e; + }); + + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + items: [ + { + index: { + error: { + type: 'cluster_block_exception', + reason: + 'index [.kibana_9000] blocked by: [FORBIDDEN/8/moving to block index write (api)]', + }, + }, + }, + { + index: { + error: { + type: 'dolly_exception', + reason: 'because', + }, + }, + }, + { + index: { + error: { + type: 'cluster_block_exception', + reason: + 'index [.kibana_9000] blocked by: [FORBIDDEN/8/moving to block index write (api)]', + }, + }, + }, + ], + }) + ); + + const task = bulkOverwriteTransformedDocuments({ + client, + index: 'new_index', + transformedDocs: [], + refresh: 'wait_for', + }); + + await expect(task()).rejects.toThrow(); + }); }); diff --git a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts index 830a8efccc7e..4c0f8717576a 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts @@ -15,7 +15,9 @@ import { catchRetryableEsClientErrors, RetryableEsClientError, } from './catch_retryable_es_client_errors'; +import { isWriteBlockException } from './es_errors'; import { WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE } from './constants'; +import type { TargetIndexHadWriteBlock } from './index'; /** @internal */ export interface BulkOverwriteTransformedDocumentsParams { @@ -24,6 +26,7 @@ export interface BulkOverwriteTransformedDocumentsParams { transformedDocs: SavedObjectsRawDoc[]; refresh?: estypes.Refresh; } + /** * Write the up-to-date transformed documents to the index, overwriting any * documents that are still on their outdated version. @@ -34,7 +37,7 @@ export const bulkOverwriteTransformedDocuments = ({ transformedDocs, refresh = false, }: BulkOverwriteTransformedDocumentsParams): TaskEither.TaskEither< - RetryableEsClientError, + RetryableEsClientError | TargetIndexHadWriteBlock, 'bulk_index_succeeded' > => () => { return client @@ -71,12 +74,19 @@ export const bulkOverwriteTransformedDocuments = ({ .then((res) => { // Filter out version_conflict_engine_exception since these just mean // that another instance already updated these documents - const errors = (res.body.items ?? []).filter( - (item) => item.index?.error?.type !== 'version_conflict_engine_exception' - ); + const errors = (res.body.items ?? []) + .filter((item) => item.index?.error) + .map((item) => item.index!.error!) + .filter(({ type }) => type !== 'version_conflict_engine_exception'); + if (errors.length === 0) { return Either.right('bulk_index_succeeded' as const); } else { + if (errors.every(isWriteBlockException)) { + return Either.left({ + type: 'target_index_had_write_block' as const, + }); + } throw new Error(JSON.stringify(errors)); } }) diff --git a/src/core/server/saved_objects/migrationsv2/actions/es_errors.test.ts b/src/core/server/saved_objects/migrationsv2/actions/es_errors.test.ts new file mode 100644 index 000000000000..c3a8c7a036a4 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/es_errors.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isIncompatibleMappingException, isWriteBlockException } from './es_errors'; + +describe('isWriteBlockError', () => { + it('returns true for a `index write` cluster_block_exception', () => { + expect( + isWriteBlockException({ + type: 'cluster_block_exception', + reason: `index [.kibana_dolly] blocked by: [FORBIDDEN/8/index write (api)]`, + }) + ).toEqual(true); + }); + it('returns true for a `moving to block index write` cluster_block_exception', () => { + expect( + isWriteBlockException({ + type: 'cluster_block_exception', + reason: `index [.kibana_dolly] blocked by: [FORBIDDEN/8/moving to block index write (api)]`, + }) + ).toEqual(true); + }); + it('returns false for incorrect type', () => { + expect( + isWriteBlockException({ + type: 'not_a_cluster_block_exception_at_all', + reason: `index [.kibana_dolly] blocked by: [FORBIDDEN/8/index write (api)]`, + }) + ).toEqual(false); + }); +}); + +describe('isIncompatibleMappingExceptionError', () => { + it('returns true for `strict_dynamic_mapping_exception` errors', () => { + expect( + isIncompatibleMappingException({ + type: 'strict_dynamic_mapping_exception', + reason: 'idk', + }) + ).toEqual(true); + }); + + it('returns true for `mapper_parsing_exception` errors', () => { + expect( + isIncompatibleMappingException({ + type: 'mapper_parsing_exception', + reason: 'idk', + }) + ).toEqual(true); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/es_errors.ts b/src/core/server/saved_objects/migrationsv2/actions/es_errors.ts new file mode 100644 index 000000000000..0d3c9fe3741a --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/es_errors.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export interface EsErrorCause { + type: string; + reason: string; +} + +export const isWriteBlockException = ({ type, reason }: EsErrorCause): boolean => { + return ( + type === 'cluster_block_exception' && + reason.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/.+ \(api\)\]/) !== null + ); +}; + +export const isIncompatibleMappingException = ({ type }: EsErrorCause): boolean => { + return type === 'strict_dynamic_mapping_exception' || type === 'mapper_parsing_exception'; +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts index 3fa4d59e383b..ecce5e954345 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts @@ -181,14 +181,17 @@ describe('migration actions', () => { { _source: { title: 'doc 3' } }, { _source: { title: 'doc 4' } }, ] as unknown) as SavedObjectsRawDoc[]; - await expect( - bulkOverwriteTransformedDocuments({ - client, - index: 'new_index_without_write_block', - transformedDocs: sourceDocs, - refresh: 'wait_for', - })() - ).rejects.toMatchObject(expect.anything()); + + const res = (await bulkOverwriteTransformedDocuments({ + client, + index: 'new_index_without_write_block', + transformedDocs: sourceDocs, + refresh: 'wait_for', + })()) as Either.Left; + + expect(res.left).toEqual({ + type: 'target_index_had_write_block', + }); }); it('resolves left index_not_found_exception when the index does not exist', async () => { expect.assertions(1); @@ -1094,6 +1097,7 @@ describe('migration actions', () => { return Either.right({ processedDocs }); }; } + const transformTask = transformDocs({ transformRawDocs: innerTransformRawDocs, outdatedDocuments: originalDocs, @@ -1496,7 +1500,7 @@ describe('migration actions', () => { } `); }); - it('rejects if there are errors', async () => { + it('resolves left if there are write_block errors', async () => { const newDocs = ([ { _source: { title: 'doc 5' } }, { _source: { title: 'doc 6' } }, @@ -1509,7 +1513,14 @@ describe('migration actions', () => { transformedDocs: newDocs, refresh: 'wait_for', })() - ).rejects.toMatchObject(expect.anything()); + ).resolves.toMatchInlineSnapshot(` + Object { + "_tag": "Left", + "left": Object { + "type": "target_index_had_write_block", + }, + } + `); }); }); }); diff --git a/src/core/server/saved_objects/migrationsv2/actions/integration_tests/es_errors.test.ts b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/es_errors.test.ts new file mode 100644 index 000000000000..baeef6b9d9f5 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/es_errors.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ElasticsearchClient } from '../../../../'; +import { InternalCoreStart } from '../../../../internal_types'; +import * as kbnTestServer from '../../../../../test_helpers/kbn_server'; +import { Root } from '../../../../root'; +import { isWriteBlockException } from '../es_errors'; +import { createIndex } from '../create_index'; +import { setWriteBlock } from '../set_write_block'; + +const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), +}); + +describe('Elasticsearch Errors', () => { + let root: Root; + let start: InternalCoreStart; + let client: ElasticsearchClient; + let esServer: kbnTestServer.TestElasticsearchUtils; + + beforeAll(async () => { + esServer = await startES(); + root = kbnTestServer.createRootWithCorePlugins({ + server: { + basePath: '/foo', + }, + }); + + await root.setup(); + start = await root.start(); + client = start.elasticsearch.client.asInternalUser; + + await createIndex({ + client, + indexName: 'existing_index_with_write_block', + mappings: { properties: {} }, + })(); + await setWriteBlock({ client, index: 'existing_index_with_write_block' })(); + }); + + afterAll(async () => { + await esServer.stop(); + await root.shutdown(); + }); + + describe('isWriteBlockException', () => { + it('correctly identify errors from index operations', async () => { + const res = await client.index( + { + index: 'existing_index_with_write_block', + id: 'some-id', + op_type: 'index', + body: { + hello: 'dolly', + }, + }, + { ignore: [403] } + ); + + expect(isWriteBlockException(res.body.error!)).toEqual(true); + }); + + it('correctly identify errors from create operations', async () => { + const res = await client.create( + { + index: 'existing_index_with_write_block', + id: 'some-id', + body: { + hello: 'dolly', + }, + }, + { ignore: [403] } + ); + + expect(isWriteBlockException(res.body.error!)).toEqual(true); + }); + + it('correctly identify errors from bulk index operations', async () => { + const res = await client.bulk({ + refresh: 'wait_for', + body: [ + { + index: { + _index: 'existing_index_with_write_block', + _id: 'some-id', + }, + }, + { + hello: 'dolly', + }, + ], + }); + + const cause = res.body.items[0].index!.error!; + + expect(isWriteBlockException(cause)).toEqual(true); + }); + + it('correctly identify errors from bulk create operations', async () => { + const res = await client.bulk({ + refresh: 'wait_for', + body: [ + { + create: { + _index: 'existing_index_with_write_block', + _id: 'some-id', + op_type: 'index', + }, + }, + { + hello: 'dolly', + }, + ], + }); + + const cause = res.body.items[0].create!.error!; + + expect(isWriteBlockException(cause)).toEqual(true); + }); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts index 18cf3350292b..cafc8f15f029 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts @@ -10,12 +10,14 @@ import * as TaskEither from 'fp-ts/lib/TaskEither'; import * as Option from 'fp-ts/lib/Option'; import { flow } from 'fp-ts/lib/function'; import { RetryableEsClientError } from './catch_retryable_es_client_errors'; -import type { IndexNotFound, WaitForReindexTaskFailure, TargetIndexHadWriteBlock } from './index'; +import type { IndexNotFound, TargetIndexHadWriteBlock } from './index'; import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; +import { isWriteBlockException, isIncompatibleMappingException } from './es_errors'; export interface IncompatibleMappingException { type: 'incompatible_mapping_exception'; } + export const waitForReindexTask = flow( waitForTask, TaskEither.chain( @@ -29,15 +31,6 @@ export const waitForReindexTask = flow( | WaitForTaskCompletionTimeout, 'reindex_succeeded' > => { - const failureIsAWriteBlock = ({ cause: { type, reason } }: WaitForReindexTaskFailure) => - type === 'cluster_block_exception' && - reason.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/index write \(api\)\]/); - - const failureIsIncompatibleMappingException = ({ - cause: { type, reason }, - }: WaitForReindexTaskFailure) => - type === 'strict_dynamic_mapping_exception' || type === 'mapper_parsing_exception'; - if (Option.isSome(res.error)) { if (res.error.value.type === 'index_not_found_exception') { return TaskEither.left({ @@ -48,9 +41,10 @@ export const waitForReindexTask = flow( throw new Error('Reindex failed with the following error:\n' + JSON.stringify(res.error)); } } else if (Option.isSome(res.failures)) { - if (res.failures.value.every(failureIsAWriteBlock)) { + const failureCauses = res.failures.value.map((failure) => failure.cause); + if (failureCauses.every(isWriteBlockException)) { return TaskEither.left({ type: 'target_index_had_write_block' as const }); - } else if (res.failures.value.every(failureIsIncompatibleMappingException)) { + } else if (failureCauses.every(isIncompatibleMappingException)) { return TaskEither.left({ type: 'incompatible_mapping_exception' as const }); } else { throw new Error( diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_concurrent_5k_foo.zip b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_concurrent_5k_foo.zip new file mode 100644 index 000000000000..46cc61cbe7b5 Binary files /dev/null and b/src/core/server/saved_objects/migrationsv2/integration_tests/archives/7.13.0_concurrent_5k_foo.zip differ diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/multiple_kibana_nodes.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/multiple_kibana_nodes.test.ts new file mode 100644 index 000000000000..6d98576581a2 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/multiple_kibana_nodes.test.ts @@ -0,0 +1,259 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; +import Util from 'util'; +import glob from 'glob'; +import { esTestConfig, kibanaServerTestUser } from '@kbn/test'; +import { kibanaPackageJson as pkg } from '@kbn/utils'; +import * as kbnTestServer from '../../../../test_helpers/kbn_server'; +import type { ElasticsearchClient } from '../../../elasticsearch'; +import { SavedObjectsType } from '../../types'; +import type { Root } from '../../../root'; + +const LOG_FILE_PREFIX = 'migration_test_multiple_kibana_nodes'; + +const asyncUnlink = Util.promisify(Fs.unlink); + +async function removeLogFiles() { + glob(Path.join(__dirname, `${LOG_FILE_PREFIX}_*.log`), (err, files) => { + files.forEach(async (file) => { + // ignore errors if it doesn't exist + await asyncUnlink(file).catch(() => void 0); + }); + }); +} + +function extractSortNumberFromId(id: string): number { + const parsedId = parseInt(id.split(':')[1], 10); // "foo:123" -> 123 + if (isNaN(parsedId)) { + throw new Error(`Failed to parse Saved Object ID [${id}]. Result is NaN`); + } + return parsedId; +} + +async function fetchDocs(esClient: ElasticsearchClient, index: string) { + const { body } = await esClient.search({ + index, + size: 10000, + body: { + query: { + bool: { + should: [ + { + term: { type: 'foo' }, + }, + ], + }, + }, + }, + }); + + return body.hits.hits + .map((h) => ({ + ...h._source, + id: h._id, + })) + .sort((a, b) => extractSortNumberFromId(a.id) - extractSortNumberFromId(b.id)); +} + +interface CreateRootConfig { + logFileName: string; +} + +function createRoot({ logFileName }: CreateRootConfig) { + return kbnTestServer.createRoot({ + elasticsearch: { + hosts: [esTestConfig.getUrl()], + username: kibanaServerTestUser.username, + password: kibanaServerTestUser.password, + }, + migrations: { + skip: false, + enableV2: true, + batchSize: 100, // fixture contains 5000 docs + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFileName, + layout: { + type: 'pattern', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + { + name: 'savedobjects-service', + appenders: ['file'], + level: 'debug', + }, + ], + }, + }); +} + +describe('migration v2', () => { + let esServer: kbnTestServer.TestElasticsearchUtils; + let rootA: Root; + let rootB: Root; + let rootC: Root; + + const migratedIndex = `.kibana_${pkg.version}_001`; + const fooType: SavedObjectsType = { + name: 'foo', + hidden: false, + mappings: { properties: { status: { type: 'text' } } }, + namespaceType: 'agnostic', + migrations: { + '7.14.0': (doc) => { + if (doc.attributes?.status) { + doc.attributes.status = doc.attributes.status.replace('unmigrated', 'migrated'); + } + return doc; + }, + }, + }; + + afterAll(async () => { + await new Promise((resolve) => setTimeout(resolve, 10000)); + }); + + beforeEach(async () => { + await removeLogFiles(); + + rootA = createRoot({ + logFileName: Path.join(__dirname, `${LOG_FILE_PREFIX}_A.log`), + }); + rootB = createRoot({ + logFileName: Path.join(__dirname, `${LOG_FILE_PREFIX}_B.log`), + }); + rootC = createRoot({ + logFileName: Path.join(__dirname, `${LOG_FILE_PREFIX}_C.log`), + }); + + const { startES } = kbnTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + es: { + license: 'basic', + // original SOs: 5k of `foo` docs with this structure: + // [ + // { id: 'foo:1', type: 'foo', foo: { status: 'unmigrated' }, migrationVersion: { foo: '7.13.0' } }, + // { id: 'foo:2', type: 'foo', foo: { status: 'unmigrated' }, migrationVersion: { foo: '7.13.0' } }, + // { id: 'foo:3', type: 'foo', foo: { status: 'unmigrated' }, migrationVersion: { foo: '7.13.0' } }, + // ]; + dataArchive: Path.join(__dirname, 'archives', '7.13.0_concurrent_5k_foo.zip'), + }, + }, + }); + esServer = await startES(); + }); + + afterEach(async () => { + await Promise.all([rootA.shutdown(), rootB.shutdown(), rootC.shutdown()]); + + if (esServer) { + await esServer.stop(); + } + }); + + const delay = (timeInMs: number) => new Promise((resolve) => setTimeout(resolve, timeInMs)); + const startWithDelay = async (instances: Root[], delayInSec: number) => { + const promises: Array> = []; + for (let i = 0; i < instances.length; i++) { + promises.push(instances[i].start()); + if (i < instances.length - 1) { + await delay(delayInSec * 1000); + } + } + return Promise.all(promises); + }; + + it('migrates saved objects normally when multiple Kibana instances are started at the same time', async () => { + const setupContracts = await Promise.all([rootA.setup(), rootB.setup(), rootC.setup()]); + + setupContracts.forEach((setup) => setup.savedObjects.registerType(fooType)); + + await startWithDelay([rootA, rootB, rootC], 0); + + const esClient = esServer.es.getClient(); + const migratedDocs = await fetchDocs(esClient, migratedIndex); + + expect(migratedDocs.length).toBe(5000); + + migratedDocs.forEach((doc, i) => { + expect(doc.id).toBe(`foo:${i}`); + expect(doc.foo.status).toBe(`migrated`); + expect(doc.migrationVersion.foo).toBe('7.14.0'); + }); + }); + + it('migrates saved objects normally when multiple Kibana instances are started with a small interval', async () => { + const setupContracts = await Promise.all([rootA.setup(), rootB.setup(), rootC.setup()]); + + setupContracts.forEach((setup) => setup.savedObjects.registerType(fooType)); + + await startWithDelay([rootA, rootB, rootC], 1); + + const esClient = esServer.es.getClient(); + const migratedDocs = await fetchDocs(esClient, migratedIndex); + + expect(migratedDocs.length).toBe(5000); + + migratedDocs.forEach((doc, i) => { + expect(doc.id).toBe(`foo:${i}`); + expect(doc.foo.status).toBe(`migrated`); + expect(doc.migrationVersion.foo).toBe('7.14.0'); + }); + }); + + it('migrates saved objects normally when multiple Kibana instances are started with an average interval', async () => { + const setupContracts = await Promise.all([rootA.setup(), rootB.setup(), rootC.setup()]); + + setupContracts.forEach((setup) => setup.savedObjects.registerType(fooType)); + + await startWithDelay([rootA, rootB, rootC], 5); + + const esClient = esServer.es.getClient(); + const migratedDocs = await fetchDocs(esClient, migratedIndex); + + expect(migratedDocs.length).toBe(5000); + + migratedDocs.forEach((doc, i) => { + expect(doc.id).toBe(`foo:${i}`); + expect(doc.foo.status).toBe(`migrated`); + expect(doc.migrationVersion.foo).toBe('7.14.0'); + }); + }); + + it('migrates saved objects normally when multiple Kibana instances are started with a bigger interval', async () => { + const setupContracts = await Promise.all([rootA.setup(), rootB.setup(), rootC.setup()]); + + setupContracts.forEach((setup) => setup.savedObjects.registerType(fooType)); + + await startWithDelay([rootA, rootB, rootC], 20); + + const esClient = esServer.es.getClient(); + const migratedDocs = await fetchDocs(esClient, migratedIndex); + + expect(migratedDocs.length).toBe(5000); + + migratedDocs.forEach((doc, i) => { + expect(doc.id).toBe(`foo:${i}`); + expect(doc.foo.status).toBe(`migrated`); + expect(doc.migrationVersion.foo).toBe('7.14.0'); + }); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/model/model.test.ts b/src/core/server/saved_objects/migrationsv2/model/model.test.ts index 174459d04d9e..136709d1b874 100644 --- a/src/core/server/saved_objects/migrationsv2/model/model.test.ts +++ b/src/core/server/saved_objects/migrationsv2/model/model.test.ts @@ -1054,6 +1054,15 @@ describe('migrations v2 model', () => { expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); + test('REINDEX_SOURCE_TO_TEMP_INDEX_BULK -> REINDEX_SOURCE_TO_TEMP_CLOSE_PIT if response is left target_index_had_write_block', () => { + const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX_BULK'> = Either.left({ + type: 'target_index_had_write_block', + }); + const newState = model(reindexSourceToTempIndexBulkState, res); + expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_CLOSE_PIT'); + expect(newState.retryCount).toEqual(0); + expect(newState.retryDelay).toEqual(0); + }); test('REINDEX_SOURCE_TO_TEMP_INDEX_BULK should throw a throwBadResponse error if action failed', () => { const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX_BULK'> = Either.left({ type: 'retryable_es_client_error', @@ -1101,7 +1110,7 @@ describe('migrations v2 model', () => { expect(newState.retryCount).toBe(0); expect(newState.retryDelay).toBe(0); }); - it('CLONE_TEMP_TO_TARGET -> REFRESH_TARGET if response is left index_not_fonud_exception', () => { + it('CLONE_TEMP_TO_TARGET -> REFRESH_TARGET if response is left index_not_found_exception', () => { const res: ResponseType<'CLONE_TEMP_TO_TARGET'> = Either.left({ type: 'index_not_found_exception', index: 'temp_index', diff --git a/src/core/server/saved_objects/migrationsv2/model/model.ts b/src/core/server/saved_objects/migrationsv2/model/model.ts index e7d6b8ed175e..b28e4e302438 100644 --- a/src/core/server/saved_objects/migrationsv2/model/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model/model.ts @@ -499,7 +499,15 @@ export const model = (currentState: State, resW: ResponseType): transformErrors: [], }; } else { - throwBadResponse(stateP, res); + if (isLeftTypeof(res.left, 'target_index_had_write_block')) { + // the temp index has a write block, meaning that another instance already finished and moved forward. + // close the PIT search and carry on with the happy path. + return { + ...stateP, + controlState: 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT', + }; + } + throwBadResponse(stateP, res.left); } } else if (stateP.controlState === 'SET_TEMP_WRITE_BLOCK') { const res = resW as ExcludeRetryableEsError>; @@ -667,7 +675,7 @@ export const model = (currentState: State, resW: ResponseType): hasTransformedDocs: true, }; } else { - throwBadResponse(stateP, res); + throwBadResponse(stateP, res as never); } } else if (stateP.controlState === 'UPDATE_TARGET_MAPPINGS') { const res = resW as ExcludeRetryableEsError>; diff --git a/src/core/server/saved_objects/routes/integration_tests/get.test.ts b/src/core/server/saved_objects/routes/integration_tests/get.test.ts index 295f80712b76..e247a913f977 100644 --- a/src/core/server/saved_objects/routes/integration_tests/get.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/get.test.ts @@ -11,6 +11,7 @@ import { registerGetRoute } from '../get'; import { ContextService } from '../../../context'; import { savedObjectsClientMock } from '../../service/saved_objects_client.mock'; import { CoreUsageStatsClient } from '../../../core_usage_data'; +import { executionContextServiceMock } from '../../../execution_context/execution_context_service.mock'; import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; import { HttpService, InternalHttpServiceSetup } from '../../../http'; @@ -33,6 +34,7 @@ describe('GET /api/saved_objects/{type}/{id}', () => { const contextService = new ContextService(coreContext); httpSetup = await server.setup({ context: contextService.setup({ pluginDependencies: new Map() }), + executionContext: executionContextServiceMock.createInternalSetupContract(), }); handlerContext = coreMock.createRequestHandlerContext(); diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts index 96d79edd39d3..294267a0e2ae 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve.test.ts @@ -13,6 +13,7 @@ import { savedObjectsClientMock } from '../../service/saved_objects_client.mock' import { CoreUsageStatsClient } from '../../../core_usage_data'; import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; +import { executionContextServiceMock } from '../../../execution_context/execution_context_service.mock'; import { HttpService, InternalHttpServiceSetup } from '../../../http'; import { createHttpServer, createCoreContext } from '../../../http/test_utils'; import { coreMock } from '../../../mocks'; @@ -33,6 +34,7 @@ describe('GET /api/saved_objects/resolve/{type}/{id}', () => { const contextService = new ContextService(coreContext); httpSetup = await server.setup({ context: contextService.setup({ pluginDependencies: new Map() }), + executionContext: executionContextServiceMock.createInternalSetupContract(), }); handlerContext = coreMock.createRequestHandlerContext(); diff --git a/src/core/server/saved_objects/routes/test_utils.ts b/src/core/server/saved_objects/routes/test_utils.ts index e6826b118509..796bfd55b782 100644 --- a/src/core/server/saved_objects/routes/test_utils.ts +++ b/src/core/server/saved_objects/routes/test_utils.ts @@ -9,6 +9,7 @@ import { ContextService } from '../../context'; import { createHttpServer, createCoreContext } from '../../http/test_utils'; import { coreMock } from '../../mocks'; +import { executionContextServiceMock } from '../../execution_context/execution_context_service.mock'; import { SavedObjectsType } from '../types'; const defaultCoreId = Symbol('core'); @@ -20,6 +21,7 @@ export const setupServer = async (coreId: symbol = defaultCoreId) => { const server = createHttpServer(coreContext); const httpSetup = await server.setup({ context: contextService.setup({ pluginDependencies: new Map() }), + executionContext: executionContextServiceMock.createInternalSetupContract(), }); const handlerContext = coreMock.createRequestHandlerContext(); diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 13ec594df907..ed55c6e3d09c 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -523,6 +523,8 @@ export interface CoreSetup; // (undocumented) http: HttpServiceSetup & { @@ -551,6 +553,8 @@ export interface CoreStart { // (undocumented) elasticsearch: ElasticsearchServiceStart; // (undocumented) + executionContext: ExecutionContextStart; + // (undocumented) http: HttpServiceStart; // (undocumented) metrics: MetricsServiceStart; @@ -1015,6 +1019,15 @@ export interface ErrorHttpResponseOptions { headers?: ResponseHeaders; } +// @public (undocumented) +export interface ExecutionContextSetup { + get(): IExecutionContextContainer | undefined; + set(context: Partial): void; +} + +// @public (undocumented) +export type ExecutionContextStart = ExecutionContextSetup; + // @public export interface FakeRequest { headers: Headers; @@ -1187,6 +1200,14 @@ export interface ICustomClusterClient extends IClusterClient { close: () => Promise; } +// @public (undocumented) +export interface IExecutionContextContainer { + // (undocumented) + toJSON(): Readonly; + // (undocumented) + toString(): string; +} + // @public export interface IExternalUrlConfig { readonly policy: IExternalUrlPolicy[]; @@ -1303,6 +1324,15 @@ export interface IUiSettingsClient { setMany: (changes: Record) => Promise; } +// @public (undocumented) +export interface KibanaExecutionContext { + readonly description: string; + readonly id: string; + readonly name: string; + readonly type: string; + readonly url?: string; +} + // @public export class KibanaRequest { // @internal (undocumented) @@ -1374,6 +1404,12 @@ export const kibanaResponseFactory: { noContent: (options?: HttpResponseOptions) => KibanaResponse; }; +// @public (undocumented) +export interface KibanaServerExecutionContext extends Partial { + // (undocumented) + requestId: string; +} + // Warning: (ae-forgotten-export) The symbol "KnownKeys" needs to be exported by the entry point index.d.ts // // @public diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 3f553dd90678..5a75550280a9 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -31,6 +31,7 @@ import { CapabilitiesService } from './capabilities'; import { EnvironmentService, config as pidConfig } from './environment'; // do not try to shorten the import to `./status`, it will break server test mocking import { StatusService } from './status/status_service'; +import { ExecutionContextService } from './execution_context'; import { config as cspConfig } from './csp'; import { config as elasticsearchConfig } from './elasticsearch'; @@ -48,6 +49,7 @@ import { CoreUsageDataService } from './core_usage_data'; import { DeprecationsService } from './deprecations'; import { CoreRouteHandlerContext } from './core_route_handler_context'; import { config as externalUrlConfig } from './external_url'; +import { config as executionContextConfig } from './execution_context'; const coreId = Symbol('core'); const rootConfigPath = ''; @@ -73,6 +75,7 @@ export class Server { private readonly coreUsageData: CoreUsageDataService; private readonly i18n: I18nService; private readonly deprecations: DeprecationsService; + private readonly executionContext: ExecutionContextService; private readonly savedObjectsStartPromise: Promise; private resolveSavedObjectsStartPromise?: (value: SavedObjectsServiceStart) => void; @@ -109,6 +112,7 @@ export class Server { this.coreUsageData = new CoreUsageDataService(core); this.i18n = new I18nService(core); this.deprecations = new DeprecationsService(core); + this.executionContext = new ExecutionContextService(core); this.savedObjectsStartPromise = new Promise((resolve) => { this.resolveSavedObjectsStartPromise = resolve; @@ -133,9 +137,11 @@ export class Server { const contextServiceSetup = this.context.setup({ pluginDependencies: new Map([...pluginTree.asOpaqueIds]), }); + const executionContextSetup = this.executionContext.setup(); const httpSetup = await this.http.setup({ context: contextServiceSetup, + executionContext: executionContextSetup, }); // setup i18n prior to any other service, to have translations ready @@ -145,6 +151,7 @@ export class Server { const elasticsearchServiceSetup = await this.elasticsearch.setup({ http: httpSetup, + executionContext: executionContextSetup, }); const metricsSetup = await this.metrics.setup({ http: httpSetup }); @@ -200,6 +207,7 @@ export class Server { context: contextServiceSetup, elasticsearch: elasticsearchServiceSetup, environment: environmentSetup, + executionContext: executionContextSetup, http: httpSetup, i18n: i18nServiceSetup, savedObjects: savedObjectsSetup, @@ -230,6 +238,7 @@ export class Server { this.log.debug('starting server'); const startTransaction = apm.startTransaction('server_start', 'kibana_platform'); + const executionContextStart = this.executionContext.start(); const elasticsearchStart = await this.elasticsearch.start(); const soStartSpan = startTransaction?.startSpan('saved_objects.migration', 'migration'); const savedObjectsStart = await this.savedObjects.start({ @@ -253,6 +262,7 @@ export class Server { this.coreStart = { capabilities: capabilitiesStart, elasticsearch: elasticsearchStart, + executionContext: executionContextStart, http: httpStart, metrics: metricsStart, savedObjects: savedObjectsStart, @@ -297,6 +307,7 @@ export class Server { public setupCoreConfig() { const configDescriptors: Array> = [ + executionContextConfig, pathConfig, cspConfig, elasticsearchConfig, diff --git a/src/core/server/status/routes/integration_tests/status.test.ts b/src/core/server/status/routes/integration_tests/status.test.ts index 841dc43cc4ee..03b9a1462e0c 100644 --- a/src/core/server/status/routes/integration_tests/status.test.ts +++ b/src/core/server/status/routes/integration_tests/status.test.ts @@ -20,6 +20,7 @@ import { HttpService, InternalHttpServiceSetup } from '../../../http'; import { registerStatusRoute } from '../status'; import { ServiceStatus, ServiceStatusLevels } from '../../types'; import { statusServiceMock } from '../../status_service.mock'; +import { executionContextServiceMock } from '../../../execution_context/execution_context_service.mock'; const coreId = Symbol('core'); @@ -35,6 +36,7 @@ describe('GET /api/status', () => { server = createHttpServer(coreContext); httpSetup = await server.setup({ context: contextService.setup({ pluginDependencies: new Map() }), + executionContext: executionContextServiceMock.createInternalSetupContract(), }); metrics = metricsServiceMock.createSetupContract(); diff --git a/src/core/types/execution_context.ts b/src/core/types/execution_context.ts new file mode 100644 index 000000000000..e624ea82f22f --- /dev/null +++ b/src/core/types/execution_context.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** @public */ +export interface KibanaExecutionContext { + /** + * Kibana application initated an operation. + * Can be narrowed to an enum later. + * */ + readonly type: string; // 'visualization' | 'actions' | 'server' | ..; + /** public name of a user-facing feature */ + readonly name: string; // 'TSVB' | 'Lens' | 'action_execution' | ..; + /** unique value to identify the source */ + readonly id: string; + /** human readable description. For example, a vis title, action name */ + readonly description: string; + /** in browser - url to navigate to a current page, on server - endpoint path, for task: task SO url */ + readonly url?: string; +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 21876844ed45..97f990f608c0 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -16,3 +16,4 @@ export * from './app_category'; export * from './ui_settings'; export * from './saved_objects'; export * from './serializable'; +export type { KibanaExecutionContext } from './execution_context'; diff --git a/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat b/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat index 9221af3142e6..1b065dd785d8 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat +++ b/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat @@ -13,7 +13,7 @@ If Not Exist "%NODE%" ( ) set CONFIG_DIR=%KBN_PATH_CONF% -If [%KBN_PATH_CONF%] == [] ( +If ["%KBN_PATH_CONF%"] == [] ( set "CONFIG_DIR=%DIR%\config" ) diff --git a/src/dev/build/tasks/bin/scripts/kibana-keystore.bat b/src/dev/build/tasks/bin/scripts/kibana-keystore.bat index c40145e7d681..11925dc4e70e 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-keystore.bat +++ b/src/dev/build/tasks/bin/scripts/kibana-keystore.bat @@ -13,7 +13,7 @@ If Not Exist "%NODE%" ( ) set CONFIG_DIR=%KBN_PATH_CONF% -If [%KBN_PATH_CONF%] == [] ( +If ["%KBN_PATH_CONF%"] == [] ( set "CONFIG_DIR=%DIR%\config" ) diff --git a/src/dev/build/tasks/bin/scripts/kibana-plugin.bat b/src/dev/build/tasks/bin/scripts/kibana-plugin.bat index d1282f8cf32a..169895082b0c 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-plugin.bat +++ b/src/dev/build/tasks/bin/scripts/kibana-plugin.bat @@ -14,7 +14,7 @@ If Not Exist "%NODE%" ( ) set CONFIG_DIR=%KBN_PATH_CONF% -If [%KBN_PATH_CONF%] == [] ( +If ["%KBN_PATH_CONF%"] == [] ( set "CONFIG_DIR=%DIR%\config" ) diff --git a/src/dev/build/tasks/bin/scripts/kibana.bat b/src/dev/build/tasks/bin/scripts/kibana.bat index 4fc62804ca9a..2b2ce84ebb92 100755 --- a/src/dev/build/tasks/bin/scripts/kibana.bat +++ b/src/dev/build/tasks/bin/scripts/kibana.bat @@ -15,7 +15,7 @@ If Not Exist "%NODE%" ( ) set CONFIG_DIR=%KBN_PATH_CONF% -If [%KBN_PATH_CONF%] == [] ( +If ["%KBN_PATH_CONF%"] == [] ( set "CONFIG_DIR=%DIR%\config" ) diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 643080fda381..39a7665f1ce5 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -381,7 +381,6 @@ kibana_vars=( xpack.security.session.lifespan xpack.security.sessionTimeout xpack.securitySolution.alertMergeStrategy - xpack.securitySolution.alertResultListDefaultDateRange xpack.securitySolution.endpointResultListDefaultFirstPageIndex xpack.securitySolution.endpointResultListDefaultPageSize xpack.securitySolution.maxRuleImportExportSize @@ -389,7 +388,6 @@ kibana_vars=( xpack.securitySolution.maxTimelineImportExportSize xpack.securitySolution.maxTimelineImportPayloadBytes xpack.securitySolution.packagerTaskInterval - xpack.securitySolution.validateArtifactDownloads xpack.spaces.enabled xpack.spaces.maxSpaces xpack.task_manager.enabled diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts index 1d4f83c88ba8..59dfa92cdbce 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.test.ts @@ -39,7 +39,7 @@ const injectImplementation = ( }; embeddableSetupMock.extract.mockImplementation(extractImplementation); embeddableSetupMock.inject.mockImplementation(injectImplementation); -embeddableSetupMock.getMigrationVersions.mockImplementation(() => []); +embeddableSetupMock.getAllMigrations.mockImplementation(() => ({})); const migrations = createDashboardSavedObjectTypeMigrations({ embeddable: embeddableSetupMock, @@ -586,28 +586,14 @@ describe('dashboard', () => { type: 'dashboard', }; - it('should add all embeddable migrations for versions above 7.12.0 to dashboard saved object migrations', () => { - const newEmbeddableSetupMock = createEmbeddableSetupMock(); - newEmbeddableSetupMock.getMigrationVersions.mockImplementation(() => [ - '7.10.100', - '7.13.0', - '8.0.0', - ]); - const migrationsList = createDashboardSavedObjectTypeMigrations({ - embeddable: newEmbeddableSetupMock, - }); - expect(Object.keys(migrationsList).indexOf('8.0.0')).not.toBe(-1); - expect(Object.keys(migrationsList).indexOf('7.13.0')).not.toBe(-1); - expect(Object.keys(migrationsList).indexOf('7.10.100')).toBe(-1); - }); - it('runs migrations on by value panels only', () => { const newEmbeddableSetupMock = createEmbeddableSetupMock(); - newEmbeddableSetupMock.getMigrationVersions.mockImplementation(() => ['7.13.0']); - newEmbeddableSetupMock.migrate.mockImplementation((state: SerializableState) => { - state.superCoolKey = 'ONLY 4 BY VALUE EMBEDDABLES THANK YOU VERY MUCH'; - return state; - }); + newEmbeddableSetupMock.getAllMigrations.mockImplementation(() => ({ + '7.13.0': (state: SerializableState) => { + state.superCoolKey = 'ONLY 4 BY VALUE EMBEDDABLES THANK YOU VERY MUCH'; + return state; + }, + })); const migrationsList = createDashboardSavedObjectTypeMigrations({ embeddable: newEmbeddableSetupMock, }); diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts index 0bd100b3d580..ceb77ba1b2f9 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import semver from 'semver'; -import { get, flow, identity } from 'lodash'; +import { get, flow, mapValues } from 'lodash'; import { SavedObjectAttributes, SavedObjectMigrationFn, @@ -26,7 +25,12 @@ import { } from '../../common/embeddable/embeddable_saved_object_converters'; import { SavedObjectEmbeddableInput } from '../../../embeddable/common'; import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../data/common'; -import { SerializableValue } from '../../../kibana_utils/common'; +import { + mergeMigrationFunctionMaps, + MigrateFunction, + MigrateFunctionsObject, + SerializableValue, +} from '../../../kibana_utils/common'; import { replaceIndexPatternReference } from './replace_index_pattern_reference'; function migrateIndexPattern(doc: DashboardDoc700To720) { @@ -156,7 +160,7 @@ type ValueOrReferenceInput = SavedObjectEmbeddableInput & { // Runs the embeddable migrations on each panel const migrateByValuePanels = ( - deps: DashboardSavedObjectTypeMigrationsDeps, + migrate: MigrateFunction, version: string ): SavedObjectMigrationFn => (doc: any) => { const { attributes } = doc; @@ -179,13 +183,10 @@ const migrateByValuePanels = ( // saved vis is used to store by value input for Visualize. This should eventually be renamed to `attributes` to align with Lens and Maps if (originalPanelState.explicitInput.attributes || originalPanelState.explicitInput.savedVis) { // If this panel is by value, migrate the state using embeddable migrations - const migratedInput = deps.embeddable.migrate( - { - ...originalPanelState.explicitInput, - type: originalPanelState.type, - }, - version - ); + const migratedInput = migrate({ + ...originalPanelState.explicitInput, + type: originalPanelState.type, + }); // Convert the embeddable state back into the panel shape newPanels.push( convertPanelStateToSavedDashboardPanel( @@ -216,16 +217,12 @@ export interface DashboardSavedObjectTypeMigrationsDeps { export const createDashboardSavedObjectTypeMigrations = ( deps: DashboardSavedObjectTypeMigrationsDeps ): SavedObjectMigrationMap => { - const embeddableMigrations = Object.fromEntries( - deps.embeddable - .getMigrationVersions() - .filter((version) => semver.gt(version, '7.12.0')) - .map((version): [string, SavedObjectMigrationFn] => { - return [version, migrateByValuePanels(deps, version)]; - }) - ); + const embeddableMigrations = mapValues( + deps.embeddable.getAllMigrations(), + migrateByValuePanels + ) as MigrateFunctionsObject; - return { + const dashboardMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version * after it. The reason for that is, that this migration has been introduced once 7.0.0 was already @@ -242,14 +239,14 @@ export const createDashboardSavedObjectTypeMigrations = ( '7.9.3': flow(migrateMatchAllQuery), '7.11.0': flow(createExtractPanelReferencesMigration(deps)), - ...embeddableMigrations, - /** * Any dashboard saved object migrations that come after this point will have to be wary of * potentially overwriting embeddable migrations. An example of how to mitigate this follows: */ // '7.x': flow(yourNewMigrationFunction, embeddableMigrations['7.x'] ?? identity), - '7.14.0': flow(replaceIndexPatternReference, embeddableMigrations['7.14.0'] ?? identity), + '7.14.0': flow(replaceIndexPatternReference), }; + + return mergeMigrationFunctionMaps(dashboardMigrations, embeddableMigrations); }; diff --git a/src/plugins/data/server/autocomplete/terms_agg.test.ts b/src/plugins/data/server/autocomplete/terms_agg.test.ts index e4652c2c422e..ae991e289a71 100644 --- a/src/plugins/data/server/autocomplete/terms_agg.test.ts +++ b/src/plugins/data/server/autocomplete/terms_agg.test.ts @@ -32,6 +32,8 @@ const mockResponse = { }, } as ApiResponse>; +jest.mock('../index_patterns'); + describe('terms agg suggestions', () => { beforeEach(() => { const requestHandlerContext = coreMock.createRequestHandlerContext(); @@ -86,4 +88,50 @@ describe('terms agg suggestions', () => { ] `); }); + + it('calls the _search API with a terms agg and fallback to fieldName when field is null', async () => { + const result = await termsAggSuggestions( + configMock, + savedObjectsClientMock, + esClientMock, + 'index', + 'fieldName', + 'query', + [] + ); + + const [[args]] = esClientMock.search.mock.calls; + + expect(args).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "suggestions": Object { + "terms": Object { + "execution_hint": "map", + "field": "fieldName", + "include": "query.*", + "shard_size": 10, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [], + }, + }, + "size": 0, + "terminate_after": 98430, + "timeout": "4513ms", + }, + "index": "index", + } + `); + expect(result).toMatchInlineSnapshot(` + Array [ + "whoa", + "amazing", + ] + `); + }); }); diff --git a/src/plugins/data/server/autocomplete/terms_enum.test.ts b/src/plugins/data/server/autocomplete/terms_enum.test.ts index be8f179db29c..41eaf3f4032a 100644 --- a/src/plugins/data/server/autocomplete/terms_enum.test.ts +++ b/src/plugins/data/server/autocomplete/terms_enum.test.ts @@ -22,6 +22,8 @@ const mockResponse = { body: { terms: ['whoa', 'amazing'] }, }; +jest.mock('../index_patterns'); + describe('_terms_enum suggestions', () => { beforeEach(() => { const requestHandlerContext = coreMock.createRequestHandlerContext(); @@ -71,4 +73,45 @@ describe('_terms_enum suggestions', () => { `); expect(result).toEqual(mockResponse.body.terms); }); + + it('calls the _terms_enum API and fallback to fieldName when field is null', async () => { + const result = await termsEnumSuggestions( + configMock, + savedObjectsClientMock, + esClientMock, + 'index', + 'fieldName', + 'query', + [] + ); + + const [[args]] = esClientMock.transport.request.mock.calls; + + expect(args).toMatchInlineSnapshot(` + Object { + "body": Object { + "field": "fieldName", + "index_filter": Object { + "bool": Object { + "must": Array [ + Object { + "terms": Object { + "_tier": Array [ + "data_hot", + "data_warm", + "data_content", + ], + }, + }, + ], + }, + }, + "string": "query", + }, + "method": "POST", + "path": "/index/_terms_enum", + } + `); + expect(result).toEqual(mockResponse.body.terms); + }); }); diff --git a/src/plugins/data/server/autocomplete/terms_enum.ts b/src/plugins/data/server/autocomplete/terms_enum.ts index c2452b0a099d..40329586a362 100644 --- a/src/plugins/data/server/autocomplete/terms_enum.ts +++ b/src/plugins/data/server/autocomplete/terms_enum.ts @@ -36,7 +36,7 @@ export async function termsEnumSuggestions( method: 'POST', path: encodeURI(`/${index}/_terms_enum`), body: { - field: field?.name ?? field, + field: field?.name ?? fieldName, string: query, index_filter: { bool: { diff --git a/src/plugins/data/server/index_patterns/deprecations/index.ts b/src/plugins/data/server/index_patterns/deprecations/index.ts new file mode 100644 index 000000000000..98fda3e2419a --- /dev/null +++ b/src/plugins/data/server/index_patterns/deprecations/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { createScriptedFieldsDeprecationsConfig } from './scripted_fields'; diff --git a/src/plugins/data/server/index_patterns/deprecations/scripted_fields.test.ts b/src/plugins/data/server/index_patterns/deprecations/scripted_fields.test.ts new file mode 100644 index 000000000000..4ed2779dea85 --- /dev/null +++ b/src/plugins/data/server/index_patterns/deprecations/scripted_fields.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { hasScriptedField } from './scripted_fields'; + +describe('hasScriptedField', () => { + test('valid index pattern object with a scripted field', () => { + expect( + hasScriptedField({ + title: 'kibana_sample_data_logs*', + fields: + '[{"count":0,"script":"return 5;","lang":"painless","name":"test","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false,"customLabel":""}]', + }) + ).toBe(true); + }); + + test('valid index pattern object without a scripted field', () => { + expect( + hasScriptedField({ + title: 'kibana_sample_data_logs*', + fields: '[]', + }) + ).toBe(false); + }); + + test('invalid index pattern object', () => { + expect( + hasScriptedField({ + title: 'kibana_sample_data_logs*', + fields: '[...]', + }) + ).toBe(false); + }); +}); diff --git a/src/plugins/data/server/index_patterns/deprecations/scripted_fields.ts b/src/plugins/data/server/index_patterns/deprecations/scripted_fields.ts new file mode 100644 index 000000000000..7c9ce6f9ed33 --- /dev/null +++ b/src/plugins/data/server/index_patterns/deprecations/scripted_fields.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + CoreSetup, + DeprecationsDetails, + GetDeprecationsContext, + RegisterDeprecationsConfig, +} from 'kibana/server'; +import { IndexPatternAttributes } from '../../../common'; + +type IndexPatternAttributesWithFields = Pick; + +export const createScriptedFieldsDeprecationsConfig: ( + core: CoreSetup +) => RegisterDeprecationsConfig = (core: CoreSetup) => ({ + getDeprecations: async (context: GetDeprecationsContext): Promise => { + const finder = context.savedObjectsClient.createPointInTimeFinder( + { + type: 'index-pattern', + perPage: 1000, + fields: ['title', 'fields'], + } + ); + + const indexPatternsWithScriptedFields: IndexPatternAttributesWithFields[] = []; + for await (const response of finder.find()) { + indexPatternsWithScriptedFields.push( + ...response.saved_objects.map((so) => so.attributes).filter(hasScriptedField) + ); + } + + if (indexPatternsWithScriptedFields.length > 0) { + const PREVIEW_LIMIT = 3; + const indexPatternTitles = indexPatternsWithScriptedFields.map((ip) => ip.title); + const titlesPreview = indexPatternTitles.slice(0, PREVIEW_LIMIT).join('; '); + const allTitles = indexPatternTitles.join('; '); + + return [ + { + message: `You have ${indexPatternsWithScriptedFields.length} index patterns (${titlesPreview}...) that use scripted fields. Scripted fields are deprecated and will be removed in future. Use runtime fields instead.`, + documentationUrl: + 'https://www.elastic.co/guide/en/elasticsearch/reference/7.x/runtime.html', // TODO: documentation service is not available serverside https://github.com/elastic/kibana/issues/95389 + level: 'warning', // warning because it is not set in stone WHEN we remove scripted fields, hence this deprecation is not a blocker for 8.0 upgrade + correctiveActions: { + manualSteps: [ + 'Navigate to Stack Management > Kibana > Index Patterns.', + `Update ${indexPatternsWithScriptedFields.length} index patterns that have scripted fields to use runtime fields instead. In most cases, to migrate existing scripts, you'll need to change "return ;" to "emit();". Index patterns with at least one scripted field: ${allTitles}`, + ], + }, + }, + ]; + } else { + return []; + } + }, +}); + +export function hasScriptedField(indexPattern: IndexPatternAttributesWithFields) { + if (indexPattern.fields) { + try { + return JSON.parse(indexPattern.fields).some( + (field: { scripted?: boolean }) => field?.scripted + ); + } catch (e) { + return false; + } + } else { + return false; + } +} diff --git a/src/plugins/data/server/index_patterns/index_patterns_service.ts b/src/plugins/data/server/index_patterns/index_patterns_service.ts index c7fd1f7914df..4269f15127da 100644 --- a/src/plugins/data/server/index_patterns/index_patterns_service.ts +++ b/src/plugins/data/server/index_patterns/index_patterns_service.ts @@ -28,6 +28,7 @@ import { UiSettingsServerToCommon } from './ui_settings_wrapper'; import { IndexPatternsApiServer } from './index_patterns_api_client'; import { SavedObjectsClientServerToCommon } from './saved_objects_client_wrapper'; import { registerIndexPatternsUsageCollector } from './register_index_pattern_usage_collection'; +import { createScriptedFieldsDeprecationsConfig } from './deprecations'; export interface IndexPatternsServiceStart { indexPatternsServiceFactory: ( @@ -88,6 +89,7 @@ export class IndexPatternsServiceProvider implements Plugin { + const factories = [{ migrations: { '7.11.0': (state: any) => state } }]; + const enhacements = [{ migrations: { '7.12.0': (state: any) => state } }]; + const migrateFn = jest.fn(); + + test('returns base migrations', () => { + expect(getAllMigrations([], [], migrateFn)).toEqual({}); + }); + + test('returns embeddable factory migrations', () => { + expect(getAllMigrations(factories as any, [], migrateFn)).toHaveProperty(['7.11.0']); + }); + + test('returns enhancement migrations', () => { + const migrations = getAllMigrations([], enhacements as any, migrateFn); + expect(migrations).toHaveProperty(['7.12.0']); + }); + + test('returns all migrations', () => { + const migrations = getAllMigrations(factories as any, enhacements as any, migrateFn); + expect(migrations).toHaveProperty(['7.11.0']); + expect(migrations).toHaveProperty(['7.12.0']); + }); +}); diff --git a/src/plugins/embeddable/common/lib/get_all_migrations.ts b/src/plugins/embeddable/common/lib/get_all_migrations.ts new file mode 100644 index 000000000000..8e3233f447a3 --- /dev/null +++ b/src/plugins/embeddable/common/lib/get_all_migrations.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { baseEmbeddableMigrations } from './migrate_base_input'; +import { + MigrateFunctionsObject, + PersistableState, + PersistableStateMigrateFn, +} from '../../../kibana_utils/common/persistable_state'; + +export const getAllMigrations = ( + factories: unknown[], + enhancements: unknown[], + migrateFn: PersistableStateMigrateFn +) => { + const uniqueVersions = new Set(); + for (const baseMigrationVersion of Object.keys(baseEmbeddableMigrations)) { + uniqueVersions.add(baseMigrationVersion); + } + for (const factory of factories) { + Object.keys((factory as PersistableState).migrations).forEach((version) => + uniqueVersions.add(version) + ); + } + for (const enhancement of enhancements) { + Object.keys((enhancement as PersistableState).migrations).forEach((version) => + uniqueVersions.add(version) + ); + } + + const migrations: MigrateFunctionsObject = {}; + uniqueVersions.forEach((version) => { + migrations[version] = (state) => ({ + ...migrateFn(state, version), + }); + }); + + return migrations; +}; diff --git a/src/plugins/embeddable/common/lib/migrate.ts b/src/plugins/embeddable/common/lib/migrate.ts index fb8ea5cf2cd8..7dde9e1d2b2a 100644 --- a/src/plugins/embeddable/common/lib/migrate.ts +++ b/src/plugins/embeddable/common/lib/migrate.ts @@ -10,8 +10,10 @@ import { CommonEmbeddableStartContract } from '../types'; import { baseEmbeddableMigrations } from './migrate_base_input'; import { SerializableState } from '../../../kibana_utils/common/persistable_state'; +export type MigrateFunction = (state: SerializableState, version: string) => SerializableState; + export const getMigrateFunction = (embeddables: CommonEmbeddableStartContract) => { - return (state: SerializableState, version: string) => { + const migrateFn: MigrateFunction = (state: SerializableState, version: string) => { const enhancements = (state.enhancements as SerializableState) || {}; const factory = embeddables.getEmbeddableFactory(state.type as string); @@ -19,10 +21,16 @@ export const getMigrateFunction = (embeddables: CommonEmbeddableStartContract) = ? baseEmbeddableMigrations[version](state) : state; - if (factory && factory.migrations[version]) { + if (factory?.migrations[version]) { updatedInput = factory.migrations[version](updatedInput); } + if (factory?.isContainerType) { + updatedInput.panels = ((state.panels as SerializableState[]) || []).map((panel) => { + return migrateFn(panel, version); + }); + } + updatedInput.enhancements = {}; Object.keys(enhancements).forEach((key) => { if (!enhancements[key]) return; @@ -35,4 +43,6 @@ export const getMigrateFunction = (embeddables: CommonEmbeddableStartContract) = return updatedInput; }; + + return migrateFn; }; diff --git a/src/plugins/embeddable/common/mocks.ts b/src/plugins/embeddable/common/mocks.ts index b8125351c59b..36d564d26b75 100644 --- a/src/plugins/embeddable/common/mocks.ts +++ b/src/plugins/embeddable/common/mocks.ts @@ -12,7 +12,7 @@ export const createEmbeddablePersistableStateServiceMock = (): jest.Mocked state), extract: jest.fn((state) => ({ state, references: [] })), - migrate: jest.fn((state, version) => state), + getAllMigrations: jest.fn(() => ({})), telemetry: jest.fn((state, collector) => ({})), }; }; diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index a9a89754c54c..ff2771f3fd58 100644 --- a/src/plugins/embeddable/public/mocks.tsx +++ b/src/plugins/embeddable/public/mocks.tsx @@ -113,7 +113,7 @@ const createStartContract = (): Start => { telemetry: jest.fn(), extract: jest.fn(), inject: jest.fn(), - migrate: jest.fn(), + getAllMigrations: jest.fn(), EmbeddablePanel: jest.fn(), getStateTransfer: jest.fn(() => createEmbeddableStateTransferMock() as EmbeddableStateTransfer), getAttributeService: jest.fn(), diff --git a/src/plugins/embeddable/public/plugin.test.ts b/src/plugins/embeddable/public/plugin.test.ts index 53302e8e6870..b93dc02ebb5a 100644 --- a/src/plugins/embeddable/public/plugin.test.ts +++ b/src/plugins/embeddable/public/plugin.test.ts @@ -106,8 +106,34 @@ describe('embeddable factory', () => { my: 'state', } as any; + const containerEmbeddableFactoryId = 'CONTAINER'; + const containerEmbeddableFactory = { + type: containerEmbeddableFactoryId, + create: jest.fn(), + getDisplayName: () => 'Container', + isContainer: true, + isEditable: () => Promise.resolve(true), + extract: jest.fn().mockImplementation((state) => ({ state, references: [] })), + inject: jest.fn().mockImplementation((state) => state), + telemetry: jest.fn().mockResolvedValue({}), + migrations: { '7.12.0': jest.fn().mockImplementation((state) => state) }, + }; + + const containerState = { + id: containerEmbeddableFactoryId, + type: containerEmbeddableFactoryId, + some: 'state', + panels: [ + { + ...embeddableState, + }, + ], + } as any; + + setup.registerEmbeddableFactory(embeddableFactoryId, embeddableFactory); + setup.registerEmbeddableFactory(containerEmbeddableFactoryId, containerEmbeddableFactory); + test('cannot register embeddable factory with the same ID', async () => { - setup.registerEmbeddableFactory(embeddableFactoryId, embeddableFactory); expect(() => setup.registerEmbeddableFactory(embeddableFactoryId, embeddableFactory) ).toThrowError( @@ -131,7 +157,12 @@ describe('embeddable factory', () => { }); test('embeddableFactory migrate function gets called when calling embeddable migrate', () => { - start.migrate(embeddableState, '7.11.0'); + start.getAllMigrations!()['7.11.0']!(embeddableState); + expect(embeddableFactory.migrations['7.11.0']).toBeCalledWith(embeddableState); + }); + + test('panels inside container get automatically migrated when migrating conta1iner', () => { + start.getAllMigrations!()['7.11.0']!(containerState); expect(embeddableFactory.migrations['7.11.0']).toBeCalledWith(embeddableState); }); }); @@ -156,8 +187,9 @@ describe('embeddable enhancements', () => { }, } as any; + setup.registerEnhancement(embeddableEnhancement); + test('cannot register embeddable enhancement with the same ID', async () => { - setup.registerEnhancement(embeddableEnhancement); expect(() => setup.registerEnhancement(embeddableEnhancement)).toThrowError( 'enhancement with id test already exists in the registry' ); @@ -179,7 +211,7 @@ describe('embeddable enhancements', () => { }); test('enhancement migrate function gets called when calling embeddable migrate', () => { - start.migrate(embeddableState, '7.11.0'); + start.getAllMigrations!()['7.11.0']!(embeddableState); expect(embeddableEnhancement.migrations['7.11.0']).toBeCalledWith( embeddableState.enhancements.test ); @@ -187,9 +219,9 @@ describe('embeddable enhancements', () => { test('doesnt fail if there is no migration function registered for specific version', () => { expect(() => { - start.migrate(embeddableState, '7.10.0'); + start.getAllMigrations!()['7.11.0']!(embeddableState); }).not.toThrow(); - expect(start.migrate(embeddableState, '7.10.0')).toEqual(embeddableState); + expect(start.getAllMigrations!()['7.11.0']!(embeddableState)).toEqual(embeddableState); }); }); diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 4ddef89727ef..62ec9e15f564 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -49,6 +49,7 @@ import { getMigrateFunction, getTelemetryFunction, } from '../common/lib'; +import { getAllMigrations } from '../common/lib/get_all_migrations'; export interface EmbeddableSetupDependencies { uiActions: UiActionsSetup; @@ -205,7 +206,12 @@ export class EmbeddablePublicPlugin implements Plugin + getAllMigrations( + Array.from(this.embeddableFactories.values()), + Array.from(this.enhancements.values()), + getMigrateFunction(commonContract) + ), }; } diff --git a/src/plugins/embeddable/server/mocks.ts b/src/plugins/embeddable/server/mocks.ts index 63754cecb027..f2a4b4e09d19 100644 --- a/src/plugins/embeddable/server/mocks.ts +++ b/src/plugins/embeddable/server/mocks.ts @@ -12,7 +12,7 @@ import { EmbeddableSetup, EmbeddableStart } from './plugin'; export const createEmbeddableSetupMock = (): jest.Mocked => ({ ...createEmbeddablePersistableStateServiceMock(), registerEmbeddableFactory: jest.fn(), - getMigrationVersions: jest.fn().mockReturnValue([]), + getAllMigrations: jest.fn().mockReturnValue({}), registerEnhancement: jest.fn(), }); diff --git a/src/plugins/embeddable/server/plugin.ts b/src/plugins/embeddable/server/plugin.ts index 788f51adc327..c85f48e01d48 100644 --- a/src/plugins/embeddable/server/plugin.ts +++ b/src/plugins/embeddable/server/plugin.ts @@ -16,19 +16,24 @@ import { EmbeddableRegistryDefinition, } from './types'; import { - baseEmbeddableMigrations, getExtractFunction, getInjectFunction, getMigrateFunction, getTelemetryFunction, } from '../common/lib'; -import { PersistableStateService, SerializableState } from '../../kibana_utils/common'; +import { + PersistableStateService, + SerializableState, + PersistableStateMigrateFn, + MigrateFunctionsObject, +} from '../../kibana_utils/common'; import { EmbeddableStateWithType } from '../common/types'; +import { getAllMigrations } from '../common/lib/get_all_migrations'; export interface EmbeddableSetup extends PersistableStateService { registerEmbeddableFactory: (factory: EmbeddableRegistryDefinition) => void; registerEnhancement: (enhancement: EnhancementRegistryDefinition) => void; - getMigrationVersions: () => string[]; + getAllMigrations: () => MigrateFunctionsObject; } export type EmbeddableStart = PersistableStateService; @@ -36,20 +41,27 @@ export type EmbeddableStart = PersistableStateService; export class EmbeddableServerPlugin implements Plugin { private readonly embeddableFactories: EmbeddableFactoryRegistry = new Map(); private readonly enhancements: EnhancementsRegistry = new Map(); + private migrateFn: PersistableStateMigrateFn | undefined; public setup(core: CoreSetup) { const commonContract = { getEmbeddableFactory: this.getEmbeddableFactory, getEnhancement: this.getEnhancement, }; + + this.migrateFn = getMigrateFunction(commonContract); return { - getMigrationVersions: this.getMigrationVersions, registerEmbeddableFactory: this.registerEmbeddableFactory, registerEnhancement: this.registerEnhancement, telemetry: getTelemetryFunction(commonContract), extract: getExtractFunction(commonContract), inject: getInjectFunction(commonContract), - migrate: getMigrateFunction(commonContract), + getAllMigrations: () => + getAllMigrations( + Array.from(this.embeddableFactories.values()), + Array.from(this.enhancements.values()), + this.migrateFn! + ), }; } @@ -63,7 +75,12 @@ export class EmbeddableServerPlugin implements Plugin + getAllMigrations( + Array.from(this.embeddableFactories.values()), + Array.from(this.enhancements.values()), + this.migrateFn! + ), }; } @@ -128,20 +145,4 @@ export class EmbeddableServerPlugin implements Plugin { - const uniqueVersions = new Set(); - for (const baseMigrationVersion of Object.keys(baseEmbeddableMigrations)) { - uniqueVersions.add(baseMigrationVersion); - } - const factories = this.embeddableFactories.values(); - for (const factory of factories) { - Object.keys(factory.migrations).forEach((version) => uniqueVersions.add(version)); - } - const enhancements = this.enhancements.values(); - for (const enhancement of enhancements) { - Object.keys(enhancement.migrations).forEach((version) => uniqueVersions.add(version)); - } - return Array.from(uniqueVersions); - }; } diff --git a/src/plugins/embeddable/server/server.api.md b/src/plugins/embeddable/server/server.api.md index d5c7ce29bab9..f8f3dcb0aa0b 100644 --- a/src/plugins/embeddable/server/server.api.md +++ b/src/plugins/embeddable/server/server.api.md @@ -23,8 +23,10 @@ export interface EmbeddableRegistryDefinition

{ + // Warning: (ae-forgotten-export) The symbol "MigrateFunctionsObject" needs to be exported by the entry point index.d.ts + // // (undocumented) - getMigrationVersions: () => string[]; + getAllMigrations: () => MigrateFunctionsObject; // (undocumented) registerEmbeddableFactory: (factory: EmbeddableRegistryDefinition) => void; // (undocumented) diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index 65857f02c883..54a3fe9e4399 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -129,6 +129,7 @@ export const applicationUsageSchema = { error: commonSchema, status: commonSchema, kibanaOverview: commonSchema, + r: commonSchema, // X-Pack apm: commonSchema, diff --git a/src/plugins/kibana_utils/common/persistable_state/index.ts b/src/plugins/kibana_utils/common/persistable_state/index.ts index 809cb15c3e96..1f417002f276 100644 --- a/src/plugins/kibana_utils/common/persistable_state/index.ts +++ b/src/plugins/kibana_utils/common/persistable_state/index.ts @@ -6,87 +6,6 @@ * Side Public License, v 1. */ -import { SavedObjectReference } from '../../../../core/types'; - -export type SerializableValue = string | number | boolean | null | undefined | SerializableState; -export type Serializable = SerializableValue | SerializableValue[]; - -export type SerializableState = { - [key: string]: Serializable; -}; - -export type MigrateFunction< - FromVersion extends SerializableState = SerializableState, - ToVersion extends SerializableState = SerializableState -> = (state: FromVersion) => ToVersion; - -export type MigrateFunctionsObject = { - [key: string]: MigrateFunction; -}; - -export interface PersistableStateService

{ - /** - * function to extract telemetry information - * @param state - * @param collector - */ - telemetry: (state: P, collector: Record) => Record; - /** - * inject function receives state and a list of references and should return state with references injected - * default is identity function - * @param state - * @param references - */ - inject: (state: P, references: SavedObjectReference[]) => P; - /** - * extract function receives state and should return state with references extracted and array of references - * default returns same state with empty reference array - * @param state - */ - extract: (state: P) => { state: P; references: SavedObjectReference[] }; - - /** - * migrateToLatest function receives state of older version and should migrate to the latest version - * @param state - * @param version - */ - migrateToLatest?: (state: SerializableState, version: string) => P; - - /** - * migrate function runs the specified migration - * @param state - * @param version - */ - migrate: (state: SerializableState, version: string) => SerializableState; -} - -export interface PersistableState

{ - /** - * function to extract telemetry information - * @param state - * @param collector - */ - telemetry: (state: P, collector: Record) => Record; - /** - * inject function receives state and a list of references and should return state with references injected - * default is identity function - * @param state - * @param references - */ - inject: (state: P, references: SavedObjectReference[]) => P; - /** - * extract function receives state and should return state with references extracted and array of references - * default returns same state with empty reference array - * @param state - */ - extract: (state: P) => { state: P; references: SavedObjectReference[] }; - - /** - * list of all migrations per semver - */ - migrations: MigrateFunctionsObject; -} - -export type PersistableStateDefinition

= Partial< - PersistableState

->; +export * from './types'; +export { migrateToLatest } from './migrate_to_latest'; +export { mergeMigrationFunctionMaps } from './merge_migration_function_map'; diff --git a/src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.test.ts b/src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.test.ts new file mode 100644 index 000000000000..9a6d774d7047 --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { mergeMigrationFunctionMaps } from './merge_migration_function_map'; + +describe('mergeSavedObjectMigrationMaps', () => { + const obj1 = { + '7.12.1': (state: number) => state + 1, + '7.12.2': (state: number) => state + 2, + }; + + const obj2 = { + '7.12.0': (state: number) => state - 2, + '7.12.2': (state: number) => state + 2, + }; + + test('correctly merges two saved object migration maps', () => { + const result = mergeMigrationFunctionMaps(obj1, obj2); + expect(result['7.12.0'](5)).toEqual(3); + expect(result['7.12.1'](5)).toEqual(6); + expect(result['7.12.2'](5)).toEqual(9); + }); +}); diff --git a/src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.ts b/src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.ts new file mode 100644 index 000000000000..fc48ab119b02 --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/merge_migration_function_map.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { mergeWith } from 'lodash'; +import { MigrateFunctionsObject, MigrateFunction, SerializableState } from './types'; + +export const mergeMigrationFunctionMaps = ( + obj1: MigrateFunctionsObject, + obj2: MigrateFunctionsObject +) => { + const customizer = (objValue: MigrateFunction, srcValue: MigrateFunction) => { + if (!srcValue || !objValue) { + return srcValue || objValue; + } + return (state: SerializableState) => objValue(srcValue(state)); + }; + + return mergeWith({ ...obj1 }, obj2, customizer); +}; diff --git a/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts new file mode 100644 index 000000000000..2ae376e787d2 --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SerializableState, MigrateFunction } from './types'; +import { migrateToLatest } from './migrate_to_latest'; + +interface StateV1 extends SerializableState { + name: string; +} + +interface StateV2 extends SerializableState { + firstName: string; + lastName: string; +} + +interface StateV3 extends SerializableState { + firstName: string; + lastName: string; + isAdmin: boolean; + age: number; +} + +const migrationV2: MigrateFunction = ({ name }) => { + return { + firstName: name, + lastName: '', + }; +}; + +const migrationV3: MigrateFunction = ({ firstName, lastName }) => { + return { + firstName, + lastName, + isAdmin: false, + age: 0, + }; +}; + +test('returns the same object if there are no migrations to be applied', () => { + const migrated = migrateToLatest( + {}, + { + state: { name: 'Foo' }, + version: '0.0.1', + } + ); + + expect(migrated).toEqual({ + state: { name: 'Foo' }, + version: '0.0.1', + }); +}); + +test('applies a single migration', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '0.0.2': (migrationV2 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '0.0.1', + } + ); + + expect(newState).toEqual({ + firstName: 'Foo', + lastName: '', + }); + expect(newVersion).toEqual('0.0.2'); +}); + +test('does not apply migration if it has the same version as state', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '0.0.54': (migrationV2 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '0.0.54', + } + ); + + expect(newState).toEqual({ + name: 'Foo', + }); + expect(newVersion).toEqual('0.0.54'); +}); + +test('does not apply migration if it has lower version', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '0.2.2': (migrationV2 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '0.3.1', + } + ); + + expect(newState).toEqual({ + name: 'Foo', + }); + expect(newVersion).toEqual('0.3.1'); +}); + +test('applies two migrations consecutively', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '7.14.0': (migrationV2 as unknown) as MigrateFunction, + '7.14.2': (migrationV3 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '7.13.4', + } + ); + + expect(newState).toEqual({ + firstName: 'Foo', + lastName: '', + isAdmin: false, + age: 0, + }); + expect(newVersion).toEqual('7.14.2'); +}); + +test('applies only migrations which are have higher semver version', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '7.14.0': (migrationV2 as unknown) as MigrateFunction, // not applied + '7.14.1': (() => ({})) as MigrateFunction, // not applied + '7.14.2': (migrationV3 as unknown) as MigrateFunction, + }, + { + state: { firstName: 'FooBar', lastName: 'Baz' }, + version: '7.14.1', + } + ); + + expect(newState).toEqual({ + firstName: 'FooBar', + lastName: 'Baz', + isAdmin: false, + age: 0, + }); + expect(newVersion).toEqual('7.14.2'); +}); diff --git a/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts new file mode 100644 index 000000000000..c16392164e3e --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { compare } from 'semver'; +import { SerializableState, VersionedState, MigrateFunctionsObject } from './types'; + +export function migrateToLatest( + migrations: MigrateFunctionsObject, + { state, version: oldVersion }: VersionedState +): VersionedState { + const versions = Object.keys(migrations || {}) + .filter((v) => compare(v, oldVersion) > 0) + .sort(compare); + + if (!versions.length) return { state, version: oldVersion } as VersionedState; + + for (const version of versions) { + state = migrations[version]!(state); + } + + return { + state: state as S, + version: versions[versions.length - 1], + }; +} diff --git a/src/plugins/kibana_utils/common/persistable_state/types.ts b/src/plugins/kibana_utils/common/persistable_state/types.ts new file mode 100644 index 000000000000..ba2b923e3e47 --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/types.ts @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SavedObjectReference } from '../../../../core/types'; + +/** + * Serializable state is something is a POJO JavaScript object that can be + * serialized to a JSON string. + */ +export type SerializableState = { + [key: string]: Serializable; +}; +export type SerializableValue = string | number | boolean | null | undefined | SerializableState; +export type Serializable = SerializableValue | SerializableValue[]; + +/** + * Versioned state is a POJO JavaScript object that can be serialized to JSON, + * and which also contains the version information. The version is stored in + * semver format and corresponds to the Kibana release version when the object + * was created. The version can be used to apply migrations to the object. + * + * For example: + * + * ```ts + * const obj: VersionedState<{ dashboardId: string }> = { + * version: '7.14.0', + * state: { + * dashboardId: '123', + * }, + * }; + * ``` + */ +export interface VersionedState { + version: string; + state: S; +} + +/** + * Persistable state interface can be implemented by something that persists + * (stores) state, for example, in a saved object. Once implemented that thing + * will gain ability to "extract" and "inject" saved object references, which + * are necessary for various saved object tasks, such as export. It will also be + * able to do state migrations across Kibana versions, if the shape of the state + * would change over time. + * + * @todo Maybe rename it to `PersistableStateItem`? + */ +export interface PersistableState

{ + /** + * Function which reports telemetry information. This function is essentially + * a "reducer" - it receives the existing "stats" object and returns an + * updated version of the "stats" object. + * + * @param state The persistable state serializable state object. + * @param stats Stats object containing the stats which were already + * collected. This `stats` object shall not be mutated in-line. + * @returns A new stats object augmented with new telemetry information. + */ + telemetry: (state: P, stats: Record) => Record; + + /** + * A function which receives state and a list of references and should return + * back the state with references injected. The default is an identity + * function. + * + * @param state The persistable state serializable state object. + * @param references List of saved object references. + * @returns Persistable state object with references injected. + */ + inject: (state: P, references: SavedObjectReference[]) => P; + + /** + * A function which receives state and should return the state with references + * extracted and an array of the extracted references. The default case could + * simply return the same state with an empty array of references. + * + * @param state The persistable state serializable state object. + * @returns Persistable state object with references extracted and a list of + * references. + */ + extract: (state: P) => { state: P; references: SavedObjectReference[] }; + + /** + * A list of migration functions, which migrate the persistable state + * serializable object to the next version. Migration functions should are + * keyed by the Kibana version using semver, where the version indicates to + * which version the state will be migrated to. + */ + migrations: MigrateFunctionsObject; +} + +/** + * Collection of migrations that a given type of persistable state object has + * accumulated over time. Migration functions are keyed using semver version + * of Kibana releases. + */ +export type MigrateFunctionsObject = { [semver: string]: MigrateFunction }; +export type MigrateFunction< + FromVersion extends SerializableState = SerializableState, + ToVersion extends SerializableState = SerializableState +> = (state: FromVersion) => ToVersion; + +/** + * migrate function runs the specified migration + * @param state + * @param version + */ +export type PersistableStateMigrateFn = ( + state: SerializableState, + version: string +) => SerializableState; + +/** + * @todo Shall we remove this? + */ +export type PersistableStateDefinition

= Partial< + PersistableState

+>; + +/** + * @todo Add description. + */ +export interface PersistableStateService

{ + /** + * Function which reports telemetry information. This function is essentially + * a "reducer" - it receives the existing "stats" object and returns an + * updated version of the "stats" object. + * + * @param state The persistable state serializable state object. + * @param stats Stats object containing the stats which were already + * collected. This `stats` object shall not be mutated in-line. + * @returns A new stats object augmented with new telemetry information. + */ + telemetry(state: P, collector: Record): Record; + + /** + * A function which receives state and a list of references and should return + * back the state with references injected. The default is an identity + * function. + * + * @param state The persistable state serializable state object. + * @param references List of saved object references. + * @returns Persistable state object with references injected. + */ + inject(state: P, references: SavedObjectReference[]): P; + + /** + * A function which receives state and should return the state with references + * extracted and an array of the extracted references. The default case could + * simply return the same state with an empty array of references. + * + * @param state The persistable state serializable state object. + * @returns Persistable state object with references extracted and a list of + * references. + */ + extract(state: P): { state: P; references: SavedObjectReference[] }; + + /** + * A function which receives the state of an older object and version and + * should migrate the state of the object to the latest possible version using + * the `.migrations` dictionary provided on a {@link PersistableState} item. + * + * @param state The persistable state serializable state object. + * @param version Current semver version of the `state`. + * @returns A serializable state object migrated to the latest state. + */ + migrateToLatest?: (state: VersionedState) => VersionedState

; + + /** + * returns all registered migrations + */ + getAllMigrations?: () => MigrateFunctionsObject; +} diff --git a/src/plugins/share/common/mocks.ts b/src/plugins/share/common/mocks.ts new file mode 100644 index 000000000000..6768c1aff810 --- /dev/null +++ b/src/plugins/share/common/mocks.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './url_service/mocks'; diff --git a/src/plugins/share/common/url_service/locators/locator.ts b/src/plugins/share/common/url_service/locators/locator.ts index 680fb2231fc4..bae57b6d8a31 100644 --- a/src/plugins/share/common/url_service/locators/locator.ts +++ b/src/plugins/share/common/url_service/locators/locator.ts @@ -30,7 +30,7 @@ export interface LocatorDependencies { getUrl: (location: KibanaLocation, getUrlParams: LocatorGetUrlParams) => Promise; } -export class Locator

implements PersistableState

, LocatorPublic

{ +export class Locator

implements LocatorPublic

{ public readonly migrations: PersistableState

['migrations']; constructor( diff --git a/src/plugins/share/common/url_service/mocks.ts b/src/plugins/share/common/url_service/mocks.ts new file mode 100644 index 000000000000..be86cfe40171 --- /dev/null +++ b/src/plugins/share/common/url_service/mocks.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable max-classes-per-file */ + +import type { LocatorDefinition, KibanaLocation } from '.'; +import { UrlService } from '.'; + +export class MockUrlService extends UrlService { + constructor() { + super({ + navigate: async () => {}, + getUrl: async ({ app, path }, { absolute }) => { + return `${absolute ? 'https://example.com' : ''}/app/${app}${path}`; + }, + }); + } +} + +export class MockLocatorDefinition implements LocatorDefinition { + constructor(public readonly id: string) {} + + public readonly getLocation = async (): Promise => { + return { + app: 'test', + path: '/test', + state: { + foo: 'bar', + }, + }; + }; +} diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index 5ee3156534c5..1f999b59ddb6 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -9,6 +9,7 @@ export { CSV_QUOTE_VALUES_SETTING, CSV_SEPARATOR_SETTING } from '../common/constants'; export { LocatorDefinition, LocatorPublic, KibanaLocation } from '../common/url_service'; +export { parseSearchParams, formatSearchParams } from './url_service'; export { UrlGeneratorStateMapping } from './url_generators/url_generator_definition'; diff --git a/src/plugins/share/public/mocks.ts b/src/plugins/share/public/mocks.ts new file mode 100644 index 000000000000..eb9c6d0d1090 --- /dev/null +++ b/src/plugins/share/public/mocks.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from '../common/mocks'; diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 893108b56bcf..adc28556d7a3 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -19,6 +19,7 @@ import { UrlGeneratorsStart, } from './url_generators/url_generator_service'; import { UrlService } from '../common/url_service'; +import { RedirectManager } from './url_service'; export interface ShareSetupDependencies { securityOss?: SecurityOssPluginSetup; @@ -86,6 +87,11 @@ export class SharePlugin implements Plugin { }, }); + const redirectManager = new RedirectManager({ + url: this.url, + }); + redirectManager.registerRedirectApp(core); + return { ...this.shareMenuRegistry.setup(), urlGenerators: this.urlGeneratorsService.setup(core), diff --git a/src/plugins/share/public/url_service/index.ts b/src/plugins/share/public/url_service/index.ts new file mode 100644 index 000000000000..8fa88e9c570b --- /dev/null +++ b/src/plugins/share/public/url_service/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './redirect'; diff --git a/src/plugins/share/public/url_service/redirect/README.md b/src/plugins/share/public/url_service/redirect/README.md new file mode 100644 index 000000000000..cd31f2b80099 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/README.md @@ -0,0 +1,18 @@ +# Redirect endpoint + +This folder contains implementation of *the Redirect Endpoint*. The Redirect +Endpoint receives parameters of a locator and then "redirects" the user using +navigation without page refresh to the location targeted by the locator. While +using the locator, it is also possible to set the *location state* of the +target page. Location state is a serializable object which can be passed to +the destination app while navigating without a page reload. + +``` +/app/r?l=MY_LOCATOR&v=7.14.0&p=(dashboardId:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) +``` + +For example: + +``` +/app/r?l=DISCOVER_APP_LOCATOR&v=7.14.0&p={%22indexPatternId%22:%22d3d7af60-4c81-11e8-b3d7-01146121b73d%22} +``` diff --git a/src/plugins/share/public/url_service/redirect/components/error.tsx b/src/plugins/share/public/url_service/redirect/components/error.tsx new file mode 100644 index 000000000000..716848427c63 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/components/error.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import { + EuiEmptyPrompt, + EuiCallOut, + EuiCodeBlock, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const defaultTitle = i18n.translate('share.urlService.redirect.components.Error.title', { + defaultMessage: 'Redirection error', + description: + 'Title displayed to user in redirect endpoint when redirection cannot be performed successfully.', +}); + +export interface ErrorProps { + title?: string; + error: Error; +} + +export const Error: React.FC = ({ title = defaultTitle, error }) => { + return ( + {title}} + body={ + + + + {error.message} + + + + + {error.stack ? error.stack : ''} + + + } + /> + ); +}; diff --git a/src/plugins/share/public/url_service/redirect/components/page.tsx b/src/plugins/share/public/url_service/redirect/components/page.tsx new file mode 100644 index 000000000000..805213b73fdd --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/components/page.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { EuiPageTemplate } from '@elastic/eui'; +import { Error } from './error'; +import { RedirectManager } from '../redirect_manager'; +import { Spinner } from './spinner'; + +export interface PageProps { + manager: Pick; +} + +export const Page: React.FC = ({ manager }) => { + const error = useObservable(manager.error$); + + if (error) { + return ( + + + + ); + } + + return ( + + + + ); +}; diff --git a/src/plugins/share/public/url_service/redirect/components/spinner.tsx b/src/plugins/share/public/url_service/redirect/components/spinner.tsx new file mode 100644 index 000000000000..a70ae5eb096a --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/components/spinner.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const text = i18n.translate('share.urlService.redirect.components.Spinner.label', { + defaultMessage: 'Redirecting…', + description: 'Redirect endpoint spinner label.', +}); + +export const Spinner: React.FC = () => { + return ( + + + + + + + + + {text} + + + + + + ); +}; diff --git a/src/plugins/share/public/url_service/redirect/index.ts b/src/plugins/share/public/url_service/redirect/index.ts new file mode 100644 index 000000000000..8dbc5f4e0ab1 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './redirect_manager'; +export { formatSearchParams } from './util/format_search_params'; +export { parseSearchParams } from './util/parse_search_params'; diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.test.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.test.ts new file mode 100644 index 000000000000..f610268f529b --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RedirectManager } from './redirect_manager'; +import { MockUrlService } from '../../mocks'; +import { MigrateFunction } from 'src/plugins/kibana_utils/common'; + +const setup = () => { + const url = new MockUrlService(); + const locator = url.locators.create({ + id: 'TEST_LOCATOR', + getLocation: async () => { + return { + app: '', + path: '', + state: {}, + }; + }, + migrations: { + '0.0.2': ((({ num }: { num: number }) => ({ num: num * 2 })) as unknown) as MigrateFunction, + }, + }); + const manager = new RedirectManager({ + url, + }); + + return { + url, + locator, + manager, + }; +}; + +describe('on page mount', () => { + test('execute locator "navigate" method', async () => { + const { locator, manager } = setup(); + const spy = jest.spyOn(locator, 'navigate'); + + expect(spy).toHaveBeenCalledTimes(0); + manager.onMount(`l=TEST_LOCATOR&v=0.0.3&p=${encodeURIComponent(JSON.stringify({}))}`); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('passes arguments provided in URL to locator "navigate" method', async () => { + const { locator, manager } = setup(); + const spy = jest.spyOn(locator, 'navigate'); + + manager.onMount( + `l=TEST_LOCATOR&v=0.0.3&p=${encodeURIComponent( + JSON.stringify({ + foo: 'bar', + }) + )}` + ); + expect(spy).toHaveBeenCalledWith({ + foo: 'bar', + }); + }); + + test('migrates parameters on-the-fly to the latest version', async () => { + const { locator, manager } = setup(); + const spy = jest.spyOn(locator, 'navigate'); + + manager.onMount( + `l=TEST_LOCATOR&v=0.0.1&p=${encodeURIComponent( + JSON.stringify({ + num: 1, + }) + )}` + ); + expect(spy).toHaveBeenCalledWith({ + num: 2, + }); + }); + + test('throws if locator does not exist', async () => { + const { manager } = setup(); + + expect(() => + manager.onMount( + `l=TEST_LOCATOR_WHICH_DOES_NOT_EXIST&v=0.0.3&p=${encodeURIComponent(JSON.stringify({}))}` + ) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator [ID = TEST_LOCATOR_WHICH_DOES_NOT_EXIST] does not exist."` + ); + }); +}); diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.ts new file mode 100644 index 000000000000..6148249f5a04 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { CoreSetup } from 'src/core/public'; +import { i18n } from '@kbn/i18n'; +import { BehaviorSubject } from 'rxjs'; +import { migrateToLatest } from '../../../../kibana_utils/common'; +import type { SerializableState } from '../../../../kibana_utils/common'; +import type { UrlService } from '../../../common/url_service'; +import { render } from './render'; +import { parseSearchParams } from './util/parse_search_params'; + +export interface RedirectOptions { + /** Locator ID. */ + id: string; + + /** Kibana version when locator params where generated. */ + version: string; + + /** Locator params. */ + params: unknown & SerializableState; +} + +export interface RedirectManagerDependencies { + url: UrlService; +} + +export class RedirectManager { + public readonly error$ = new BehaviorSubject(null); + + constructor(public readonly deps: RedirectManagerDependencies) {} + + public registerRedirectApp(core: CoreSetup) { + core.application.register({ + id: 'r', + title: 'Redirect endpoint', + chromeless: true, + mount: (params) => { + const unmount = render(params.element, { manager: this }); + this.onMount(params.history.location.search); + return () => { + unmount(); + }; + }, + }); + } + + public onMount(urlLocationSearch: string) { + const options = this.parseSearchParams(urlLocationSearch); + const locator = this.deps.url.locators.get(options.id); + + if (!locator) { + const message = i18n.translate('share.urlService.redirect.RedirectManager.locatorNotFound', { + defaultMessage: 'Locator [ID = {id}] does not exist.', + values: { + id: options.id, + }, + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because locator does not exist.', + }); + const error = new Error(message); + this.error$.next(error); + throw error; + } + + const { state: migratedParams } = migrateToLatest(locator.migrations, { + state: options.params, + version: options.version, + }); + + locator + .navigate(migratedParams) + .then() + .catch((error) => { + // eslint-disable-next-line no-console + console.log('Redirect endpoint failed to execute locator redirect.'); + // eslint-disable-next-line no-console + console.error(error); + }); + } + + protected parseSearchParams(urlLocationSearch: string): RedirectOptions { + try { + return parseSearchParams(urlLocationSearch); + } catch (error) { + this.error$.next(error); + throw error; + } + } +} diff --git a/src/plugins/share/public/url_service/redirect/render.ts b/src/plugins/share/public/url_service/redirect/render.ts new file mode 100644 index 000000000000..2b9c3a50758e --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/render.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { Page, PageProps } from './components/page'; + +export const render = (container: HTMLElement, props: PageProps) => { + ReactDOM.render(React.createElement(Page, props), container); + + return () => { + ReactDOM.unmountComponentAtNode(container); + }; +}; diff --git a/src/plugins/share/public/url_service/redirect/util/format_search_params.test.ts b/src/plugins/share/public/url_service/redirect/util/format_search_params.test.ts new file mode 100644 index 000000000000..f8d8d6a6295d --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/format_search_params.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { formatSearchParams } from './format_search_params'; +import { parseSearchParams } from './parse_search_params'; + +test('can format typical locator settings as URL path search params', () => { + const search = formatSearchParams({ + id: 'LOCATOR_ID', + version: '7.21.3', + params: { + dashboardId: '123', + mode: 'edit', + }, + }); + + expect(search.get('l')).toBe('LOCATOR_ID'); + expect(search.get('v')).toBe('7.21.3'); + expect(JSON.parse(search.get('p')!)).toEqual({ + dashboardId: '123', + mode: 'edit', + }); +}); + +test('can format and then parse redirect options', () => { + const options = { + id: 'LOCATOR_ID', + version: '7.21.3', + params: { + dashboardId: '123', + mode: 'edit', + }, + }; + const formatted = formatSearchParams(options); + const parsed = parseSearchParams(formatted.toString()); + + expect(parsed).toEqual(options); +}); diff --git a/src/plugins/share/public/url_service/redirect/util/format_search_params.ts b/src/plugins/share/public/url_service/redirect/util/format_search_params.ts new file mode 100644 index 000000000000..12c6424182a8 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/format_search_params.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RedirectOptions } from '../redirect_manager'; + +export function formatSearchParams(opts: RedirectOptions): URLSearchParams { + const searchParams = new URLSearchParams(); + + searchParams.set('l', opts.id); + searchParams.set('v', opts.version); + searchParams.set('p', JSON.stringify(opts.params)); + + return searchParams; +} diff --git a/src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts b/src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts new file mode 100644 index 000000000000..418e21cfd405 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { parseSearchParams } from './parse_search_params'; + +test('parses a well constructed URL path search part', () => { + const res = parseSearchParams(`?l=LOCATOR&v=0.0.0&p=${encodeURIComponent('{"foo":"bar"}')}`); + + expect(res).toEqual({ + id: 'LOCATOR', + version: '0.0.0', + params: { + foo: 'bar', + }, + }); +}); + +test('throws on missing locator ID', () => { + expect(() => + parseSearchParams(`?v=0.0.0&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator ID not specified. Specify \\"l\\" search parameter in the URL, which should be an existing locator ID."` + ); + + expect(() => + parseSearchParams(`?l=&v=0.0.0&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator ID not specified. Specify \\"l\\" search parameter in the URL, which should be an existing locator ID."` + ); +}); + +test('throws on missing version', () => { + expect(() => + parseSearchParams(`?l=LOCATOR&v=&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator params version not specified. Specify \\"v\\" search parameter in the URL, which should be the release version of Kibana when locator params were generated."` + ); + + expect(() => + parseSearchParams(`?l=LOCATOR&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator params version not specified. Specify \\"v\\" search parameter in the URL, which should be the release version of Kibana when locator params were generated."` + ); +}); + +test('throws on missing params', () => { + expect(() => parseSearchParams(`?l=LOCATOR&v=1.1.1`)).toThrowErrorMatchingInlineSnapshot( + `"Locator params not specified. Specify \\"p\\" search parameter in the URL, which should be JSON serialized object of locator params."` + ); + + expect(() => parseSearchParams(`?l=LOCATOR&v=1.1.1&p=`)).toThrowErrorMatchingInlineSnapshot( + `"Locator params not specified. Specify \\"p\\" search parameter in the URL, which should be JSON serialized object of locator params."` + ); +}); + +test('throws if params are not JSON', () => { + expect(() => parseSearchParams(`?l=LOCATOR&v=1.1.1&p=asdf`)).toThrowErrorMatchingInlineSnapshot( + `"Could not parse locator params. Locator params must be serialized as JSON and set at \\"p\\" URL search parameter."` + ); +}); diff --git a/src/plugins/share/public/url_service/redirect/util/parse_search_params.ts b/src/plugins/share/public/url_service/redirect/util/parse_search_params.ts new file mode 100644 index 000000000000..a60c1d1b68a9 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/parse_search_params.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SerializableState } from 'src/plugins/kibana_utils/common'; +import { i18n } from '@kbn/i18n'; +import type { RedirectOptions } from '../redirect_manager'; + +/** + * Parses redirect endpoint URL path search parameters. Expects them in the + * following form: + * + * ``` + * /r?l=&v=&p= + * ``` + * + * @param urlSearch Search part of URL path. + * @returns Parsed out locator ID, version, and locator params. + */ +export function parseSearchParams(urlSearch: string): RedirectOptions { + const search = new URLSearchParams(urlSearch); + const id = search.get('l'); + const version = search.get('v'); + const paramsJson = search.get('p'); + + if (!id) { + const message = i18n.translate( + 'share.urlService.redirect.RedirectManager.missingParamLocator', + { + defaultMessage: + 'Locator ID not specified. Specify "l" search parameter in the URL, which should be an existing locator ID.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because of missing locator ID.', + } + ); + throw new Error(message); + } + + if (!version) { + const message = i18n.translate( + 'share.urlService.redirect.RedirectManager.missingParamVersion', + { + defaultMessage: + 'Locator params version not specified. Specify "v" search parameter in the URL, which should be the release version of Kibana when locator params were generated.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because of missing version parameter.', + } + ); + throw new Error(message); + } + + if (!paramsJson) { + const message = i18n.translate('share.urlService.redirect.RedirectManager.missingParamParams', { + defaultMessage: + 'Locator params not specified. Specify "p" search parameter in the URL, which should be JSON serialized object of locator params.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because of missing params parameter.', + }); + throw new Error(message); + } + + let params: unknown & SerializableState; + try { + params = JSON.parse(paramsJson); + } catch { + const message = i18n.translate('share.urlService.redirect.RedirectManager.invalidParamParams', { + defaultMessage: + 'Could not parse locator params. Locator params must be serialized as JSON and set at "p" URL search parameter.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because locator parameters could not be parsed as JSON.', + }); + throw new Error(message); + } + + return { + id, + version, + params, + }; +} diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index d11e1cf78c96..13caa3c33fa8 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -1743,6 +1743,137 @@ } } }, + "r": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "Always `main`" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 90 days" + } + }, + "views": { + "type": "array", + "items": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "The application view being tracked" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application sub view since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application sub view is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 90 days" + } + } + } + } + } + } + }, "apm": { "properties": { "appId": { diff --git a/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts b/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts index 1409e4dd2c90..8466093664d0 100644 --- a/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts +++ b/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts @@ -18,6 +18,7 @@ import { contextServiceMock, loggingSystemMock, metricsServiceMock, + executionContextServiceMock, } from '../../../../../core/server/mocks'; import { createHttpServer } from '../../../../../core/server/test_utils'; import { registerStatsRoute } from '../stats'; @@ -37,6 +38,7 @@ describe('/api/stats', () => { server = createHttpServer(); httpSetup = await server.setup({ context: contextServiceMock.createSetupContract(), + executionContext: executionContextServiceMock.createInternalSetupContract(), }); overallStatus$ = new BehaviorSubject({ level: ServiceStatusLevels.available, diff --git a/src/plugins/user_setup/README.md b/src/plugins/user_setup/README.md new file mode 100644 index 000000000000..61ec964f5bb8 --- /dev/null +++ b/src/plugins/user_setup/README.md @@ -0,0 +1,3 @@ +# `userSetup` plugin + +The plugin provides UI and APIs for the interactive setup mode. diff --git a/src/plugins/user_setup/jest.config.js b/src/plugins/user_setup/jest.config.js new file mode 100644 index 000000000000..75e355e230c5 --- /dev/null +++ b/src/plugins/user_setup/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/user_setup'], +}; diff --git a/src/plugins/user_setup/kibana.json b/src/plugins/user_setup/kibana.json new file mode 100644 index 000000000000..192fd42cd3e2 --- /dev/null +++ b/src/plugins/user_setup/kibana.json @@ -0,0 +1,13 @@ +{ + "id": "userSetup", + "owner": { + "name": "Platform Security", + "githubTeam": "kibana-security" + }, + "description": "This plugin provides UI and APIs for the interactive setup mode.", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["userSetup"], + "server": true, + "ui": true +} diff --git a/src/plugins/user_setup/public/app.tsx b/src/plugins/user_setup/public/app.tsx new file mode 100644 index 000000000000..2b6b70895397 --- /dev/null +++ b/src/plugins/user_setup/public/app.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiPageTemplate, EuiPanel, EuiText } from '@elastic/eui'; +import React from 'react'; + +export const App = () => { + return ( + + + Kibana server is not ready yet. + + + ); +}; diff --git a/src/plugins/user_setup/public/index.ts b/src/plugins/user_setup/public/index.ts new file mode 100644 index 000000000000..153bc92a0dd0 --- /dev/null +++ b/src/plugins/user_setup/public/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UserSetupPlugin } from './plugin'; + +export const plugin = () => new UserSetupPlugin(); diff --git a/src/plugins/user_setup/public/plugin.tsx b/src/plugins/user_setup/public/plugin.tsx new file mode 100644 index 000000000000..677c27cc456d --- /dev/null +++ b/src/plugins/user_setup/public/plugin.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import type { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { App } from './app'; + +export class UserSetupPlugin implements Plugin { + public setup(core: CoreSetup) { + core.application.register({ + id: 'userSetup', + title: 'User Setup', + chromeless: true, + mount: (params) => { + ReactDOM.render(, params.element); + return () => ReactDOM.unmountComponentAtNode(params.element); + }, + }); + } + + public start(core: CoreStart) {} +} diff --git a/src/plugins/user_setup/server/config.ts b/src/plugins/user_setup/server/config.ts new file mode 100644 index 000000000000..b16c51bcbda0 --- /dev/null +++ b/src/plugins/user_setup/server/config.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; + +export type ConfigType = TypeOf; + +export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); diff --git a/src/plugins/user_setup/server/index.ts b/src/plugins/user_setup/server/index.ts new file mode 100644 index 000000000000..2a43cbbf65c9 --- /dev/null +++ b/src/plugins/user_setup/server/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import type { PluginConfigDescriptor } from 'src/core/server'; + +import { ConfigSchema } from './config'; +import { UserSetupPlugin } from './plugin'; + +export const config: PluginConfigDescriptor> = { + schema: ConfigSchema, +}; + +export const plugin = () => new UserSetupPlugin(); diff --git a/src/plugins/user_setup/server/plugin.ts b/src/plugins/user_setup/server/plugin.ts new file mode 100644 index 000000000000..918c9a200793 --- /dev/null +++ b/src/plugins/user_setup/server/plugin.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { CoreSetup, CoreStart, Plugin } from 'src/core/server'; + +export class UserSetupPlugin implements Plugin { + public setup(core: CoreSetup) {} + + public start(core: CoreStart) {} + + public stop() {} +} diff --git a/src/plugins/user_setup/tsconfig.json b/src/plugins/user_setup/tsconfig.json new file mode 100644 index 000000000000..d211a70f12df --- /dev/null +++ b/src/plugins/user_setup/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["public/**/*", "server/**/*"], + "references": [{ "path": "../../core/tsconfig.json" }] +} diff --git a/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx b/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx index 436ce81d3ce3..628c2d74dc43 100644 --- a/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx +++ b/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx @@ -10,7 +10,7 @@ import React, { useCallback } from 'react'; import Color from 'color'; import { LegendColorPicker, Position } from '@elastic/charts'; import { PopoverAnchorPosition, EuiPopover, EuiOutsideClickDetector } from '@elastic/eui'; -import { DatatableRow } from '../../../expressions/public'; +import type { DatatableRow } from '../../../expressions/public'; import type { PersistedState } from '../../../visualizations/public'; import { ColorPicker } from '../../../charts/public'; import { BucketColumns } from '../types'; diff --git a/src/plugins/vis_type_pie/public/utils/get_layers.test.ts b/src/plugins/vis_type_pie/public/utils/get_layers.test.ts index e0658eaa295f..d6f80b3eb231 100644 --- a/src/plugins/vis_type_pie/public/utils/get_layers.test.ts +++ b/src/plugins/vis_type_pie/public/utils/get_layers.test.ts @@ -7,6 +7,8 @@ */ import { ShapeTreeNode } from '@elastic/charts'; import { PaletteDefinition, SeriesLayer } from '../../../charts/public'; +import { dataPluginMock } from '../../../data/public/mocks'; +import type { DataPublicPluginStart } from '../../../data/public'; import { computeColor } from './get_layers'; import { createMockVisData, createMockBucketColumns, createMockPieParams } from '../mocks'; @@ -14,6 +16,20 @@ const visData = createMockVisData(); const buckets = createMockBucketColumns(); const visParams = createMockPieParams(); const colors = ['color1', 'color2', 'color3', 'color4']; +const dataMock = dataPluginMock.createStartContract(); +interface RangeProps { + gte: number; + lt: number; +} + +dataMock.fieldFormats = ({ + deserialize: jest.fn(() => ({ + convert: jest.fn((s: RangeProps) => { + return `≥ ${s.gte} and < ${s.lt}`; + }), + })), +} as unknown) as DataPublicPluginStart['fieldFormats']; + export const getPaletteRegistry = () => { const mockPalette1: jest.Mocked = { id: 'default', @@ -60,7 +76,8 @@ describe('computeColor', () => { visData.rows, visParams, getPaletteRegistry(), - false + false, + dataMock.fieldFormats ); expect(color).toEqual(colors[0]); }); @@ -84,7 +101,8 @@ describe('computeColor', () => { visData.rows, visParams, getPaletteRegistry(), - false + false, + dataMock.fieldFormats ); expect(color).toEqual('color3'); }); @@ -107,8 +125,60 @@ describe('computeColor', () => { visData.rows, visParams, getPaletteRegistry(), - false + false, + dataMock.fieldFormats ); expect(color).toEqual('#000028'); }); + + it('returns the overwriteColor for older visualizations with formatted values', () => { + const d = ({ + dataName: { + gte: 1000, + lt: 2000, + }, + depth: 1, + sortIndex: 0, + parent: { + children: [ + [ + { + gte: 1000, + lt: 2000, + }, + ], + [ + { + gte: 2000, + lt: 3000, + }, + ], + ], + depth: 0, + sortIndex: 0, + }, + } as unknown) as ShapeTreeNode; + const visParamsNew = { + ...visParams, + distinctColors: true, + }; + const color = computeColor( + d, + true, + { '≥ 1000 and < 2000': '#3F6833' }, + buckets, + visData.rows, + visParamsNew, + getPaletteRegistry(), + false, + dataMock.fieldFormats, + { + id: 'range', + params: { + id: 'number', + }, + } + ); + expect(color).toEqual('#3F6833'); + }); }); diff --git a/src/plugins/vis_type_pie/public/utils/get_layers.ts b/src/plugins/vis_type_pie/public/utils/get_layers.ts index 5a82871bf368..b995df83c0bb 100644 --- a/src/plugins/vis_type_pie/public/utils/get_layers.ts +++ b/src/plugins/vis_type_pie/public/utils/get_layers.ts @@ -15,9 +15,9 @@ import { } from '@elastic/charts'; import { isEqual } from 'lodash'; import { SeriesLayer, PaletteRegistry, lightenColor } from '../../../charts/public'; -import { DataPublicPluginStart } from '../../../data/public'; -import { DatatableRow } from '../../../expressions/public'; -import { BucketColumns, PieVisParams, SplitDimensionParams } from '../types'; +import type { DataPublicPluginStart } from '../../../data/public'; +import type { DatatableRow } from '../../../expressions/public'; +import type { BucketColumns, PieVisParams, SplitDimensionParams } from '../types'; import { getDistinctSeries } from './get_distinct_series'; const EMPTY_SLICE = Symbol('empty_slice'); @@ -30,14 +30,33 @@ export const computeColor = ( rows: DatatableRow[], visParams: PieVisParams, palettes: PaletteRegistry | null, - syncColors: boolean + syncColors: boolean, + formatter: DataPublicPluginStart['fieldFormats'], + format?: BucketColumns['format'] ) => { const { parentSeries, allSeries } = getDistinctSeries(rows, columns); + const dataName = d.dataName; + + let formattedLabel = ''; + if (format) { + formattedLabel = formatter.deserialize(format).convert(dataName) ?? ''; + } if (visParams.distinctColors) { - const dataName = d.dataName; + let overwriteColor; + // this is for supporting old visualizations (created by vislib plugin) + // it seems that there for some aggs, the uiState saved from vislib is + // different than the es-charts handle it + if (overwriteColors.hasOwnProperty(formattedLabel)) { + overwriteColor = overwriteColors[formattedLabel]; + } + if (Object.keys(overwriteColors).includes(dataName.toString())) { - return overwriteColors[dataName]; + overwriteColor = overwriteColors[dataName]; + } + + if (overwriteColor) { + return overwriteColor; } const index = allSeries.findIndex((name) => isEqual(name, dataName)); @@ -80,6 +99,13 @@ export const computeColor = ( } let overwriteColor; + // this is for supporting old visualizations (created by vislib plugin) + // it seems that there for some aggs, the uiState saved from vislib is + // different than the es-charts handle it + if (overwriteColors.hasOwnProperty(formattedLabel)) { + overwriteColor = overwriteColors[formattedLabel]; + } + seriesLayers.forEach((layer) => { if (Object.keys(overwriteColors).includes(layer.name)) { overwriteColor = overwriteColors[layer.name]; @@ -141,7 +167,7 @@ export const getLayers = ( if (name1 === '__other__' && name2 !== '__other__') return 1; if (name2 === '__other__' && name1 !== '__other__') return -1; // metric sorting - if (sort !== '_key') { + if (sort && sort !== '_key') { if (params?.order === 'desc') { return node2.value - node1.value; } else { @@ -169,7 +195,9 @@ export const getLayers = ( rows, visParams, palettes, - syncColors + syncColors, + formatter, + col.format ); return outputColor || 'rgba(0,0,0,0)'; diff --git a/test/api_integration/apis/index_patterns/deprecations/index.ts b/test/api_integration/apis/index_patterns/deprecations/index.ts new file mode 100644 index 000000000000..a3970fc8004c --- /dev/null +++ b/test/api_integration/apis/index_patterns/deprecations/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('scripted_fields_deprecations', () => { + loadTestFile(require.resolve('./scripted_fields')); + }); +} diff --git a/test/api_integration/apis/index_patterns/deprecations/scripted_fields.ts b/test/api_integration/apis/index_patterns/deprecations/scripted_fields.ts new file mode 100644 index 000000000000..168c2b005d80 --- /dev/null +++ b/test/api_integration/apis/index_patterns/deprecations/scripted_fields.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import type { DeprecationsGetResponse } from 'src/core/server/types'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('scripted field deprecations', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + await esArchiver.load('test/api_integration/fixtures/es_archiver/index_patterns/basic_index'); + }); + + after(async () => { + await esArchiver.unload( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); + }); + + it('no scripted fields deprecations', async () => { + const { body } = await supertest.get('/api/deprecations/'); + + const { deprecations } = body as DeprecationsGetResponse; + const dataPluginDeprecations = deprecations.filter(({ domainId }) => domainId === 'data'); + + expect(dataPluginDeprecations.length).to.be(0); + }); + + it('scripted field deprecation', async () => { + const title = `basic_index`; + await supertest.post('/api/index_patterns/index_pattern').send({ + index_pattern: { + title, + fields: { + foo: { + name: 'foo', + type: 'string', + scripted: true, + script: "doc['field_name'].value", + }, + bar: { + name: 'bar', + type: 'number', + scripted: true, + script: "doc['field_name'].value", + }, + }, + }, + }); + + const { body } = await supertest.get('/api/deprecations/'); + const { deprecations } = body as DeprecationsGetResponse; + const dataPluginDeprecations = deprecations.filter(({ domainId }) => domainId === 'data'); + + expect(dataPluginDeprecations.length).to.be(1); + expect(dataPluginDeprecations[0].message).to.contain(title); + }); + }); +} diff --git a/test/api_integration/apis/index_patterns/index.js b/test/api_integration/apis/index_patterns/index.js index 656b4e506fa2..3dbe01206afa 100644 --- a/test/api_integration/apis/index_patterns/index.js +++ b/test/api_integration/apis/index_patterns/index.js @@ -17,5 +17,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./default_index_pattern')); loadTestFile(require.resolve('./runtime_fields_crud')); loadTestFile(require.resolve('./integration')); + loadTestFile(require.resolve('./deprecations')); }); } diff --git a/test/functional/apps/context/_discover_navigation.js b/test/functional/apps/context/_discover_navigation.js index a09be8b35ba8..6a2298ba48cb 100644 --- a/test/functional/apps/context/_discover_navigation.js +++ b/test/functional/apps/context/_discover_navigation.js @@ -32,7 +32,8 @@ export default function ({ getService, getPageObjects }) { const browser = getService('browser'); const kibanaServer = getService('kibanaServer'); - describe('context link in discover', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104413 + describe.skip('context link in discover', () => { before(async () => { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await kibanaServer.uiSettings.update({ diff --git a/test/functional/apps/dashboard/view_edit.ts b/test/functional/apps/dashboard/view_edit.ts index b29b07f9df4e..1ca70112c3d1 100644 --- a/test/functional/apps/dashboard/view_edit.ts +++ b/test/functional/apps/dashboard/view_edit.ts @@ -19,7 +19,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardName = 'dashboard with filter'; const filterBar = getService('filterBar'); - describe('dashboard view edit mode', function viewEditModeTests() { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104467 + describe.skip('dashboard view edit mode', function viewEditModeTests() { before(async () => { await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index bb75b4441f88..245b895d75b3 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -38,7 +38,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - describe('query', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104409 + describe.skip('query', function () { const queryName1 = 'Query # 1'; it('should show correct time range string by timepicker', async function () { diff --git a/test/functional/apps/discover/_doc_table_newline.ts b/test/functional/apps/discover/_doc_table_newline.ts new file mode 100644 index 000000000000..cdb149641348 --- /dev/null +++ b/test/functional/apps/discover/_doc_table_newline.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects(['common', 'discover']); + const find = getService('find'); + const log = getService('log'); + const retry = getService('retry'); + const security = getService('security'); + + describe('discover doc table newline handling', function describeIndexTests() { + before(async function () { + await security.testUser.setRoles(['kibana_admin', 'kibana_message_with_newline']); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/message_with_newline'); + await kibanaServer.uiSettings.replace({ + defaultIndex: 'newline-test', + 'doc_table:legacy': true, + }); + await PageObjects.common.navigateToApp('discover'); + }); + after(async () => { + await security.testUser.restoreDefaults(); + esArchiver.unload('test/functional/fixtures/es_archiver/message_with_newline'); + await kibanaServer.uiSettings.unset('defaultIndex'); + await kibanaServer.uiSettings.unset('doc_table:legacy'); + }); + + it('should break text on newlines', async function () { + await PageObjects.discover.clickFieldListItemToggle('message'); + const dscTableRows = await find.allByCssSelector('.kbnDocTable__row'); + + await retry.waitFor('height of multi-line content > single-line content', async () => { + const heightWithoutNewline = await dscTableRows[0].getAttribute('clientHeight'); + const heightWithNewline = await dscTableRows[1].getAttribute('clientHeight'); + log.debug(`Without newlines: ${heightWithoutNewline}, With newlines: ${heightWithNewline}`); + return Number(heightWithNewline) > Number(heightWithoutNewline); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_field_data.ts b/test/functional/apps/discover/_field_data.ts index 338d17ba31ff..5ab649568672 100644 --- a/test/functional/apps/discover/_field_data.ts +++ b/test/functional/apps/discover/_field_data.ts @@ -33,7 +33,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); }); - describe('field data', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104466 + describe.skip('field data', function () { it('search php should show the correct hit count', async function () { const expectedHitCount = '445'; await retry.try(async function () { diff --git a/test/functional/apps/discover/index.ts b/test/functional/apps/discover/index.ts index 869fb625e879..b396f172f696 100644 --- a/test/functional/apps/discover/index.ts +++ b/test/functional/apps/discover/index.ts @@ -27,6 +27,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_discover')); loadTestFile(require.resolve('./_discover_histogram')); loadTestFile(require.resolve('./_doc_table')); + loadTestFile(require.resolve('./_doc_table_newline')); loadTestFile(require.resolve('./_filter_editor')); loadTestFile(require.resolve('./_errors')); loadTestFile(require.resolve('./_field_data')); diff --git a/test/functional/config.js b/test/functional/config.js index 670488003e56..c2c856517c58 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -247,6 +247,20 @@ export default async function ({ readConfigFile }) { }, kibana: [], }, + kibana_message_with_newline: { + elasticsearch: { + cluster: [], + indices: [ + { + names: ['message_with_newline'], + privileges: ['read', 'view_index_metadata'], + field_security: { grant: ['*'], except: [] }, + }, + ], + run_as: [], + }, + kibana: [], + }, kibana_timefield: { elasticsearch: { diff --git a/test/functional/fixtures/es_archiver/message_with_newline/data.json b/test/functional/fixtures/es_archiver/message_with_newline/data.json new file mode 100644 index 000000000000..3611f2d3878a --- /dev/null +++ b/test/functional/fixtures/es_archiver/message_with_newline/data.json @@ -0,0 +1,36 @@ +{ + "type": "doc", + "value": { + "id": "index-pattern:newline-test", + "index": ".kibana", + "source": { + "index-pattern": { + "fields": "[]", + "title": "newline-test" + }, + "type": "index-pattern" + } + } +} + +{ + "type": "doc", + "value": { + "id": "1", + "index": "newline-test", + "source": { + "message" : "no new line" + } + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "newline-test", + "source": { + "message" : "two\nnew\nlines" + } + } +} diff --git a/test/functional/fixtures/es_archiver/message_with_newline/mappings.json b/test/functional/fixtures/es_archiver/message_with_newline/mappings.json new file mode 100644 index 000000000000..3e2db145e7e3 --- /dev/null +++ b/test/functional/fixtures/es_archiver/message_with_newline/mappings.json @@ -0,0 +1,12 @@ +{ + "type": "index", + "value": { + "index": "newline-test", + "settings": { + "index": { + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/test/plugin_functional/plugins/core_plugin_execution_context/kibana.json b/test/plugin_functional/plugins/core_plugin_execution_context/kibana.json new file mode 100644 index 000000000000..625745202e39 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_execution_context/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "corePluginExecutionContext", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_plugin_execution_context"], + "server": true, + "ui": false +} diff --git a/test/plugin_functional/plugins/core_plugin_execution_context/package.json b/test/plugin_functional/plugins/core_plugin_execution_context/package.json new file mode 100644 index 000000000000..4b932850cfa0 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_execution_context/package.json @@ -0,0 +1,13 @@ +{ + "name": "core_plugin_execution_context", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_plugin_execution_context", + "kibana": { + "version": "kibana" + }, + "license": "SSPL-1.0 OR Elastic License 2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../node_modules/.bin/tsc" + } +} diff --git a/test/plugin_functional/plugins/core_plugin_execution_context/server/index.ts b/test/plugin_functional/plugins/core_plugin_execution_context/server/index.ts new file mode 100644 index 000000000000..019e30209675 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_execution_context/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CorePluginExecutionContext } from './plugin'; + +export const plugin = () => new CorePluginExecutionContext(); diff --git a/test/plugin_functional/plugins/core_plugin_execution_context/server/plugin.ts b/test/plugin_functional/plugins/core_plugin_execution_context/server/plugin.ts new file mode 100644 index 000000000000..48889c6d4a45 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_execution_context/server/plugin.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Plugin, CoreSetup } from 'kibana/server'; + +export class CorePluginExecutionContext implements Plugin { + public setup(core: CoreSetup, deps: {}) { + const router = core.http.createRouter(); + router.get( + { + path: '/execution_context/pass', + validate: false, + options: { + authRequired: false, + }, + }, + async (context, request, response) => { + const { headers } = await context.core.elasticsearch.client.asCurrentUser.ping(); + return response.ok({ body: headers || {} }); + } + ); + } + + public start() {} + public stop() {} +} diff --git a/test/plugin_functional/plugins/core_plugin_execution_context/tsconfig.json b/test/plugin_functional/plugins/core_plugin_execution_context/tsconfig.json new file mode 100644 index 000000000000..21662b2b64a1 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_execution_context/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "server/**/*.ts", + ], + "exclude": [], + "references": [ + { "path": "../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/plugin_functional/test_suites/core_plugins/execution_context.ts b/test/plugin_functional/test_suites/core_plugins/execution_context.ts new file mode 100644 index 000000000000..21bcddd32bc1 --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/execution_context.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; +import '../../../../test/plugin_functional/plugins/core_provider_plugin/types'; + +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { + describe('execution context', function () { + describe('passed for a client-side operation', () => { + const PageObjects = getPageObjects(['common']); + const browser = getService('browser'); + + before(async () => { + await PageObjects.common.navigateToApp('home'); + }); + + it('passes plugin-specific execution context to Elasticsearch server', async () => { + expect( + await browser.execute(async () => { + const coreStart = window._coreProvider.start.core; + + const context = coreStart.executionContext.create({ + type: 'execution_context_app', + name: 'Execution context app', + id: '42', + // add a non-ASCII symbols to make sure it doesn't break the context propagation mechanism + description: 'какое-то странное описание', + }); + + const result = await coreStart.http.get('/execution_context/pass', { + context, + }); + + return result['x-opaque-id']; + }) + ).to.contain('kibana:execution_context_app:42'); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index 87a153a24570..79850dd63337 100644 --- a/test/plugin_functional/test_suites/core_plugins/index.ts +++ b/test/plugin_functional/test_suites/core_plugins/index.ts @@ -12,6 +12,7 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) { describe('core plugins', () => { loadTestFile(require.resolve('./applications')); loadTestFile(require.resolve('./elasticsearch_client')); + loadTestFile(require.resolve('./execution_context')); loadTestFile(require.resolve('./server_plugins')); loadTestFile(require.resolve('./ui_plugins')); loadTestFile(require.resolve('./ui_settings')); diff --git a/x-pack/package.json b/x-pack/package.json index 1af3d569e41a..805d8555bf45 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -24,8 +24,5 @@ }, "engines": { "yarn": "^1.21.1" - }, - "devDependencies": { - "@kbn/test": "link:../packages/kbn-test" } } \ No newline at end of file diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/schema.stories.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/schema.stories.tsx new file mode 100644 index 000000000000..7cac4ba97e72 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/schema.stories.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Story } from '@storybook/react'; +import React, { ComponentType } from 'react'; +import { CoreStart } from '../../../../../../../../src/core/public'; +import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; +import { createCallApmApi } from '../../../../services/rest/createCallApmApi'; +import { Schema } from './'; + +export default { + title: 'app/Settings/Schema', + component: Schema, + decorators: [ + (StoryComponent: ComponentType) => { + const coreMock = ({ + http: { + get: () => { + return {}; + }, + }, + } as unknown) as CoreStart; + + createCallApmApi(coreMock); + + return ( + + + + ); + }, + ], +}; + +export const Example: Story = () => { + return ; +}; diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx index 1005c09cb11b..7a874ed5b803 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx @@ -5,26 +5,25 @@ * 2.0. */ -import React from 'react'; import { + EuiButton, + EuiCallOut, + EuiCard, EuiFlexGroup, EuiFlexItem, - EuiSpacer, - EuiTitle, - EuiText, - EuiCard, EuiIcon, - EuiButton, - EuiCallOut, EuiLoadingSpinner, + EuiSpacer, + EuiText, EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ElasticDocsLink } from '../../../shared/Links/ElasticDocsLink'; -import rocketLaunchGraphic from './blog-rocket-720x420.png'; +import React from 'react'; import { APMLink } from '../../../shared/Links/apm/APMLink'; +import { ElasticDocsLink } from '../../../shared/Links/ElasticDocsLink'; import { useFleetCloudAgentPolicyHref } from '../../../shared/Links/kibana'; +import rocketLaunchGraphic from './blog-rocket-720x420.png'; interface Props { onSwitch: () => void; @@ -285,18 +284,6 @@ export function SchemaOverviewHeading() { /> - - - -

- {i18n.translate('xpack.apm.settings.schema.title', { - defaultMessage: 'Schema', - })} -

- - - - ); } diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx index be3895967d4d..5a56b6437453 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx @@ -157,9 +157,9 @@ export function DetailView({ errorGroup, urlParams }: Props) { { history.replace({ - ...location, + ...history.location, search: fromQuery({ - ...toQuery(location.search), + ...toQuery(history.location.search), detailTab: key, }), }); diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index 4f9e58239855..afdf4c12f41a 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -118,8 +118,8 @@ export function SearchBar({ diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts index 8e82a189d75f..a98bdab53cad 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { contextServiceMock } from 'src/core/server/mocks'; +import { + contextServiceMock, + executionContextServiceMock, +} from '../../../../../../../../src/core/server/mocks'; import { createHttpServer } from 'src/core/server/test_utils'; import supertest from 'supertest'; import { createApmEventClient } from '.'; @@ -23,6 +26,7 @@ describe('createApmEventClient', () => { it('cancels a search when a request is aborted', async () => { const { server: innerServer, createRouter } = await server.setup({ context: contextServiceMock.createSetupContract(), + executionContext: executionContextServiceMock.createInternalSetupContract(), }); const router = createRouter('/'); diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index e617ed0510a8..647330eade1f 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -127,6 +127,10 @@ export class APMPlugin const getCoreStart = () => core.getStartServices().then(([coreStart]) => coreStart); + const alertsIndexPattern = ruleDataService.getFullAssetName( + 'observability-apm*' + ); + const initializeRuleDataTemplates = once(async () => { const componentTemplateName = ruleDataService.getFullAssetName( 'apm-mappings' @@ -164,15 +168,16 @@ export class APMPlugin await ruleDataService.createOrUpdateIndexTemplate({ name: ruleDataService.getFullAssetName('apm-index-template'), body: { - index_patterns: [ - ruleDataService.getFullAssetName('observability-apm*'), - ], + index_patterns: [alertsIndexPattern], composed_of: [ ruleDataService.getFullAssetName(TECHNICAL_COMPONENT_TEMPLATE_NAME), componentTemplateName, ], }, }); + await ruleDataService.updateIndexMappingsMatchingPattern( + alertsIndexPattern + ); }); // initialize eagerly diff --git a/x-pack/plugins/canvas/i18n/errors.ts b/x-pack/plugins/canvas/i18n/errors.ts index a55762dce2d2..8b6697e78ca3 100644 --- a/x-pack/plugins/canvas/i18n/errors.ts +++ b/x-pack/plugins/canvas/i18n/errors.ts @@ -17,30 +17,6 @@ export const ErrorStrings = { }, }), }, - downloadWorkpad: { - getDownloadFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.downloadWorkpad.downloadFailureErrorMessage', { - defaultMessage: "Couldn't download workpad", - }), - getDownloadRenderedWorkpadFailureErrorMessage: () => - i18n.translate( - 'xpack.canvas.error.downloadWorkpad.downloadRenderedWorkpadFailureErrorMessage', - { - defaultMessage: "Couldn't download rendered workpad", - } - ), - getDownloadRuntimeFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.downloadWorkpad.downloadRuntimeFailureErrorMessage', { - defaultMessage: "Couldn't download Shareable Runtime", - }), - getDownloadZippedRuntimeFailureErrorMessage: () => - i18n.translate( - 'xpack.canvas.error.downloadWorkpad.downloadZippedRuntimeFailureErrorMessage', - { - defaultMessage: "Couldn't download ZIP file", - } - ), - }, esPersist: { getSaveFailureTitle: () => i18n.translate('xpack.canvas.error.esPersist.saveFailureTitle', { diff --git a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.js b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.js deleted file mode 100644 index a27f133c05f4..000000000000 --- a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { debounce } from 'lodash'; -import { getWindow } from '../../lib/get_window'; - -export class Fullscreen extends React.Component { - static propTypes = { - isFullscreen: PropTypes.bool, - children: PropTypes.func, - }; - - state = { - width: 0, - height: 0, - }; - - UNSAFE_componentWillMount() { - this.win = getWindow(); - this.setState({ - width: this.win.innerWidth, - height: this.win.innerHeight, - }); - } - - componentDidMount() { - this.win.addEventListener('resize', this.onWindowResize); - } - - componentWillUnmount() { - this.win.removeEventListener('resize', this.onWindowResize); - } - - getWindowSize = () => ({ - width: this.win.innerWidth, - height: this.win.innerHeight, - }); - - onWindowResize = debounce(() => { - const { width, height } = this.getWindowSize(); - this.setState({ width, height }); - }, 100); - - render() { - const { isFullscreen, children } = this.props; - const windowSize = { - width: this.state.width, - height: this.state.height, - }; - - return children({ isFullscreen, windowSize }); - } -} diff --git a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.ts b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.ts new file mode 100644 index 000000000000..a578afccb4cc --- /dev/null +++ b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FC, useEffect, useState } from 'react'; +import { debounce } from 'lodash'; +import { getWindow } from '../../lib/get_window'; + +interface Props { + isFullscreen?: boolean; + children: (props: { + isFullscreen: boolean; + windowSize: { width: number; height: number }; + }) => JSX.Element; +} + +export const Fullscreen: FC = ({ isFullscreen = false, children }) => { + const [width, setWidth] = useState(getWindow().innerWidth); + const [height, setHeight] = useState(getWindow().innerHeight); + + const onWindowResize = debounce(({ target }) => { + const { innerWidth, innerHeight } = target as Window; + setWidth(innerWidth); + setHeight(innerHeight); + }, 100); + + useEffect(() => { + const window = getWindow(); + window.addEventListener('resize', onWindowResize); + + return () => window.removeEventListener('resize', onWindowResize); + }); + + const windowSize = { + width, + height, + }; + + return children({ isFullscreen, windowSize }); +}; diff --git a/x-pack/plugins/canvas/public/components/fullscreen/index.tsx b/x-pack/plugins/canvas/public/components/fullscreen/index.tsx index dbf5c378ffa1..953f27ce0b02 100644 --- a/x-pack/plugins/canvas/public/components/fullscreen/index.tsx +++ b/x-pack/plugins/canvas/public/components/fullscreen/index.tsx @@ -6,12 +6,18 @@ */ import React, { FC, useContext } from 'react'; -// @ts-expect-error import { Fullscreen as Component } from './fullscreen'; import { WorkpadRoutingContext } from '../../routes/workpad'; -export const Fullscreen: FC = ({ children }) => { +interface Props { + children: (props: { + isFullscreen: boolean; + windowSize: { width: number; height: number }; + }) => JSX.Element; +} + +export const Fullscreen: FC = ({ children }) => { const { isFullscreen } = useContext(WorkpadRoutingContext); return ; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/index.ts b/x-pack/plugins/canvas/public/components/home/hooks/index.ts index c4267a985749..dde9a06e4851 100644 --- a/x-pack/plugins/canvas/public/components/home/hooks/index.ts +++ b/x-pack/plugins/canvas/public/components/home/hooks/index.ts @@ -8,7 +8,6 @@ export { useCloneWorkpad } from './use_clone_workpad'; export { useCreateWorkpad } from './use_create_workpad'; export { useDeleteWorkpads } from './use_delete_workpad'; -export { useDownloadWorkpad } from './use_download_workpad'; export { useFindTemplates } from './use_find_templates'; export { useFindWorkpads } from './use_find_workpad'; export { useImportWorkpad } from './use_upload_workpad'; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_upload_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_upload_workpad.ts index 7934a469bb7a..5c01ce631f5a 100644 --- a/x-pack/plugins/canvas/public/components/home/hooks/use_upload_workpad.ts +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_upload_workpad.ts @@ -12,10 +12,12 @@ import { i18n } from '@kbn/i18n'; import { CANVAS, JSON as JSONString } from '../../../../i18n/constants'; import { useNotifyService } from '../../../services'; import { getId } from '../../../lib/get_id'; +import { useCreateWorkpad } from './use_create_workpad'; import type { CanvasWorkpad } from '../../../../types'; export const useImportWorkpad = () => { const notifyService = useNotifyService(); + const createWorkpad = useCreateWorkpad(); return useCallback( (file?: File, onComplete: (workpad?: CanvasWorkpad) => void = () => {}) => { @@ -37,7 +39,7 @@ export const useImportWorkpad = () => { const reader = new FileReader(); // handle reading the uploaded file - reader.onload = () => { + reader.onload = async () => { try { const workpad = JSON.parse(reader.result as string); // Type-casting because we catch below. workpad.id = getId('workpad'); @@ -48,6 +50,7 @@ export const useImportWorkpad = () => { throw new Error(errors.getMissingPropertiesErrorMessage()); } + await createWorkpad(workpad); onComplete(workpad); } catch (e) { notifyService.error(e, { @@ -62,7 +65,7 @@ export const useImportWorkpad = () => { // read the uploaded file reader.readAsText(file); }, - [notifyService] + [notifyService, createWorkpad] ); }; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.tsx index 8ee0ae108392..962f61651539 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.tsx +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.tsx @@ -11,8 +11,7 @@ import Dropzone from 'react-dropzone'; import { useNotifyService } from '../../../services'; import { ErrorStrings } from '../../../../i18n'; -import { useImportWorkpad, useCreateWorkpad } from '../hooks'; -import { CanvasWorkpad } from '../../../../types'; +import { useImportWorkpad } from '../hooks'; import { UploadDropzone as Component } from './upload_dropzone.component'; @@ -21,18 +20,8 @@ const { WorkpadDropzone: errors } = ErrorStrings; export const UploadDropzone: FC = ({ children }) => { const notify = useNotifyService(); const uploadWorkpad = useImportWorkpad(); - const createWorkpad = useCreateWorkpad(); const [isDisabled, setIsDisabled] = useState(false); - const onComplete = async (workpad?: CanvasWorkpad) => { - if (!workpad) { - setIsDisabled(false); - return; - } - - await createWorkpad(workpad); - }; - const onDrop = (files: FileList) => { if (!files) { return; @@ -44,7 +33,7 @@ export const UploadDropzone: FC = ({ children }) => { } setIsDisabled(true); - uploadWorkpad(files[0], onComplete); + uploadWorkpad(files[0], () => setIsDisabled(false)); }; return ( diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx index e5d83039a87e..6d88691f2eab 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx @@ -11,7 +11,8 @@ import { useSelector } from 'react-redux'; import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; import type { State } from '../../../../types'; import { usePlatformService } from '../../../services'; -import { useCloneWorkpad, useDownloadWorkpad } from '../hooks'; +import { useCloneWorkpad } from '../hooks'; +import { useDownloadWorkpad } from '../../hooks'; import { WorkpadTable as Component } from './workpad_table.component'; import { WorkpadsContext } from './my_workpads'; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx index 62d84adfc264..02b4ee61ea0c 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx @@ -10,7 +10,8 @@ import { useSelector } from 'react-redux'; import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; import type { State } from '../../../../types'; -import { useDeleteWorkpads, useDownloadWorkpad } from '../hooks'; +import { useDeleteWorkpads } from '../hooks'; +import { useDownloadWorkpad } from '../../hooks'; import { WorkpadTableTools as Component, diff --git a/x-pack/plugins/canvas/public/components/hooks/index.tsx b/x-pack/plugins/canvas/public/components/hooks/index.tsx new file mode 100644 index 000000000000..e420ab4cd698 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hooks/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './workpad'; diff --git a/x-pack/plugins/canvas/public/components/hooks/workpad/index.tsx b/x-pack/plugins/canvas/public/components/hooks/workpad/index.tsx new file mode 100644 index 000000000000..50d527036560 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hooks/workpad/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useDownloadWorkpad, useDownloadRenderedWorkpad } from './use_download_workpad'; diff --git a/x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts b/x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts new file mode 100644 index 000000000000..b688bb5a3b1a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import fileSaver from 'file-saver'; +import { i18n } from '@kbn/i18n'; +import { useNotifyService, useWorkpadService } from '../../../services'; +import { CanvasWorkpad } from '../../../../types'; +import { CanvasRenderedWorkpad } from '../../../../shareable_runtime/types'; + +const strings = { + getDownloadFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.downloadWorkpad.downloadFailureErrorMessage', { + defaultMessage: "Couldn't download workpad", + }), + getDownloadRenderedWorkpadFailureErrorMessage: () => + i18n.translate( + 'xpack.canvas.error.downloadWorkpad.downloadRenderedWorkpadFailureErrorMessage', + { + defaultMessage: "Couldn't download rendered workpad", + } + ), +}; + +export const useDownloadWorkpad = () => { + const notifyService = useNotifyService(); + const workpadService = useWorkpadService(); + const download = useDownloadWorkpadBlob(); + + return useCallback( + async (workpadId: string) => { + try { + const workpad = await workpadService.get(workpadId); + + download(workpad, `canvas-workpad-${workpad.name}-${workpad.id}`); + } catch (err) { + notifyService.error(err, { title: strings.getDownloadFailureErrorMessage() }); + } + }, + [workpadService, notifyService, download] + ); +}; + +export const useDownloadRenderedWorkpad = () => { + const notifyService = useNotifyService(); + const download = useDownloadWorkpadBlob(); + + return useCallback( + async (workpad: CanvasRenderedWorkpad) => { + try { + download(workpad, `canvas-embed-workpad-${workpad.name}-${workpad.id}`); + } catch (err) { + notifyService.error(err, { + title: strings.getDownloadRenderedWorkpadFailureErrorMessage(), + }); + } + }, + [notifyService, download] + ); +}; + +const useDownloadWorkpadBlob = () => { + return useCallback((workpad: CanvasWorkpad | CanvasRenderedWorkpad, filename: string) => { + const jsonBlob = new Blob([JSON.stringify(workpad)], { type: 'application/json' }); + fileSaver.saveAs(jsonBlob, `${filename}.json`); + }, []); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx index be337a6dcf00..52e80c316c1e 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { FC, useCallback } from 'react'; import { EuiText, EuiSpacer, @@ -24,35 +24,21 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { arrayBufferFetch } from '../../../../../common/lib/fetch'; -import { API_ROUTE_SHAREABLE_ZIP } from '../../../../../common/lib/constants'; import { CanvasRenderedWorkpad } from '../../../../../shareable_runtime/types'; -import { - downloadRenderedWorkpad, - downloadRuntime, - downloadZippedRuntime, -} from '../../../../lib/download_workpad'; +import { useDownloadRenderedWorkpad } from '../../../hooks'; +import { useDownloadRuntime, useDownloadZippedRuntime } from './hooks'; import { ZIP, CANVAS, HTML } from '../../../../../i18n/constants'; import { OnCloseFn } from '../share_menu.component'; import { WorkpadStep } from './workpad_step'; import { RuntimeStep } from './runtime_step'; import { SnippetsStep } from './snippets_step'; -import { useNotifyService, usePlatformService } from '../../../../services'; +import { useNotifyService } from '../../../../services'; const strings = { getCopyShareConfigMessage: () => i18n.translate('xpack.canvas.workpadHeaderShareMenu.copyShareConfigMessage', { defaultMessage: 'Copied share markup to clipboard', }), - getShareableZipErrorTitle: (workpadName: string) => - i18n.translate('xpack.canvas.workpadHeaderShareMenu.shareWebsiteErrorTitle', { - defaultMessage: - "Failed to create {ZIP} file for '{workpadName}'. The workpad may be too large. You'll need to download the files separately.", - values: { - ZIP, - workpadName, - }, - }), getUnknownExportErrorMessage: (type: string) => i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', { defaultMessage: 'Unknown export type: {type}', @@ -121,33 +107,33 @@ export const ShareWebsiteFlyout: FC = ({ renderedWorkpad, }) => { const notifyService = useNotifyService(); - const platformService = usePlatformService(); - const onCopy = () => { - notifyService.info(strings.getCopyShareConfigMessage()); - }; - const onDownload = (type: 'share' | 'shareRuntime' | 'shareZip') => { - switch (type) { - case 'share': - downloadRenderedWorkpad(renderedWorkpad); - return; - case 'shareRuntime': - downloadRuntime(platformService.getBasePath()); - case 'shareZip': - const basePath = platformService.getBasePath(); - arrayBufferFetch - .post(`${basePath}${API_ROUTE_SHAREABLE_ZIP}`, JSON.stringify(renderedWorkpad)) - .then((blob) => downloadZippedRuntime(blob.data)) - .catch((err: Error) => { - notifyService.error(err, { - title: strings.getShareableZipErrorTitle(renderedWorkpad.name), - }); - }); - return; - default: - throw new Error(strings.getUnknownExportErrorMessage(type)); - } - }; + const onCopy = useCallback(() => notifyService.info(strings.getCopyShareConfigMessage()), [ + notifyService, + ]); + + const downloadRenderedWorkpad = useDownloadRenderedWorkpad(); + const downloadRuntime = useDownloadRuntime(); + const downloadZippedRuntime = useDownloadZippedRuntime(); + + const onDownload = useCallback( + (type: 'share' | 'shareRuntime' | 'shareZip') => { + switch (type) { + case 'share': + downloadRenderedWorkpad(renderedWorkpad); + return; + case 'shareRuntime': + downloadRuntime(); + return; + case 'shareZip': + downloadZippedRuntime(renderedWorkpad); + return; + default: + throw new Error(strings.getUnknownExportErrorMessage(type)); + } + }, + [downloadRenderedWorkpad, downloadRuntime, downloadZippedRuntime, renderedWorkpad] + ); const link = ( { @@ -35,12 +34,6 @@ const getUnsupportedRenderers = (state: State) => { return renderers; }; -const mapStateToProps = (state: State) => ({ - renderedWorkpad: getRenderedWorkpad(state), - unsupportedRenderers: getUnsupportedRenderers(state), - workpad: getWorkpad(state), -}); - interface Props { onClose: OnCloseFn; renderedWorkpad: CanvasRenderedWorkpad; @@ -48,14 +41,18 @@ interface Props { workpad: CanvasWorkpad; } -export const ShareWebsiteFlyout = compose>( - connect(mapStateToProps), - withKibana, - withProps( - ({ unsupportedRenderers, renderedWorkpad, onClose, workpad }: Props): ComponentProps => ({ - renderedWorkpad, - unsupportedRenderers, - onClose, - }) - ) -)(Component); +export const ShareWebsiteFlyout: FC> = ({ onClose }) => { + const { renderedWorkpad, unsupportedRenderers } = useSelector((state: State) => ({ + renderedWorkpad: getRenderedWorkpad(state), + unsupportedRenderers: getUnsupportedRenderers(state), + workpad: getWorkpad(state), + })); + + return ( + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/index.ts new file mode 100644 index 000000000000..a4243c9fff7e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './use_download_runtime'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.ts new file mode 100644 index 000000000000..dc2e4ff685ca --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import fileSaver from 'file-saver'; +import { i18n } from '@kbn/i18n'; +import { API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD } from '../../../../../../common/lib/constants'; +import { ZIP } from '../../../../../../i18n/constants'; + +import { usePlatformService, useNotifyService, useWorkpadService } from '../../../../../services'; +import { CanvasRenderedWorkpad } from '../../../../../../shareable_runtime/types'; + +const strings = { + getDownloadRuntimeFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.downloadWorkpad.downloadRuntimeFailureErrorMessage', { + defaultMessage: "Couldn't download Shareable Runtime", + }), + getDownloadZippedRuntimeFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.downloadWorkpad.downloadZippedRuntimeFailureErrorMessage', { + defaultMessage: "Couldn't download ZIP file", + }), + getShareableZipErrorTitle: (workpadName: string) => + i18n.translate('xpack.canvas.workpadHeaderShareMenu.shareWebsiteErrorTitle', { + defaultMessage: + "Failed to create {ZIP} file for '{workpadName}'. The workpad may be too large. You'll need to download the files separately.", + values: { + ZIP, + workpadName, + }, + }), +}; + +export const useDownloadRuntime = () => { + const platformService = usePlatformService(); + const notifyService = useNotifyService(); + + const downloadRuntime = useCallback(() => { + try { + const path = `${platformService.getBasePath()}${API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD}`; + window.open(path); + return; + } catch (err) { + notifyService.error(err, { title: strings.getDownloadRuntimeFailureErrorMessage() }); + } + }, [platformService, notifyService]); + + return downloadRuntime; +}; + +export const useDownloadZippedRuntime = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + + const downloadZippedRuntime = useCallback( + (workpad: CanvasRenderedWorkpad) => { + const downloadZip = async () => { + try { + let runtimeZipBlob: Blob | undefined; + try { + runtimeZipBlob = await workpadService.getRuntimeZip(workpad); + } catch (err) { + notifyService.error(err, { + title: strings.getShareableZipErrorTitle(workpad.name), + }); + } + + if (runtimeZipBlob) { + fileSaver.saveAs(runtimeZipBlob, 'canvas-workpad-embed.zip'); + } + } catch (err) { + notifyService.error(err, { + title: strings.getDownloadZippedRuntimeFailureErrorMessage(), + }); + } + }; + + downloadZip(); + }, + [notifyService, workpadService] + ); + return downloadZippedRuntime; +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts deleted file mode 100644 index f514f813599b..000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { connect } from 'react-redux'; -import { compose, withProps } from 'recompose'; -import { i18n } from '@kbn/i18n'; - -import { CanvasWorkpad, State } from '../../../../types'; -import { downloadWorkpad } from '../../../lib/download_workpad'; -import { withServices, WithServicesProps } from '../../../services'; -import { getPages, getWorkpad } from '../../../state/selectors/workpad'; -import { Props as ComponentProps, ShareMenu as Component } from './share_menu.component'; - -const strings = { - getUnknownExportErrorMessage: (type: string) => - i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', { - defaultMessage: 'Unknown export type: {type}', - values: { - type, - }, - }), -}; - -const mapStateToProps = (state: State) => ({ - workpad: getWorkpad(state), - pageCount: getPages(state).length, -}); - -interface Props { - workpad: CanvasWorkpad; - pageCount: number; -} - -export const ShareMenu = compose( - connect(mapStateToProps), - withServices, - withProps( - ({ workpad, pageCount, services }: Props & WithServicesProps): ComponentProps => { - const { - reporting: { start: reporting }, - } = services; - - return { - sharingServices: { reporting }, - sharingData: { workpad, pageCount }, - onExport: (type) => { - switch (type) { - case 'pdf': - // notifications are automatically handled by the Reporting plugin - break; - case 'json': - downloadWorkpad(workpad.id); - return; - default: - throw new Error(strings.getUnknownExportErrorMessage(type)); - } - }, - }; - } - ) -)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx new file mode 100644 index 000000000000..0083ff1659c5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { State } from '../../../../types'; +import { useReportingService } from '../../../services'; +import { getPages, getWorkpad } from '../../../state/selectors/workpad'; +import { useDownloadWorkpad } from '../../hooks'; +import { ShareMenu as ShareMenuComponent } from './share_menu.component'; + +const strings = { + getUnknownExportErrorMessage: (type: string) => + i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', { + defaultMessage: 'Unknown export type: {type}', + values: { + type, + }, + }), +}; + +export const ShareMenu: FC = () => { + const { workpad, pageCount } = useSelector((state: State) => ({ + workpad: getWorkpad(state), + pageCount: getPages(state).length, + })); + + const reportingService = useReportingService(); + const downloadWorkpad = useDownloadWorkpad(); + + const sharingServices = { + reporting: reportingService.start, + }; + + const sharingData = { + workpad, + pageCount, + }; + + const onExport = useCallback( + (type: string) => { + switch (type) { + case 'pdf': + // notifications are automatically handled by the Reporting plugin + break; + case 'json': + downloadWorkpad(workpad.id); + return; + default: + throw new Error(strings.getUnknownExportErrorMessage(type)); + } + }, + [downloadWorkpad, workpad] + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/canvas/public/lib/download_workpad.ts b/x-pack/plugins/canvas/public/lib/download_workpad.ts deleted file mode 100644 index a346de3322d0..000000000000 --- a/x-pack/plugins/canvas/public/lib/download_workpad.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import fileSaver from 'file-saver'; -import { API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD } from '../../common/lib/constants'; -import { ErrorStrings } from '../../i18n'; - -// TODO: clint - convert this whole file to hooks -import { pluginServices } from '../services'; - -// @ts-expect-error untyped local -import * as workpadService from './workpad_service'; -import { CanvasRenderedWorkpad } from '../../shareable_runtime/types'; - -const { downloadWorkpad: strings } = ErrorStrings; - -export const downloadWorkpad = async (workpadId: string) => { - try { - const workpad = await workpadService.get(workpadId); - const jsonBlob = new Blob([JSON.stringify(workpad)], { type: 'application/json' }); - fileSaver.saveAs(jsonBlob, `canvas-workpad-${workpad.name}-${workpad.id}.json`); - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadFailureErrorMessage() }); - } -}; - -export const downloadRenderedWorkpad = async (renderedWorkpad: CanvasRenderedWorkpad) => { - try { - const jsonBlob = new Blob([JSON.stringify(renderedWorkpad)], { type: 'application/json' }); - fileSaver.saveAs( - jsonBlob, - `canvas-embed-workpad-${renderedWorkpad.name}-${renderedWorkpad.id}.json` - ); - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadRenderedWorkpadFailureErrorMessage() }); - } -}; - -export const downloadRuntime = async (basePath: string) => { - try { - const path = `${basePath}${API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD}`; - window.open(path); - return; - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadRuntimeFailureErrorMessage() }); - } -}; - -export const downloadZippedRuntime = async (data: any) => { - try { - const zip = new Blob([data], { type: 'octet/stream' }); - fileSaver.saveAs(zip, 'canvas-workpad-embed.zip'); - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadZippedRuntimeFailureErrorMessage() }); - } -}; diff --git a/x-pack/plugins/canvas/public/lib/get_window.ts b/x-pack/plugins/canvas/public/lib/get_window.ts index 82e57a8948ca..4fc8bcda5e66 100644 --- a/x-pack/plugins/canvas/public/lib/get_window.ts +++ b/x-pack/plugins/canvas/public/lib/get_window.ts @@ -10,14 +10,12 @@ const windowObj = { location: null, localStorage: {} as Window['localStorage'], sessionStorage: {} as Window['sessionStorage'], + innerWidth: 0, + innerHeight: 0, + addEventListener: () => {}, + removeEventListener: () => {}, }; -export const getWindow = (): - | Window - | { - location: Location | null; - localStorage: Window['localStorage']; - sessionStorage: Window['sessionStorage']; - } => { +export const getWindow = (): Window | typeof windowObj => { return typeof window === 'undefined' ? windowObj : window; }; diff --git a/x-pack/plugins/canvas/public/lib/workpad_service.js b/x-pack/plugins/canvas/public/lib/workpad_service.js deleted file mode 100644 index 20ad82860f1f..000000000000 --- a/x-pack/plugins/canvas/public/lib/workpad_service.js +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// TODO: clint - move to workpad service. -import { - API_ROUTE_WORKPAD, - API_ROUTE_WORKPAD_ASSETS, - API_ROUTE_WORKPAD_STRUCTURES, - DEFAULT_WORKPAD_CSS, -} from '../../common/lib/constants'; -import { fetch } from '../../common/lib/fetch'; -import { pluginServices } from '../services'; - -/* - Remove any top level keys from the workpad which will be rejected by validation -*/ -const validKeys = [ - '@created', - '@timestamp', - 'assets', - 'colors', - 'css', - 'variables', - 'height', - 'id', - 'isWriteable', - 'name', - 'page', - 'pages', - 'width', -]; - -const sanitizeWorkpad = function (workpad) { - const workpadKeys = Object.keys(workpad); - - for (const key of workpadKeys) { - if (!validKeys.includes(key)) { - delete workpad[key]; - } - } - - return workpad; -}; - -const getApiPath = function () { - const platformService = pluginServices.getServices().platform; - const basePath = platformService.getBasePath(); - return `${basePath}${API_ROUTE_WORKPAD}`; -}; - -const getApiPathStructures = function () { - const platformService = pluginServices.getServices().platform; - const basePath = platformService.getBasePath(); - return `${basePath}${API_ROUTE_WORKPAD_STRUCTURES}`; -}; - -const getApiPathAssets = function () { - const platformService = pluginServices.getServices().platform; - const basePath = platformService.getBasePath(); - return `${basePath}${API_ROUTE_WORKPAD_ASSETS}`; -}; - -export function create(workpad) { - return fetch.post(getApiPath(), { - ...sanitizeWorkpad({ ...workpad }), - assets: workpad.assets || {}, - variables: workpad.variables || [], - }); -} - -export async function createFromTemplate(templateId) { - return fetch.post(getApiPath(), { - templateId, - }); -} - -export function get(workpadId) { - return fetch.get(`${getApiPath()}/${workpadId}`).then(({ data: workpad }) => { - // shim old workpads with new properties - return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad }; - }); -} - -// TODO: I think this function is never used. Look into and remove the corresponding route as well -export function update(id, workpad) { - return fetch.put(`${getApiPath()}/${id}`, sanitizeWorkpad({ ...workpad })); -} - -export function updateWorkpad(id, workpad) { - return fetch.put(`${getApiPathStructures()}/${id}`, sanitizeWorkpad({ ...workpad })); -} - -export function updateAssets(id, workpadAssets) { - return fetch.put(`${getApiPathAssets()}/${id}`, workpadAssets); -} - -export function remove(id) { - return fetch.delete(`${getApiPath()}/${id}`); -} - -export function find(searchTerm) { - const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0; - - return fetch - .get(`${getApiPath()}/find?name=${validSearchTerm ? searchTerm : ''}&perPage=10000`) - .then(({ data: workpads }) => workpads); -} diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx new file mode 100644 index 000000000000..3ef93905f7e3 --- /dev/null +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { renderHook } from '@testing-library/react-hooks'; +import { useWorkpadPersist } from './use_workpad_persist'; + +const mockGetState = jest.fn(); +const mockUpdateWorkpad = jest.fn(); +const mockUpdateAssets = jest.fn(); +const mockUpdate = jest.fn(); +const mockNotifyError = jest.fn(); + +// Mock the hooks and actions used by the UseWorkpad hook +jest.mock('react-redux', () => ({ + useSelector: (selector: any) => selector(mockGetState()), +})); + +jest.mock('../../../services', () => ({ + useWorkpadService: () => ({ + updateWorkpad: mockUpdateWorkpad, + updateAssets: mockUpdateAssets, + update: mockUpdate, + }), + useNotifyService: () => ({ + error: mockNotifyError, + }), +})); + +describe('useWorkpadPersist', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('initial render does not persist state', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + renderHook(useWorkpadPersist); + + expect(mockUpdateWorkpad).not.toBeCalled(); + expect(mockUpdateAssets).not.toBeCalled(); + expect(mockUpdate).not.toBeCalled(); + }); + + test('changes to workpad cause a workpad update', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + ...state, + persistent: { + workpad: { new: 'workpad' }, + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdateWorkpad).toHaveBeenCalled(); + }); + + test('changes to assets cause an asset update', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + ...state, + assets: { + asset1: 'some asset', + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdateAssets).toHaveBeenCalled(); + }); + + test('changes to both assets and workpad causes a full update', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + persistent: { + workpad: { new: 'workpad' }, + }, + assets: { + asset1: 'some asset', + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdate).toHaveBeenCalled(); + }); + + test('non changes causes no updated', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + rerender(); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockUpdateWorkpad).not.toHaveBeenCalled(); + expect(mockUpdateAssets).not.toHaveBeenCalled(); + }); + + test('non write permissions causes no updates', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + transient: { + canUserWrite: false, + }, + }; + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + persistent: { + workpad: { new: 'workpad value' }, + }, + assets: { + asset3: 'something', + }, + transient: { + canUserWrite: false, + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockUpdateWorkpad).not.toHaveBeenCalled(); + expect(mockUpdateAssets).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts new file mode 100644 index 000000000000..62c83e041184 --- /dev/null +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useEffect, useCallback } from 'react'; +import { isEqual } from 'lodash'; +import usePrevious from 'react-use/lib/usePrevious'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { CanvasWorkpad, State } from '../../../../types'; +import { getWorkpad, getFullWorkpadPersisted } from '../../../state/selectors/workpad'; +import { canUserWrite } from '../../../state/selectors/app'; +import { getAssetIds } from '../../../state/selectors/assets'; +import { useWorkpadService, useNotifyService } from '../../../services'; + +const strings = { + getSaveFailureTitle: () => + i18n.translate('xpack.canvas.error.esPersist.saveFailureTitle', { + defaultMessage: "Couldn't save your changes to Elasticsearch", + }), + getTooLargeErrorMessage: () => + i18n.translate('xpack.canvas.error.esPersist.tooLargeErrorMessage', { + defaultMessage: + 'The server gave a response that the workpad data was too large. This usually means uploaded image assets that are too large for Kibana or a proxy. Try removing some assets in the asset manager.', + }), + getUpdateFailureTitle: () => + i18n.translate('xpack.canvas.error.esPersist.updateFailureTitle', { + defaultMessage: "Couldn't update workpad", + }), +}; + +export const useWorkpadPersist = () => { + const service = useWorkpadService(); + const notifyService = useNotifyService(); + const notifyError = useCallback( + (err: any) => { + const statusCode = err.response && err.response.status; + switch (statusCode) { + case 400: + return notifyService.error(err.response, { + title: strings.getSaveFailureTitle(), + }); + case 413: + return notifyService.error(strings.getTooLargeErrorMessage(), { + title: strings.getSaveFailureTitle(), + }); + default: + return notifyService.error(err, { + title: strings.getUpdateFailureTitle(), + }); + } + }, + [notifyService] + ); + + // Watch for workpad state or workpad assets to change and then persist those changes + const [workpad, assetIds, fullWorkpad, canWrite]: [ + CanvasWorkpad, + Array, + CanvasWorkpad, + boolean + ] = useSelector((state: State) => [ + getWorkpad(state), + getAssetIds(state), + getFullWorkpadPersisted(state), + canUserWrite(state), + ]); + + const previousWorkpad = usePrevious(workpad); + const previousAssetIds = usePrevious(assetIds); + + const workpadChanged = previousWorkpad && workpad !== previousWorkpad; + const assetsChanged = previousAssetIds && !isEqual(assetIds, previousAssetIds); + + useEffect(() => { + if (canWrite) { + if (workpadChanged && assetsChanged) { + service.update(workpad.id, fullWorkpad).catch(notifyError); + } + if (workpadChanged) { + service.updateWorkpad(workpad.id, workpad).catch(notifyError); + } else if (assetsChanged) { + service.updateAssets(workpad.id, fullWorkpad.assets).catch(notifyError); + } + } + }, [service, workpad, fullWorkpad, workpadChanged, assetsChanged, canWrite, notifyError]); +}; diff --git a/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx b/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx index 95caba08517e..2c1ad4fcb6aa 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx +++ b/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx @@ -20,6 +20,7 @@ import { useWorkpad } from './hooks/use_workpad'; import { useRestoreHistory } from './hooks/use_restore_history'; import { useWorkpadHistory } from './hooks/use_workpad_history'; import { usePageSync } from './hooks/use_page_sync'; +import { useWorkpadPersist } from './hooks/use_workpad_persist'; import { WorkpadPageRouteProps, WorkpadRouteProps, WorkpadPageRouteParams } from '.'; import { WorkpadRoutingContextComponent } from './workpad_routing_context'; import { WorkpadPresentationHelper } from './workpad_presentation_helper'; @@ -88,6 +89,7 @@ export const WorkpadHistoryManager: FC = ({ children }) => { useRestoreHistory(); useWorkpadHistory(); usePageSync(); + useWorkpadPersist(); return <>{children}; }; diff --git a/x-pack/plugins/canvas/public/services/kibana/workpad.ts b/x-pack/plugins/canvas/public/services/kibana/workpad.ts index 36ad1c568f9e..8609d5055cb8 100644 --- a/x-pack/plugins/canvas/public/services/kibana/workpad.ts +++ b/x-pack/plugins/canvas/public/services/kibana/workpad.ts @@ -14,6 +14,9 @@ import { API_ROUTE_WORKPAD, DEFAULT_WORKPAD_CSS, API_ROUTE_TEMPLATES, + API_ROUTE_WORKPAD_ASSETS, + API_ROUTE_WORKPAD_STRUCTURES, + API_ROUTE_SHAREABLE_ZIP, } from '../../../common/lib/constants'; import { CanvasWorkpad } from '../../../types'; @@ -93,5 +96,25 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ coreStart, remove: (id: string) => { return coreStart.http.delete(`${getApiPath()}/${id}`); }, + update: (id, workpad) => { + return coreStart.http.put(`${getApiPath()}/${id}`, { + body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }) }), + }); + }, + updateWorkpad: (id, workpad) => { + return coreStart.http.put(`${API_ROUTE_WORKPAD_STRUCTURES}/${id}`, { + body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }) }), + }); + }, + updateAssets: (id, assets) => { + return coreStart.http.put(`${API_ROUTE_WORKPAD_ASSETS}/${id}`, { + body: JSON.stringify(assets), + }); + }, + getRuntimeZip: (workpad) => { + return coreStart.http.post(API_ROUTE_SHAREABLE_ZIP, { + body: JSON.stringify(workpad), + }); + }, }; }; diff --git a/x-pack/plugins/canvas/public/services/legacy/context.tsx b/x-pack/plugins/canvas/public/services/legacy/context.tsx index 2f472afd7d3c..fb30a9d418df 100644 --- a/x-pack/plugins/canvas/public/services/legacy/context.tsx +++ b/x-pack/plugins/canvas/public/services/legacy/context.tsx @@ -26,13 +26,14 @@ const defaultContextValue = { search: {}, }; -const context = createContext(defaultContextValue as CanvasServices); +export const ServicesContext = createContext(defaultContextValue as CanvasServices); -export const useServices = () => useContext(context); +export const useServices = () => useContext(ServicesContext); export const useEmbeddablesService = () => useServices().embeddables; export const useExpressionsService = () => useServices().expressions; export const useNavLinkService = () => useServices().navLink; export const useLabsService = () => useServices().labs; +export const useReportingService = () => useServices().reporting; export const withServices = (type: ComponentType) => { const EnhancedType: FC = (props) => @@ -53,5 +54,5 @@ export const LegacyServicesProvider: FC<{ reporting: specifiedProviders.reporting.getService(), labs: specifiedProviders.labs.getService(), }; - return {children}; + return {children}; }; diff --git a/x-pack/plugins/canvas/public/services/storybook/workpad.ts b/x-pack/plugins/canvas/public/services/storybook/workpad.ts index a494f634141b..cdf4137e1d84 100644 --- a/x-pack/plugins/canvas/public/services/storybook/workpad.ts +++ b/x-pack/plugins/canvas/public/services/storybook/workpad.ts @@ -97,4 +97,18 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ action('workpadService.remove')(id); return Promise.resolve(); }, + update: (id, workpad) => { + action('worpadService.update')(workpad, id); + return Promise.resolve(); + }, + updateWorkpad: (id, workpad) => { + action('workpadService.updateWorkpad')(workpad, id); + return Promise.resolve(); + }, + updateAssets: (id, assets) => { + action('workpadService.updateAssets')(assets, id); + return Promise.resolve(); + }, + getRuntimeZip: (workpad) => + Promise.resolve(new Blob([JSON.stringify(workpad)], { type: 'application/json' })), }); diff --git a/x-pack/plugins/canvas/public/services/stubs/workpad.ts b/x-pack/plugins/canvas/public/services/stubs/workpad.ts index eef7508e7c1e..2f2598563d49 100644 --- a/x-pack/plugins/canvas/public/services/stubs/workpad.ts +++ b/x-pack/plugins/canvas/public/services/stubs/workpad.ts @@ -96,4 +96,9 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = () => ({ createFromTemplate: (_templateId: string) => Promise.resolve(getDefaultWorkpad()), find: findNoWorkpads(), remove: (_id: string) => Promise.resolve(), + update: (id, workpad) => Promise.resolve(), + updateWorkpad: (id, workpad) => Promise.resolve(), + updateAssets: (id, assets) => Promise.resolve(), + getRuntimeZip: (workpad) => + Promise.resolve(new Blob([JSON.stringify(workpad)], { type: 'application/json' })), }); diff --git a/x-pack/plugins/canvas/public/services/workpad.ts b/x-pack/plugins/canvas/public/services/workpad.ts index 6b90cc346834..c0e948669647 100644 --- a/x-pack/plugins/canvas/public/services/workpad.ts +++ b/x-pack/plugins/canvas/public/services/workpad.ts @@ -6,6 +6,7 @@ */ import { CanvasWorkpad, CanvasTemplate } from '../../types'; +import { CanvasRenderedWorkpad } from '../../shareable_runtime/types'; export type FoundWorkpads = Array>; export type FoundWorkpad = FoundWorkpads[number]; @@ -24,4 +25,8 @@ export interface CanvasWorkpadService { find: (term: string) => Promise; remove: (id: string) => Promise; findTemplates: () => Promise; + update: (id: string, workpad: CanvasWorkpad) => Promise; + updateWorkpad: (id: string, workpad: CanvasWorkpad) => Promise; + updateAssets: (id: string, assets: CanvasWorkpad['assets']) => Promise; + getRuntimeZip: (workpad: CanvasRenderedWorkpad) => Promise; } diff --git a/x-pack/plugins/canvas/public/state/middleware/es_persist.js b/x-pack/plugins/canvas/public/state/middleware/es_persist.js deleted file mode 100644 index 17d0c9649b91..000000000000 --- a/x-pack/plugins/canvas/public/state/middleware/es_persist.js +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isEqual } from 'lodash'; -import { ErrorStrings } from '../../../i18n'; -import { getWorkpad, getFullWorkpadPersisted, getWorkpadPersisted } from '../selectors/workpad'; -import { getAssetIds } from '../selectors/assets'; -import { appReady } from '../actions/app'; -import { setWorkpad, setRefreshInterval, resetWorkpad } from '../actions/workpad'; -import { setAssets, resetAssets } from '../actions/assets'; -import * as transientActions from '../actions/transient'; -import * as resolvedArgsActions from '../actions/resolved_args'; -import { update, updateAssets, updateWorkpad } from '../../lib/workpad_service'; -import { pluginServices } from '../../services'; -import { canUserWrite } from '../selectors/app'; - -const { esPersist: strings } = ErrorStrings; - -const workpadChanged = (before, after) => { - const workpad = getWorkpad(before); - return getWorkpad(after) !== workpad; -}; - -const assetsChanged = (before, after) => { - const assets = getAssetIds(before); - return !isEqual(assets, getAssetIds(after)); -}; - -export const esPersistMiddleware = ({ getState }) => { - // these are the actions we don't want to trigger a persist call - const skippedActions = [ - appReady, // there's no need to resave the workpad once we've loaded it. - resetWorkpad, // used for resetting the workpad in state - setWorkpad, // used for loading and creating workpads - setAssets, // used when loading assets - resetAssets, // used when creating new workpads - setRefreshInterval, // used to set refresh time interval which is a transient value - ...Object.values(resolvedArgsActions), // no resolved args affect persisted values - ...Object.values(transientActions), // no transient actions cause persisted state changes - ].map((a) => a.toString()); - - return (next) => (action) => { - // if the action is in the skipped list, do not persist - if (skippedActions.indexOf(action.type) >= 0) { - return next(action); - } - - // capture state before and after the action - const curState = getState(); - next(action); - const newState = getState(); - - // skips the update request if user doesn't have write permissions - if (!canUserWrite(newState)) { - return; - } - - const notifyError = (err) => { - const statusCode = err.response && err.response.status; - const notifyService = pluginServices.getServices().notify; - - switch (statusCode) { - case 400: - return notifyService.error(err.response, { - title: strings.getSaveFailureTitle(), - }); - case 413: - return notifyService.error(strings.getTooLargeErrorMessage(), { - title: strings.getSaveFailureTitle(), - }); - default: - return notifyService.error(err, { - title: strings.getUpdateFailureTitle(), - }); - } - }; - - const changedWorkpad = workpadChanged(curState, newState); - const changedAssets = assetsChanged(curState, newState); - - if (changedWorkpad && changedAssets) { - // if both the workpad and the assets changed, save it in its entirety to elasticsearch - const persistedWorkpad = getFullWorkpadPersisted(getState()); - return update(persistedWorkpad.id, persistedWorkpad).catch(notifyError); - } else if (changedWorkpad) { - // if the workpad changed, save it to elasticsearch - const persistedWorkpad = getWorkpadPersisted(getState()); - return updateWorkpad(persistedWorkpad.id, persistedWorkpad).catch(notifyError); - } else if (changedAssets) { - // if the assets changed, save it to elasticsearch - const persistedWorkpad = getFullWorkpadPersisted(getState()); - return updateAssets(persistedWorkpad.id, persistedWorkpad.assets).catch(notifyError); - } - }; -}; diff --git a/x-pack/plugins/canvas/public/state/middleware/index.js b/x-pack/plugins/canvas/public/state/middleware/index.js index 713232543fab..fbed2fbb3741 100644 --- a/x-pack/plugins/canvas/public/state/middleware/index.js +++ b/x-pack/plugins/canvas/public/state/middleware/index.js @@ -8,21 +8,13 @@ import { applyMiddleware, compose as reduxCompose } from 'redux'; import thunkMiddleware from 'redux-thunk'; import { getWindow } from '../../lib/get_window'; -import { esPersistMiddleware } from './es_persist'; import { inFlight } from './in_flight'; import { workpadUpdate } from './workpad_update'; import { elementStats } from './element_stats'; import { resolvedArgs } from './resolved_args'; const middlewares = [ - applyMiddleware( - thunkMiddleware, - elementStats, - resolvedArgs, - esPersistMiddleware, - inFlight, - workpadUpdate - ), + applyMiddleware(thunkMiddleware, elementStats, resolvedArgs, inFlight, workpadUpdate), ]; // compose with redux devtools, if extension is installed diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts index e1cebeb65bd2..9cfccf3fc559 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts @@ -7,6 +7,7 @@ import { get, omit } from 'lodash'; import { safeElementFromExpression, fromExpression } from '@kbn/interpreter/common'; +import { CanvasRenderedWorkpad } from '../../../shareable_runtime/types'; import { append } from '../../lib/modify_path'; import { getAssets } from './assets'; import { @@ -500,7 +501,7 @@ export function getRenderedWorkpad(state: State) { return { pages: renderedPages, ...rest, - }; + } as CanvasRenderedWorkpad; } export function getRenderedWorkpadExpressions(state: State) { diff --git a/x-pack/plugins/canvas/shareable_runtime/types.ts b/x-pack/plugins/canvas/shareable_runtime/types.ts index ac8f140b7f11..751fb3f79552 100644 --- a/x-pack/plugins/canvas/shareable_runtime/types.ts +++ b/x-pack/plugins/canvas/shareable_runtime/types.ts @@ -24,15 +24,14 @@ export interface CanvasRenderedElement { * Represents a Page within a Canvas Workpad that is made up of ready-to- * render Elements. */ -export interface CanvasRenderedPage extends Omit, 'groups'> { +export interface CanvasRenderedPage extends Omit { elements: CanvasRenderedElement[]; - groups: CanvasRenderedElement[][]; } /** * A Canvas Workpad made up of ready-to-render Elements. */ -export interface CanvasRenderedWorkpad extends Omit { +export interface CanvasRenderedWorkpad extends Omit { pages: CanvasRenderedPage[]; } diff --git a/x-pack/plugins/canvas/storybook/decorators/index.ts b/x-pack/plugins/canvas/storybook/decorators/index.ts index a4ea3226b776..68481e27ae48 100644 --- a/x-pack/plugins/canvas/storybook/decorators/index.ts +++ b/x-pack/plugins/canvas/storybook/decorators/index.ts @@ -21,6 +21,6 @@ export const addDecorators = () => { addDecorator(kibanaContextDecorator); addDecorator(routerContextDecorator); - addDecorator(servicesContextDecorator); addDecorator(legacyContextDecorator()); + addDecorator(servicesContextDecorator()); }; diff --git a/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx b/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx index fbc3f140bffc..23dcc7b21a8b 100644 --- a/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx +++ b/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx @@ -11,30 +11,35 @@ import { DecoratorFn } from '@storybook/react'; import { I18nProvider } from '@kbn/i18n/react'; import { PluginServiceRegistry } from '../../../../../src/plugins/presentation_util/public'; -import { pluginServices, LegacyServicesProvider } from '../../public/services'; -import { CanvasPluginServices } from '../../public/services'; +import { pluginServices, CanvasPluginServices } from '../../public/services'; import { pluginServiceProviders, StorybookParams } from '../../public/services/storybook'; +import { LegacyServicesProvider } from '../../public/services/legacy'; +import { startServices } from '../../public/services/legacy/stubs'; -export const servicesContextDecorator: DecoratorFn = (story: Function, storybook) => { - if (process.env.JEST_WORKER_ID !== undefined) { - storybook.args.useStaticData = true; - } - +export const servicesContextDecorator = (): DecoratorFn => { const pluginServiceRegistry = new PluginServiceRegistry( pluginServiceProviders ); - pluginServices.setRegistry(pluginServiceRegistry.start(storybook.args)); + pluginServices.setRegistry(pluginServiceRegistry.start({})); - const ContextProvider = pluginServices.getContextProvider(); + return (story: Function, storybook) => { + if (process.env.JEST_WORKER_ID !== undefined) { + storybook.args.useStaticData = true; + } - return ( - - {story()} - - ); + pluginServices.setRegistry(pluginServiceRegistry.start(storybook.args)); + const ContextProvider = pluginServices.getContextProvider(); + + return ( + + {story()} + + ); + }; }; -export const legacyContextDecorator = () => (story: Function) => ( - {story()} -); +export const legacyContextDecorator = () => { + startServices(); + return (story: Function) => {story()}; +}; diff --git a/x-pack/plugins/canvas/storybook/preview.ts b/x-pack/plugins/canvas/storybook/preview.ts index 8eae76abaf41..040e1c3de149 100644 --- a/x-pack/plugins/canvas/storybook/preview.ts +++ b/x-pack/plugins/canvas/storybook/preview.ts @@ -7,14 +7,11 @@ import { addParameters } from '@storybook/react'; -import { startServices } from '../public/services/stubs'; import { addDecorators } from './decorators'; // Import Canvas CSS import '../public/style/index.scss'; -startServices(); - addDecorators(); addParameters({ controls: { hideNoControlsWarning: true }, diff --git a/x-pack/plugins/canvas/types/state.ts b/x-pack/plugins/canvas/types/state.ts index 6e27093379e3..cc42839ddfac 100644 --- a/x-pack/plugins/canvas/types/state.ts +++ b/x-pack/plugins/canvas/types/state.ts @@ -94,7 +94,7 @@ interface PersistentState { export interface State { app: StoreAppState; - assets: { [assetKey: string]: AssetType | undefined }; + assets: { [assetKey: string]: AssetType }; transient: TransientState; persistent: PersistentState; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index ed35bfbe9784..d00ef999617e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -39,6 +39,7 @@ describe('EngineRouter', () => { ...mockEngineValues, dataLoading: false, engineNotFound: false, + isMetaEngine: false, myRole: {}, }; const actions = { @@ -175,14 +176,18 @@ describe('EngineRouter', () => { }); it('renders a source engines view', () => { - setMockValues({ ...values, myRole: { canViewMetaEngineSourceEngines: true } }); + setMockValues({ + ...values, + myRole: { canViewMetaEngineSourceEngines: true }, + isMetaEngine: true, + }); const wrapper = shallow(); expect(wrapper.find(SourceEngines)).toHaveLength(1); }); it('renders a crawler view', () => { - setMockValues({ ...values, myRole: { canViewEngineCrawler: true } }); + setMockValues({ ...values, myRole: { canViewEngineCrawler: true }, isMetaEngine: false }); const wrapper = shallow(); expect(wrapper.find(CrawlerRouter)).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 2d1bd32a0fff..8b0e6428babf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -66,7 +66,7 @@ export const EngineRouter: React.FC = () => { } = useValues(AppLogic); const { engineName: engineNameFromUrl } = useParams() as { engineName: string }; - const { engineName, dataLoading, engineNotFound } = useValues(EngineLogic); + const { engineName, dataLoading, engineNotFound, isMetaEngine } = useValues(EngineLogic); const { setEngineName, initializeEngine, pollEmptyEngine, stopPolling, clearEngine } = useActions( EngineLogic ); @@ -120,12 +120,12 @@ export const EngineRouter: React.FC = () => { )} - {canViewMetaEngineSourceEngines && ( + {canViewMetaEngineSourceEngines && isMetaEngine && ( )} - {canViewEngineCrawler && ( + {canViewEngineCrawler && !isMetaEngine && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/beta.scss b/x-pack/plugins/enterprise_search/public/applications/shared/layout/beta.scss index 6ba90cba381c..677767c190f0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/beta.scss +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/beta.scss @@ -7,18 +7,14 @@ @include euiBreakpoint('m', 'l', 'xl') { .kbnPageTemplateSolutionNav { - position: relative; - min-height: 100%; - - // Nested to override EUI specificity - .betaNotificationSideNavItem { - margin-top: $euiSizeL; - } + display: flex; + flex-direction: column; } - - .betaNotificationWrapper { - position: absolute; - bottom: 3px; // Without this 3px buffer, the popover won't render to the right + .euiSideNav__content { + flex-grow: 1; + display: flex; + flex-direction: column; + justify-content: space-between; } } diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/beta.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/beta.test.tsx index 99b42b6f915e..4e4c7f4edbba 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/beta.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/beta.test.tsx @@ -107,7 +107,6 @@ describe('appendBetaNotificationItem', () => { { id: 'beta', name: '', - className: 'betaNotificationSideNavItem', renderItem: expect.any(Function), }, ], @@ -118,7 +117,6 @@ describe('appendBetaNotificationItem', () => { const SideNavItem = (mockSideNav.items[2] as any).renderItem; const wrapper = shallow(); - expect(wrapper.hasClass('betaNotificationWrapper')).toBe(true); expect(wrapper.find(BetaNotification)).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/beta.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/beta.tsx index 46aa0a0af9e8..1f4c8328cc87 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/beta.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/beta.tsx @@ -98,12 +98,7 @@ export const appendBetaNotificationItem = (sideNav: KibanaPageTemplateProps['sol sideNav.items.push({ id: 'beta', name: '', - className: 'betaNotificationSideNavItem', - renderItem: () => ( -
- -
- ), + renderItem: () => , }); } }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index cf459171a808..0e56ee8f6724 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -568,23 +568,23 @@ export const REDIRECT_INSECURE_ERROR_TEXT = i18n.translate( } ); -export const LICENSE_MODAL_TITLE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.licenseModal.title', +export const NON_PLATINUM_OAUTH_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.nonPlatinumOauthTitle', { defaultMessage: 'Configuring OAuth for Custom Search Applications', } ); -export const LICENSE_MODAL_DESCRIPTION = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.licenseModal.description', +export const NON_PLATINUM_OAUTH_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.nonPlatinumOauthDescription', { defaultMessage: 'Configure an OAuth application for secure use of the Workplace Search Search API. Upgrade to a Platinum license to enable the Search API and create your OAuth application.', } ); -export const LICENSE_MODAL_LINK = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.licenseModal.link', +export const NON_PLATINUM_OAUTH_LINK = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.nonPlatinumOauthLinkLabel', { defaultMessage: 'Explore Platinum features', } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts index 2d15d6ce407b..e9ebc791622d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts @@ -7,3 +7,4 @@ export { toSentenceSerial } from './to_sentence_serial'; export { getAsLocalDateTimeString } from './get_as_local_datetime_string'; +export { mimeType } from './mime_types'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/mime_types.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/mime_types.test.ts new file mode 100644 index 000000000000..48b874484a0c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/mime_types.test.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mimeType } from './'; + +describe('mimeType', () => { + it('should return correct mimeType when present', () => { + expect(mimeType('Image/gif')).toEqual('GIF'); + }); + + it('should fall back gracefully when mimeType not present', () => { + expect(mimeType('NOPE')).toEqual('NOPE'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/mime_types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/mime_types.ts new file mode 100644 index 000000000000..f7664c90d461 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/mime_types.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const mimeTypes = { + 'application/iwork-keynote-sffkey': 'Keynote', + 'application/x-iwork-keynote-sffkey': 'Keynote', + 'application/iwork-numbers-sffnumbers': 'Numbers', + 'application/iwork-pages-sffpages': 'Pages', + 'application/json': 'JSON', + 'application/mp4': 'MP4', + 'application/msword': 'DOC', + 'application/octet-stream': 'Binary', + 'application/pdf': 'PDF', + 'application/rtf': 'RTF', + 'application/vnd.google-apps.audio': 'Google Audio', + 'application/vnd.google-apps.document': 'Google Doc', + 'application/vnd.google-apps.drawing': 'Google Drawing', + 'application/vnd.google-apps.file': 'Google Drive File', + 'application/vnd.google-apps.folder': 'Google Drive Folder', + 'application/vnd.google-apps.form': 'Google Form', + 'application/vnd.google-apps.fusiontable': 'Google Fusion Table', + 'application/vnd.google-apps.map': 'Google Map', + 'application/vnd.google-apps.photo': 'Google Photo', + 'application/vnd.google-apps.presentation': 'Google Slides', + 'application/vnd.google-apps.script': 'Google Script', + 'application/vnd.google-apps.sites': 'Google Site', + 'application/vnd.google-apps.spreadsheet': 'Google Sheet', + 'application/vnd.google-apps.unknown': 'Google Unknown', + 'application/vnd.google-apps.video': 'Google Video', + 'application/vnd.ms-excel': 'XLS', + 'application/vnd.ms-powerpoint': 'PPT', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PPTX', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX', + 'application/xml': 'XML', + 'application/zip': 'ZIP', + 'image/gif': 'GIF', + 'image/jpeg': 'JPEG', + 'image/png': 'PNG', + 'image/svg+xml': 'SVG', + 'image/tiff': 'TIFF', + 'image/vnd.adobe.photoshop': 'PSD', + 'text/comma-separated-values': 'CSV', + 'text/css': 'CSS', + 'text/html': 'HTML', + 'text/plain': 'TXT', + 'video/quicktime': 'MOV', +} as { [key: string]: string }; + +export const mimeType = (type: string) => mimeTypes[type.toLowerCase()] || type; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx index 95a62b06515c..549faf1676a5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx @@ -13,7 +13,7 @@ import { useValues } from 'kea'; import { isColorDark, hexToRgb } from '@elastic/eui'; import { DESCRIPTION_LABEL } from '../../../../constants'; -import { getAsLocalDateTimeString } from '../../../../utils'; +import { getAsLocalDateTimeString, mimeType } from '../../../../utils'; import { CustomSourceIcon } from './custom_source_icon'; import { DisplaySettingsLogic } from './display_settings_logic'; @@ -117,7 +117,7 @@ export const ExampleSearchResultGroup: React.FC = () => { data-test-subj="MediaTypeField" > - {result[mediaTypeField]} + {mimeType(result[mediaTypeField] as string)} )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx index b6aa180eb65d..46b8de678946 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx @@ -13,7 +13,7 @@ import { useValues } from 'kea'; import { isColorDark, hexToRgb } from '@elastic/eui'; import { DESCRIPTION_LABEL } from '../../../../constants'; -import { getAsLocalDateTimeString } from '../../../../utils'; +import { getAsLocalDateTimeString, mimeType } from '../../../../utils'; import { CustomSourceIcon } from './custom_source_icon'; import { DisplaySettingsLogic } from './display_settings_logic'; @@ -108,7 +108,9 @@ export const ExampleStandoutResult: React.FC = () => { })} data-test-subj="MediaTypeField" > - {result[mediaTypeField]} + + {mimeType(result[mediaTypeField] as string)} + )}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx index a6a0fcda0dd6..6515c8740f7a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx @@ -11,7 +11,7 @@ import { useActions, useValues } from 'kea'; import { EuiColorPicker, - EuiFlexGrid, + EuiFlexGroup, EuiFlexItem, EuiForm, EuiFormRow, @@ -76,7 +76,7 @@ export const SearchResults: React.FC = () => { return ( <> - + @@ -257,7 +257,7 @@ export const SearchResults: React.FC = () => { - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts index 3e8322145dad..d642900aea16 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts @@ -325,10 +325,11 @@ describe('SchemaLogic', () => { }); it('handles duplicate', () => { + const onSchemaSetFormErrorsSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetFormErrors'); SchemaLogic.actions.onInitializeSchema(serverResponse); SchemaLogic.actions.addNewField('foo', SchemaType.Number); - expect(setErrorMessage).toHaveBeenCalledWith('New field already exists: foo.'); + expect(onSchemaSetFormErrorsSpy).toHaveBeenCalledWith(['New field already exists: foo.']); }); }); @@ -393,8 +394,10 @@ describe('SchemaLogic', () => { it('handles error with message', async () => { const onSchemaSetFormErrorsSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetFormErrors'); - // We expect body.message to be a string[] when it is present - http.post.mockReturnValue(Promise.reject({ body: { message: ['this is an error'] } })); + // We expect body.attributes.errors to be a string[] when it is present + http.post.mockReturnValue( + Promise.reject({ body: { attributes: { errors: ['this is an error'] } } }) + ); SchemaLogic.actions.setServerField(schema, ADD); await nextTick(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts index 7af074d412a6..f43be974102b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts @@ -301,15 +301,15 @@ export const SchemaLogic = kea>({ addNewField: ({ fieldName, newFieldType }) => { if (fieldName in values.activeSchema) { window.scrollTo(0, 0); - setErrorMessage( + actions.onSchemaSetFormErrors([ i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.schema.newFieldExists.message', { defaultMessage: 'New field already exists: {fieldName}.', values: { fieldName }, } - ) - ); + ), + ]); } else { const schema = cloneDeep(values.activeSchema); schema[fieldName] = newFieldType; @@ -350,8 +350,8 @@ export const SchemaLogic = kea>({ } catch (e) { window.scrollTo(0, 0); if (isAdding) { - // We expect body.message to be a string[] for actions.onSchemaSetFormErrors - const message: string[] = e?.body?.message || [defaultErrorMessage]; + // We expect body.attributes.errors to be a string[] for actions.onSchemaSetFormErrors + const message: string[] = e?.body?.attributes?.errors || [defaultErrorMessage]; actions.onSchemaSetFormErrors(message); } else { flashAPIErrors(e); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx index 9f793fcd34fb..2a7dc2ebcc28 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx @@ -79,7 +79,7 @@ export const SourceRouter: React.FC = () => { )} {isCustomSource && ( - + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.test.tsx index f8c70d6bbba7..4d329ff357b8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.test.tsx @@ -14,7 +14,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiModal, EuiForm } from '@elastic/eui'; +import { EuiForm } from '@elastic/eui'; import { getPageDescription } from '../../../../test_helpers'; @@ -96,25 +96,55 @@ describe('OauthApplication', () => { expect(wrapper.find(CredentialItem)).toHaveLength(2); }); - it('renders license modal', () => { - setMockValues({ - hasPlatinumLicense: false, - oauthApplication, + describe('non-platinum license content', () => { + beforeEach(() => { + setMockValues({ + hasPlatinumLicense: false, + oauthApplication, + }); }); - const wrapper = shallow(); - - expect(wrapper.find(EuiModal)).toHaveLength(1); - }); - it('closes license modal', () => { - setMockValues({ - hasPlatinumLicense: false, - oauthApplication, + it('renders pageTitle', () => { + const wrapper = shallow(); + + expect(wrapper.prop('pageHeader').pageTitle).toMatchInlineSnapshot(` + + + + +

+ Configuring OAuth for Custom Search Applications +

+
+
+ `); }); - const wrapper = shallow(); - wrapper.find(EuiModal).prop('onClose')(); - expect(wrapper.find(EuiModal)).toHaveLength(0); + it('renders description', () => { + const wrapper = shallow(); + + expect(wrapper.prop('pageHeader').description).toMatchInlineSnapshot(` + + + Configure an OAuth application for secure use of the Workplace Search Search API. Upgrade to a Platinum license to enable the Search API and create your OAuth application. + + + + Explore Platinum features + + + `); + }); }); it('handles conditional copy', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx index ca8eadbcf75f..075d95f72603 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FormEvent, useState } from 'react'; +import React, { FormEvent } from 'react'; import { useActions, useValues } from 'kea'; @@ -19,8 +19,6 @@ import { EuiCode, EuiSpacer, EuiLink, - EuiModal, - EuiModalBody, EuiTitle, EuiText, } from '@elastic/eui'; @@ -47,9 +45,9 @@ import { REDIRECT_SECURE_ERROR_TEXT, REDIRECT_URIS_LABEL, SAVE_CHANGES_BUTTON, - LICENSE_MODAL_TITLE, - LICENSE_MODAL_DESCRIPTION, - LICENSE_MODAL_LINK, + NON_PLATINUM_OAUTH_TITLE, + NON_PLATINUM_OAUTH_DESCRIPTION, + NON_PLATINUM_OAUTH_LINK, } from '../../../constants'; import { ENT_SEARCH_LICENSE_MANAGEMENT } from '../../../routes'; import { SettingsLogic } from '../settings_logic'; @@ -59,9 +57,6 @@ export const OauthApplication: React.FC = () => { const { oauthApplication } = useValues(SettingsLogic); const { hasPlatinumLicense } = useValues(LicensingLogic); - const [isLicenseModalVisible, setIsLicenseModalVisible] = useState(!hasPlatinumLicense); - const closeLicenseModal = () => setIsLicenseModalVisible(false); - if (!oauthApplication) return null; const persisted = !!(oauthApplication.uid && oauthApplication.secret); @@ -91,38 +86,47 @@ export const OauthApplication: React.FC = () => { updateOauthApplication(); }; - const licenseModal = ( - - - - - - -

{LICENSE_MODAL_TITLE}

-
- - {LICENSE_MODAL_DESCRIPTION} - - - {LICENSE_MODAL_LINK} - - -
-
+ const nonPlatinumTitle = ( + <> + + + +

{NON_PLATINUM_OAUTH_TITLE}

+
+ + ); + + const nonPlatinumDescription = ( + <> + {NON_PLATINUM_OAUTH_DESCRIPTION} + + + {NON_PLATINUM_OAUTH_LINK} + + ); return ( + setOauthApplication({ ...oauthApplication, name: e.target.value })} + onChange={(e) => + setOauthApplication({ + ...oauthApplication, + name: e.target.value, + }) + } required disabled={!hasPlatinumLicense} /> @@ -139,7 +143,10 @@ export const OauthApplication: React.FC = () => { value={oauthApplication.redirectUri} data-test-subj="RedirectURIsTextArea" onChange={(e) => - setOauthApplication({ ...oauthApplication, redirectUri: e.target.value }) + setOauthApplication({ + ...oauthApplication, + redirectUri: e.target.value, + }) } required disabled={!hasPlatinumLicense} @@ -152,7 +159,10 @@ export const OauthApplication: React.FC = () => { checked={oauthApplication.confidential} data-test-subj="ConfidentialToggle" onChange={(e) => - setOauthApplication({ ...oauthApplication, confidential: e.target.checked }) + setOauthApplication({ + ...oauthApplication, + confidential: e.target.checked, + }) } disabled={!hasPlatinumLicense} /> @@ -193,7 +203,6 @@ export const OauthApplication: React.FC = () => { )} - {isLicenseModalVisible && licenseModal} ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx index 254885ea71b1..c0c425447e55 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx @@ -42,7 +42,7 @@ const breadcrumbGetters: { BASE_BREADCRUMB, { text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, ], @@ -50,7 +50,7 @@ const breadcrumbGetters: { BASE_BREADCRUMB, { text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, ], @@ -59,7 +59,7 @@ const breadcrumbGetters: { { href: pagePathGetters.policies()[1], text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, { text: policyName }, @@ -69,7 +69,7 @@ const breadcrumbGetters: { { href: pagePathGetters.policies()[1], text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, { @@ -100,7 +100,7 @@ const breadcrumbGetters: { { href: pagePathGetters.policies()[1], text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, { diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx index 7ad034b1cc05..dd15020adcc7 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx @@ -49,7 +49,7 @@ export const DefaultLayout: React.FunctionComponent = ({ name: ( ), isSelected: section === 'agent_policies', @@ -60,7 +60,7 @@ export const DefaultLayout: React.FunctionComponent = ({ name: ( ), isSelected: section === 'enrollment_tokens', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx index 1d7b44ceefb7..d6a6210bc867 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx @@ -136,7 +136,6 @@ export const SearchAndFilterBar: React.FunctionComponent<{ onClick={() => setIsStatutsFilterOpen(!isStatusFilterOpen)} isSelected={isStatusFilterOpen} hasActiveFilters={selectedStatus.length > 0} - numActiveFilters={selectedStatus.length} disabled={agentPolicies.length === 0} > { const fleetServerHost = settings?.item.fleet_server_hosts?.[0]; const output = outputsRequest.data?.items?.[0]; const esHost = output?.hosts?.[0]; + const refreshOutputs = outputsRequest.resendRequest; const installCommand = useMemo((): string => { if (!serviceToken || !esHost) { @@ -288,8 +289,8 @@ export const useFleetServerInstructions = (policyId?: string) => { }, [notifications.toasts]); const refresh = useCallback(() => { - return Promise.all([outputsRequest.resendRequest(), refreshSettings()]); - }, [outputsRequest, refreshSettings]); + return Promise.all([refreshOutputs(), refreshSettings()]); + }, [refreshOutputs, refreshSettings]); const addFleetServerHost = useCallback( async (host: string) => { @@ -449,9 +450,9 @@ export const AddFleetServerHostStepContent = ({ setIsLoading(true); if (validate(fleetServerHost)) { await addFleetServerHost(fleetServerHost); + setCalloutHost(fleetServerHost); + setFleetServerHost(''); } - setCalloutHost(fleetServerHost); - setFleetServerHost(''); } finally { setIsLoading(false); } @@ -481,10 +482,11 @@ export const AddFleetServerHostStepContent = ({ { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install -f \\\\ - --fleet-server-es=http://elasticsearch:9200 \\\\ + ".\\\\elastic-agent.exe install -f \` + --fleet-server-es=http://elasticsearch:9200 \` --fleet-server-service-token=service-token-1" `); }); @@ -78,9 +78,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install -f \\\\ - --fleet-server-es=http://elasticsearch:9200 \\\\ - --fleet-server-service-token=service-token-1 \\\\ + ".\\\\elastic-agent.exe install -f \` + --fleet-server-es=http://elasticsearch:9200 \` + --fleet-server-service-token=service-token-1 \` --fleet-server-policy=policy-1" `); }); @@ -137,14 +137,14 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install --url=http://fleetserver:8220 \\\\ - -f \\\\ - --fleet-server-es=http://elasticsearch:9200 \\\\ - --fleet-server-service-token=service-token-1 \\\\ - --fleet-server-policy=policy-1 \\\\ - --certificate-authorities= \\\\ - --fleet-server-es-ca= \\\\ - --fleet-server-cert= \\\\ + ".\\\\elastic-agent.exe install --url=http://fleetserver:8220 \` + -f \` + --fleet-server-es=http://elasticsearch:9200 \` + --fleet-server-service-token=service-token-1 \` + --fleet-server-policy=policy-1 \` + --certificate-authorities= \` + --fleet-server-es-ca= \` + --fleet-server-cert= \` --fleet-server-cert-key=" `); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts index b91c4b60aa71..e129d7a4d5b4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts @@ -16,22 +16,23 @@ export function getInstallCommandForPlatform( isProductionDeployment?: boolean ) { let commandArguments = ''; + const newLineSeparator = platform === 'windows' ? '`' : '\\'; if (isProductionDeployment && fleetServerHost) { - commandArguments += `--url=${fleetServerHost} \\\n`; + commandArguments += `--url=${fleetServerHost} ${newLineSeparator}\n`; } - commandArguments += ` -f \\\n --fleet-server-es=${esHost}`; - commandArguments += ` \\\n --fleet-server-service-token=${serviceToken}`; + commandArguments += ` -f ${newLineSeparator}\n --fleet-server-es=${esHost}`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-service-token=${serviceToken}`; if (policyId) { - commandArguments += ` \\\n --fleet-server-policy=${policyId}`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-policy=${policyId}`; } if (isProductionDeployment) { - commandArguments += ` \\\n --certificate-authorities=`; - commandArguments += ` \\\n --fleet-server-es-ca=`; - commandArguments += ` \\\n --fleet-server-cert=`; - commandArguments += ` \\\n --fleet-server-cert-key=`; + commandArguments += ` ${newLineSeparator}\n --certificate-authorities=`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-es-ca=`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-cert=`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-cert-key=`; } switch (platform) { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx index f8e4c9994e57..8e900e625215 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx @@ -22,20 +22,20 @@ export const DisplayedAssets: ServiceNameToAssetTypes = { export type DisplayedAssetType = ElasticsearchAssetType | KibanaAssetType | 'view'; export const AssetTitleMap: Record = { - dashboard: 'Dashboard', - ilm_policy: 'ILM Policy', - ingest_pipeline: 'Ingest Pipeline', - transform: 'Transform', - index_pattern: 'Index Pattern', - index_template: 'Index Template', - component_template: 'Component Template', - search: 'Saved Search', - visualization: 'Visualization', - map: 'Map', - data_stream_ilm_policy: 'Data Stream ILM Policy', + dashboard: 'Dashboards', + ilm_policy: 'ILM policies', + ingest_pipeline: 'Ingest pipelines', + transform: 'Transforms', + index_pattern: 'Index patterns', + index_template: 'Index templates', + component_template: 'Component templates', + search: 'Saved searches', + visualization: 'Visualizations', + map: 'Maps', + data_stream_ilm_policy: 'Data stream ILM policies', lens: 'Lens', - security_rule: 'Security Rule', - ml_module: 'ML Module', + security_rule: 'Security rules', + ml_module: 'ML modules', view: 'Views', }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets_accordion.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets_accordion.tsx index 91c6b68c6622..4a2d64fb8401 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets_accordion.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/assets/assets_accordion.tsx @@ -24,6 +24,8 @@ import { AssetTitleMap } from '../../../constants'; import { getHrefToObjectInKibanaApp, useStartServices } from '../../../../../hooks'; +import { KibanaAssetType } from '../../../../../types'; + import type { AllowedAssetType, AssetSavedObject } from './types'; interface Props { @@ -33,8 +35,12 @@ interface Props { export const AssetsAccordion: FunctionComponent = ({ savedObjects, type }) => { const { http } = useStartServices(); + + const isDashboard = type === KibanaAssetType.dashboard; + return ( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx index 79eea3441643..96e4071e9b46 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx @@ -5,7 +5,7 @@ * 2.0. */ import { stringify, parse } from 'query-string'; -import React, { memo, useCallback, useMemo, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { Redirect, useLocation, useHistory } from 'react-router-dom'; import type { CriteriaWithPagination, @@ -95,6 +95,21 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps const agentEnrollmentFlyoutExtension = useUIExtension(name, 'agent-enrollment-flyout'); + // Handle the "add agent" link displayed in post-installation toast notifications in the case + // where a user is clicking the link while on the package policies listing page + useEffect(() => { + const unlisten = history.listen((location) => { + const params = new URLSearchParams(location.search); + const addAgentToPolicyId = params.get('addAgentToPolicyId'); + + if (addAgentToPolicyId) { + setFlyoutOpenForPolicyId(addAgentToPolicyId); + } + }); + + return () => unlisten(); + }, [history]); + const handleTableOnChange = useCallback( ({ page }: CriteriaWithPagination) => { setPagination({ diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx index 96fab27a5505..adc6ba44dbb1 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx @@ -22,12 +22,14 @@ import { interface Props { agentPolicyId?: string; selectedApiKeyId?: string; + initialAuthenticationSettingsOpen?: boolean; onKeyChange: (key?: string) => void; } export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ agentPolicyId, selectedApiKeyId, + initialAuthenticationSettingsOpen = false, onKeyChange, }) => { const { notifications } = useStartServices(); @@ -35,7 +37,9 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ [] ); const [isLoadingEnrollmentKey, setIsLoadingEnrollmentKey] = useState(false); - const [isAuthenticationSettingsOpen, setIsAuthenticationSettingsOpen] = useState(false); + const [isAuthenticationSettingsOpen, setIsAuthenticationSettingsOpen] = useState( + initialAuthenticationSettingsOpen + ); const onCreateEnrollmentTokenClick = async () => { setIsLoadingEnrollmentKey(true); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts index d16be0d8b97e..d2e7c4089e88 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts @@ -50,6 +50,7 @@ jest.mock('./steps', () => { AgentPolicySelectionStep: jest.fn(), AgentEnrollmentKeySelectionStep: jest.fn(), ViewDataStep: jest.fn(), + DownloadStep: jest.fn(), }; }); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx index 8eeb5fac4b0d..6cffa39628d9 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx @@ -9,15 +9,24 @@ import React, { useCallback, useMemo } from 'react'; import { EuiText, EuiButton, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import semver from 'semver'; import type { AgentPolicy, PackagePolicy } from '../../types'; -import { sendGetOneAgentPolicy } from '../../hooks'; +import { sendGetOneAgentPolicy, useKibanaVersion } from '../../hooks'; import { FLEET_SERVER_PACKAGE } from '../../constants'; import { EnrollmentStepAgentPolicy } from './agent_policy_selection'; import { AdvancedAgentAuthenticationSettings } from './advanced_agent_authentication_settings'; export const DownloadStep = () => { + const kibanaVersion = useKibanaVersion(); + const kibanaVersionURLString = useMemo( + () => + `${semver.major(kibanaVersion)}-${semver.minor(kibanaVersion)}-${semver.patch( + kibanaVersion + )}`, + [kibanaVersion] + ); return { title: i18n.translate('xpack.fleet.agentEnrollment.stepDownloadAgentTitle', { defaultMessage: 'Download the Elastic Agent to your host', @@ -30,9 +39,16 @@ export const DownloadStep = () => { defaultMessage="Fleet Server runs on an Elastic Agent. You can download the Elastic Agent binaries and verification signatures from Elastic’s download page." /> + + + + { return { title: i18n.translate('xpack.fleet.agentEnrollment.stepConfigurePolicyAuthenticationTitle', { - defaultMessage: 'Configure agent authentication', + defaultMessage: 'Select enrollment token', }), children: ( <> {agentPolicy.name}, }} @@ -138,6 +154,7 @@ export const AgentEnrollmentKeySelectionStep = ({ diff --git a/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_header_link.tsx b/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_header_link.tsx index 819fa03e2b6d..074a1c40bdb1 100644 --- a/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_header_link.tsx +++ b/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_header_link.tsx @@ -33,10 +33,10 @@ const TutorialDirectoryHeaderLink: TutorialDirectoryHeaderLinkComponent = memo(( return hasIngestManager && noticeState.settingsDataLoaded && noticeState.hasSeenNotice ? ( - + diff --git a/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_notice.tsx b/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_notice.tsx index 23754571c5bc..e784ff1ac6ad 100644 --- a/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_notice.tsx +++ b/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_notice.tsx @@ -71,13 +71,13 @@ const TutorialDirectoryNotice: TutorialDirectoryNoticeComponent = memo(() => { title={ ), @@ -88,12 +88,16 @@ const TutorialDirectoryNotice: TutorialDirectoryNoticeComponent = memo(() => {

+ {

- + diff --git a/x-pack/plugins/fleet/public/components/home_integration/tutorial_module_notice.tsx b/x-pack/plugins/fleet/public/components/home_integration/tutorial_module_notice.tsx index f5b76de46e3a..6b9b441551a5 100644 --- a/x-pack/plugins/fleet/public/components/home_integration/tutorial_module_notice.tsx +++ b/x-pack/plugins/fleet/public/components/home_integration/tutorial_module_notice.tsx @@ -31,8 +31,8 @@ const TutorialModuleNotice: TutorialModuleNoticeComponent = memo(({ moduleName }

@@ -50,13 +50,13 @@ const TutorialModuleNotice: TutorialModuleNoticeComponent = memo(({ moduleName } > ), blogPostLink: ( diff --git a/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx b/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx index 9743135d5f1c..7b0a300ac9dc 100644 --- a/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx +++ b/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx @@ -28,6 +28,7 @@ export const PackagePolicyActionsMenu: React.FunctionComponent<{ const { getHref } = useLink(); const hasWriteCapabilities = useCapabilities().write; const refreshAgentPolicy = useAgentPolicyRefresh(); + const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false); const onEnrollmentFlyoutClose = useMemo(() => { return () => setIsEnrollmentFlyoutOpen(false); @@ -48,7 +49,10 @@ export const PackagePolicyActionsMenu: React.FunctionComponent<{ // , setIsEnrollmentFlyoutOpen(true)} + onClick={() => { + setIsActionsMenuOpen(false); + setIsEnrollmentFlyoutOpen(true); + }} key="addAgent" > )} - + setIsActionsMenuOpen(isOpen)} + /> ); }; diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index 9b3aefa488f7..f4a347c2ab3f 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -32,7 +32,7 @@ import type { CheckPermissionsResponse, PostIngestSetupResponse } from '../commo import type { FleetConfigType } from '../common/types'; -import { CUSTOM_LOGS_INTEGRATION_NAME, FLEET_BASE_PATH } from './constants'; +import { CUSTOM_LOGS_INTEGRATION_NAME, INTEGRATIONS_BASE_PATH } from './constants'; import { licenseService } from './hooks'; import { setHttpClient } from './hooks/use_request'; import { createPackageSearchProvider } from './search_provider'; @@ -183,14 +183,14 @@ export class FleetPlugin implements Plugin { id: 'test-test', score: 80, title: 'test', - type: 'package', + type: 'integration', url: { path: 'undefined#/detail/test-test/overview', prependBasePath: false, @@ -100,7 +100,7 @@ describe('Package search provider', () => { id: 'test1-test1', score: 80, title: 'test1', - type: 'package', + type: 'integration', url: { path: 'undefined#/detail/test1-test1/overview', prependBasePath: false, @@ -173,7 +173,7 @@ describe('Package search provider', () => { id: 'test-test', score: 80, title: 'test', - type: 'package', + type: 'integration', url: { path: 'undefined#/detail/test-test/overview', prependBasePath: false, @@ -209,7 +209,7 @@ describe('Package search provider', () => { expect(sendGetPackages).toHaveBeenCalledTimes(0); }); - test('with packages tag, with no search term', () => { + test('with integration tag, with no search term', () => { getTestScheduler().run(({ hot, expectObservable }) => { mockSendGetPackages.mockReturnValue( hot('--(a|)', { a: { data: { response: testResponse } } }) @@ -220,7 +220,7 @@ describe('Package search provider', () => { const packageSearchProvider = createPackageSearchProvider(setupMock); expectObservable( packageSearchProvider.find( - { types: ['package'] }, + { types: ['integration'] }, { aborted$: NEVER, maxResults: 100, preference: '' } ) ).toBe('--(a|)', { @@ -229,7 +229,7 @@ describe('Package search provider', () => { id: 'test-test', score: 80, title: 'test', - type: 'package', + type: 'integration', url: { path: 'undefined#/detail/test-test/overview', prependBasePath: false, @@ -239,7 +239,7 @@ describe('Package search provider', () => { id: 'test1-test1', score: 80, title: 'test1', - type: 'package', + type: 'integration', url: { path: 'undefined#/detail/test1-test1/overview', prependBasePath: false, @@ -252,7 +252,7 @@ describe('Package search provider', () => { expect(sendGetPackages).toHaveBeenCalledTimes(1); }); - test('with packages tag, with search term', () => { + test('with integration tag, with search term', () => { getTestScheduler().run(({ hot, expectObservable }) => { mockSendGetPackages.mockReturnValue( hot('--(a|)', { a: { data: { response: testResponse } } }) @@ -263,7 +263,7 @@ describe('Package search provider', () => { const packageSearchProvider = createPackageSearchProvider(setupMock); expectObservable( packageSearchProvider.find( - { term: 'test1', types: ['package'] }, + { term: 'test1', types: ['integration'] }, { aborted$: NEVER, maxResults: 100, preference: '' } ) ).toBe('--(a|)', { @@ -272,7 +272,7 @@ describe('Package search provider', () => { id: 'test1-test1', score: 80, title: 'test1', - type: 'package', + type: 'integration', url: { path: 'undefined#/detail/test1-test1/overview', prependBasePath: false, diff --git a/x-pack/plugins/fleet/public/search_provider.ts b/x-pack/plugins/fleet/public/search_provider.ts index 56e08ecad29f..a8b46a3dc0f0 100644 --- a/x-pack/plugins/fleet/public/search_provider.ts +++ b/x-pack/plugins/fleet/public/search_provider.ts @@ -21,7 +21,7 @@ import { sendGetPackages } from './hooks'; import type { GetPackagesResponse } from './types'; import { pagePathGetters } from './constants'; -const packageType = 'package'; +const packageType = 'integration'; const createPackages$ = () => from(sendGetPackages()).pipe( @@ -70,7 +70,7 @@ export const createPackageSearchProvider = (core: CoreSetup): GlobalSearchResult }; return { - id: 'packages', + id: 'integrations', getSearchableTypes: () => [packageType], find: ({ term, types }, { maxResults, aborted$ }) => { if (types?.includes(packageType) === false) { diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 1f8f54261c72..0ab102d91cd4 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -15,7 +15,6 @@ import type { PluginInitializerContext, SavedObjectsServiceStart, HttpServiceSetup, - SavedObjectsClientContract, RequestHandlerContext, KibanaRequest, } from 'kibana/server'; @@ -30,12 +29,7 @@ import type { } from '../../encrypted_saved_objects/server'; import type { SecurityPluginSetup, SecurityPluginStart } from '../../security/server'; import type { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; -import type { - EsAssetReference, - FleetConfigType, - NewPackagePolicy, - UpdatePackagePolicy, -} from '../common'; +import type { FleetConfigType, NewPackagePolicy, UpdatePackagePolicy } from '../common'; import { INTEGRATIONS_PLUGIN_ID } from '../common'; import type { CloudSetup } from '../../cloud/server'; @@ -224,7 +218,7 @@ export class FleetPlugin if (deps.features) { deps.features.registerKibanaFeature({ id: PLUGIN_ID, - name: 'Fleet', + name: 'Fleet and Integrations', category: DEFAULT_APP_CATEGORIES.management, app: [PLUGIN_ID, INTEGRATIONS_PLUGIN_ID, 'kibana'], catalogue: ['fleet'], @@ -309,13 +303,7 @@ export class FleetPlugin }), esIndexPatternService: new ESIndexPatternSavedObjectService(), packageService: { - getInstalledEsAssetReferences: async ( - savedObjectsClient: SavedObjectsClientContract, - pkgName: string - ): Promise => { - const installation = await getInstallation({ savedObjectsClient, pkgName }); - return installation?.installed_es || []; - }, + getInstallation, }, agentService: { getAgent: getAgentById, diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index cff70737be6e..830298331631 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -773,10 +773,10 @@ class AgentPolicyService { ) { const names: string[] = []; if (fullAgentPolicy.agent.monitoring.logs) { - names.push(`logs-elastic_agent.*-${monitoringNamespace}`); + names.push(`logs-elastic_agent*-${monitoringNamespace}`); } if (fullAgentPolicy.agent.monitoring.metrics) { - names.push(`metrics-elastic_agent.*-${monitoringNamespace}`); + names.push(`metrics-elastic_agent*-${monitoringNamespace}`); } permissions._elastic_agent_checks.indices = [ diff --git a/x-pack/plugins/fleet/server/services/index.ts b/x-pack/plugins/fleet/server/services/index.ts index f82415987e5a..f4355320c5a6 100644 --- a/x-pack/plugins/fleet/server/services/index.ts +++ b/x-pack/plugins/fleet/server/services/index.ts @@ -8,11 +8,12 @@ import type { KibanaRequest } from 'kibana/server'; import type { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server'; -import type { AgentStatus, Agent, EsAssetReference } from '../types'; +import type { AgentStatus, Agent } from '../types'; import type { getAgentById, getAgentsByKuery } from './agents'; import type { agentPolicyService } from './agent_policy'; import * as settingsService from './settings'; +import type { getInstallation } from './epm/packages'; export { ESIndexPatternSavedObjectService } from './es_index_pattern'; export { getRegistryUrl } from './epm/registry/registry_url'; @@ -33,10 +34,7 @@ export interface ESIndexPatternService { */ export interface PackageService { - getInstalledEsAssetReferences( - savedObjectsClient: SavedObjectsClientContract, - pkgName: string - ): Promise; + getInstallation: typeof getInstallation; } /** diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 9b3e9b7a5736..cf06136c487e 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -14,7 +14,10 @@ import type { AgentPolicy, NewPackagePolicy, Output } from '../types'; import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../constants'; -import { ensurePreconfiguredPackagesAndPolicies } from './preconfiguration'; +import { + ensurePreconfiguredPackagesAndPolicies, + comparePreconfiguredPolicyToCurrent, +} from './preconfiguration'; jest.mock('./agent_policy_update'); @@ -279,3 +282,75 @@ describe('policy preconfiguration', () => { expect(policiesB[0].updated_at).toEqual(policiesA[0].updated_at); }); }); + +describe('comparePreconfiguredPolicyToCurrent', () => { + const baseConfig = { + name: 'Test policy', + namespace: 'default', + description: 'This is a test policy', + id: 'test-id', + unenroll_timeout: 60, + package_policies: [ + { + package: { name: 'test_package' }, + name: 'Test package', + }, + ], + }; + + const basePackagePolicy: AgentPolicy = { + id: 'test-id', + namespace: 'default', + monitoring_enabled: ['logs', 'metrics'], + name: 'Test policy', + description: 'This is a test policy', + unenroll_timeout: 60, + is_preconfigured: true, + status: 'active', + is_managed: true, + revision: 1, + updated_at: '2021-07-07T16:29:55.144Z', + updated_by: 'system', + package_policies: [ + { + package: { name: 'test_package', title: 'Test package', version: '1.0.0' }, + name: 'Test package', + namespace: 'default', + enabled: true, + id: 'test-package-id', + revision: 1, + updated_at: '2021-07-07T16:29:55.144Z', + updated_by: 'system', + created_at: '2021-07-07T16:29:55.144Z', + created_by: 'system', + inputs: [], + policy_id: 'abc123', + output_id: 'default', + }, + ], + }; + + it('should return hasChanged when a top-level policy field changes', () => { + const { hasChanged } = comparePreconfiguredPolicyToCurrent( + { ...baseConfig, unenroll_timeout: 120 }, + basePackagePolicy + ); + expect(hasChanged).toBe(true); + }); + + it('should not return hasChanged when no top-level fields change', () => { + const { hasChanged } = comparePreconfiguredPolicyToCurrent( + { + ...baseConfig, + package_policies: [ + { + package: { name: 'different_package' }, + name: 'Different package', + }, + ], + }, + basePackagePolicy + ); + expect(hasChanged).toBe(false); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index f153ed3e68d3..0e24871628dc 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -7,7 +7,7 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; import { i18n } from '@kbn/i18n'; -import { groupBy, omit, isEqual } from 'lodash'; +import { groupBy, omit, pick, isEqual } from 'lodash'; import type { NewPackagePolicy, @@ -142,14 +142,16 @@ export async function ensurePreconfiguredPackagesAndPolicies( if (!created) { if (!policy?.is_managed) return { created, policy }; - const configTopLevelFields = omit(preconfiguredAgentPolicy, 'package_policies', 'id'); - const currentTopLevelFields = omit(policy, 'package_policies'); - if (!isEqual(configTopLevelFields, currentTopLevelFields)) { + const { hasChanged, fields } = comparePreconfiguredPolicyToCurrent( + preconfiguredAgentPolicy, + policy + ); + if (hasChanged) { const updatedPolicy = await agentPolicyService.update( soClient, esClient, String(preconfiguredAgentPolicy.id), - configTopLevelFields + fields ); return { created, policy: updatedPolicy }; } @@ -243,6 +245,19 @@ export async function ensurePreconfiguredPackagesAndPolicies( }; } +export function comparePreconfiguredPolicyToCurrent( + policyFromConfig: PreconfiguredAgentPolicy, + currentPolicy: AgentPolicy +) { + const configTopLevelFields = omit(policyFromConfig, 'package_policies', 'id'); + const currentTopLevelFields = pick(currentPolicy, ...Object.keys(configTopLevelFields)); + + return { + hasChanged: !isEqual(configTopLevelFields, currentTopLevelFields), + fields: configTopLevelFields, + }; +} + async function addPreconfiguredPolicyPackages( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts index 4cdeb678c432..9d85316d978e 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts @@ -7,7 +7,7 @@ import { isEqual } from 'lodash'; import createContainer from 'constate'; -import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import usePrevious from 'react-use/lib/usePrevious'; import useSetState from 'react-use/lib/useSetState'; import { esQuery } from '../../../../../../../src/plugins/data/public'; @@ -66,10 +66,9 @@ export function useLogStream({ const prevStartTimestamp = usePrevious(startTimestamp); const prevEndTimestamp = usePrevious(endTimestamp); - const cachedQuery = useRef(query); - + const [cachedQuery, setCachedQuery] = useState(query); if (!isEqual(query, cachedQuery)) { - cachedQuery.current = query; + setCachedQuery(query); } useEffect(() => { @@ -89,7 +88,7 @@ export function useLogStream({ sourceId, startTimestamp, endTimestamp, - query: cachedQuery.current, + query: cachedQuery, columnOverrides: columns, }), [columns, endTimestamp, cachedQuery, sourceId, startTimestamp] diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts index 9806cdaad637..445df21a6067 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts @@ -106,18 +106,20 @@ export const tinymathFunctions: Record< type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.addFunction.markdown', { + defaultMessage: ` Adds up two numbers. Also works with + symbol Example: Calculate the sum of two fields -${'`sum(price) + sum(tax)`'} +\`sum(price) + sum(tax)\` Example: Offset count by a static value -${'`add(count(), 5)`'} +\`add(count(), 5)\` `, + }), }, subtract: { positionalArguments: [ @@ -130,13 +132,15 @@ ${'`add(count(), 5)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.subtractFunction.markdown', { + defaultMessage: ` Subtracts the first number from the second number. -Also works with ${'`-`'} symbol +Also works with \`-\` symbol Example: Calculate the range of a field -${'`subtract(max(bytes), min(bytes))`'} +\`subtract(max(bytes), min(bytes))\` `, + }), }, multiply: { positionalArguments: [ @@ -149,16 +153,18 @@ ${'`subtract(max(bytes), min(bytes))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.multiplyFunction.markdown', { + defaultMessage: ` Multiplies two numbers. -Also works with ${'`*`'} symbol. +Also works with \`*\` symbol. Example: Calculate price after current tax rate -${'`sum(bytes) * last_value(tax_rate)`'} +\`sum(bytes) * last_value(tax_rate)\` Example: Calculate price after constant tax rate -${'`multiply(sum(price), 1.2)`'} +\`multiply(sum(price), 1.2)\` `, + }), }, divide: { positionalArguments: [ @@ -171,15 +177,17 @@ ${'`multiply(sum(price), 1.2)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.divideFunction.markdown', { + defaultMessage: ` Divides the first number by the second number. -Also works with ${'`/`'} symbol +Also works with \`/\` symbol Example: Calculate profit margin -${'`sum(profit) / sum(revenue)`'} +\`sum(profit) / sum(revenue)\` -Example: ${'`divide(sum(bytes), 2)`'} +Example: \`divide(sum(bytes), 2)\` `, + }), }, abs: { positionalArguments: [ @@ -188,11 +196,13 @@ Example: ${'`divide(sum(bytes), 2)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.absFunction.markdown', { + defaultMessage: ` Calculates absolute value. A negative value is multiplied by -1, a positive value stays the same. -Example: Calculate average distance to sea level ${'`abs(average(altitude))`'} +Example: Calculate average distance to sea level \`abs(average(altitude))\` `, + }), }, cbrt: { positionalArguments: [ @@ -201,12 +211,14 @@ Example: Calculate average distance to sea level ${'`abs(average(altitude))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.cbrtFunction.markdown', { + defaultMessage: ` Cube root of value. Example: Calculate side length from volume -${'`cbrt(last_value(volume))`'} +\`cbrt(last_value(volume))\` `, + }), }, ceil: { positionalArguments: [ @@ -215,13 +227,14 @@ ${'`cbrt(last_value(volume))`'} type: getTypeI18n('number'), }, ], - // signature: 'ceil(value: number)', - help: ` + help: i18n.translate('xpack.lens.formula.ceilFunction.markdown', { + defaultMessage: ` Ceiling of value, rounds up. Example: Round up price to the next dollar -${'`ceil(sum(price))`'} +\`ceil(sum(price))\` `, + }), }, clamp: { positionalArguments: [ @@ -238,8 +251,8 @@ ${'`ceil(sum(price))`'} type: getTypeI18n('number'), }, ], - // signature: 'clamp(value: number, minimum: number, maximum: number)', - help: ` + help: i18n.translate('xpack.lens.formula.clampFunction.markdown', { + defaultMessage: ` Limits the value from a minimum to maximum. Example: Make sure to catch outliers @@ -251,6 +264,7 @@ clamp( ) \`\`\` `, + }), }, cube: { positionalArguments: [ @@ -259,12 +273,14 @@ clamp( type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.cubeFunction.markdown', { + defaultMessage: ` Calculates the cube of a number. Example: Calculate volume from side length -${'`cube(last_value(length))`'} +\`cube(last_value(length))\` `, + }), }, exp: { positionalArguments: [ @@ -273,13 +289,15 @@ ${'`cube(last_value(length))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.expFunction.markdown', { + defaultMessage: ` Raises *e* to the nth power. Example: Calculate the natural exponential function -${'`exp(last_value(duration))`'} +\`exp(last_value(duration))\` `, + }), }, fix: { positionalArguments: [ @@ -288,12 +306,14 @@ ${'`exp(last_value(duration))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.fixFunction.markdown', { + defaultMessage: ` For positive values, takes the floor. For negative values, takes the ceiling. Example: Rounding towards zero -${'`fix(sum(profit))`'} +\`fix(sum(profit))\` `, + }), }, floor: { positionalArguments: [ @@ -302,12 +322,14 @@ ${'`fix(sum(profit))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.floorFunction.markdown', { + defaultMessage: ` Round down to nearest integer value Example: Round down a price -${'`floor(sum(price))`'} +\`floor(sum(price))\` `, + }), }, log: { positionalArguments: [ @@ -322,7 +344,8 @@ ${'`floor(sum(price))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.logFunction.markdown', { + defaultMessage: ` Logarithm with optional base. The natural base *e* is used as default. Example: Calculate number of bits required to store values @@ -331,17 +354,8 @@ log(sum(bytes)) log(sum(bytes), 2) \`\`\` `, + }), }, - // TODO: check if this is valid for Tinymath - // log10: { - // positionalArguments: [ - // { name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }), type: getTypeI18n('number') }, - // ], - // help: ` - // Base 10 logarithm. - // Example: ${'`log10(sum(bytes))`'} - // `, - // }, mod: { positionalArguments: [ { @@ -353,12 +367,14 @@ log(sum(bytes), 2) type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.modFunction.markdown', { + defaultMessage: ` Remainder after dividing the function by a number Example: Calculate last three digits of a value -${'`mod(sum(price), 1000)`'} +\`mod(sum(price), 1000)\` `, + }), }, pow: { positionalArguments: [ @@ -371,12 +387,14 @@ ${'`mod(sum(price), 1000)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.powFunction.markdown', { + defaultMessage: ` Raises the value to a certain power. The second argument is required Example: Calculate volume based on side length -${'`pow(last_value(length), 3)`'} +\`pow(last_value(length), 3)\` `, + }), }, round: { positionalArguments: [ @@ -391,7 +409,8 @@ ${'`pow(last_value(length), 3)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.roundFunction.markdown', { + defaultMessage: ` Rounds to a specific number of decimal places, default of 0 Examples: Round to the cent @@ -400,6 +419,7 @@ round(sum(bytes)) round(sum(bytes), 2) \`\`\` `, + }), }, sqrt: { positionalArguments: [ @@ -408,12 +428,14 @@ round(sum(bytes), 2) type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.sqrtFunction.markdown', { + defaultMessage: ` Square root of a positive value only Example: Calculate side length based on area -${'`sqrt(last_value(area))`'} +\`sqrt(last_value(area))\` `, + }), }, square: { positionalArguments: [ @@ -422,12 +444,14 @@ ${'`sqrt(last_value(area))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.squareFunction.markdown', { + defaultMessage: ` Raise the value to the 2nd power Example: Calculate area based on side length -${'`square(last_value(length))`'} +\`square(last_value(length))\` `, + }), }, }; diff --git a/x-pack/plugins/lens/public/shared_components/debounced_value.ts b/x-pack/plugins/lens/public/shared_components/debounced_value.ts index 5525f6b16b31..54696e672ccb 100644 --- a/x-pack/plugins/lens/public/shared_components/debounced_value.ts +++ b/x-pack/plugins/lens/public/shared_components/debounced_value.ts @@ -30,12 +30,20 @@ export const useDebouncedValue = ( // Save the initial value const initialValue = useRef(value); + const flushChangesTimeout = useRef(); + const onChangeDebounced = useMemo(() => { const callback = debounce((val: T) => { onChange(val); - unflushedChanges.current = false; + // do not reset unflushed flag right away, wait a bit for upstream to pick it up + flushChangesTimeout.current = setTimeout(() => { + unflushedChanges.current = false; + }, 256); }, 256); return (val: T) => { + if (flushChangesTimeout.current) { + clearTimeout(flushChangesTimeout.current); + } unflushedChanges.current = true; callback(val); }; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index 04533f6c914e..8f7fe4601739 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -540,7 +540,10 @@ export function DimensionEditor( (yAxisConfig) => yAxisConfig.forAccessor === accessor ); if (existingIndex !== -1) { - newYAxisConfigs[existingIndex].axisMode = newMode; + newYAxisConfigs[existingIndex] = { + ...newYAxisConfigs[existingIndex], + axisMode: newMode, + }; } else { newYAxisConfigs.push({ forAccessor: accessor, @@ -625,9 +628,9 @@ const ColorPicker = ({ const existingIndex = newYConfigs.findIndex((yConfig) => yConfig.forAccessor === accessor); if (existingIndex !== -1) { if (text === '') { - delete newYConfigs[existingIndex].color; + newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: undefined }; } else { - newYConfigs[existingIndex].color = output.hex; + newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: output.hex }; } } else { newYConfigs.push({ diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx index 66f0e0c4a951..ce4fcd8f1256 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_control.tsx @@ -134,7 +134,7 @@ export class DrawControl extends Component { }, paint: { 'text-color': '#fbb03b', - 'text-halo-color': 'rgba(255, 255, 255, 1)', + 'text-halo-color': 'rgba(0, 0, 0, 1)', 'text-halo-width': 2, }, }); diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_feature_control/draw_feature_control.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_feature_control/draw_feature_control.tsx index 9f2b99b9c41a..7b408df3a813 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_feature_control/draw_feature_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_feature_control/draw_feature_control.tsx @@ -6,7 +6,7 @@ */ import React, { Component } from 'react'; -import { Map as MbMap, Point as MbPoint } from 'mapbox-gl'; +import { Map as MbMap, Point as MbPoint } from '@kbn/mapbox-gl'; // @ts-expect-error import MapboxDraw from '@mapbox/mapbox-gl-draw'; import { Feature, Geometry, Position } from 'geojson'; diff --git a/x-pack/plugins/metrics_entities/README.md b/x-pack/plugins/metrics_entities/README.md index 6c711ce4fed8..71ac2730f383 100755 --- a/x-pack/plugins/metrics_entities/README.md +++ b/x-pack/plugins/metrics_entities/README.md @@ -2,7 +2,7 @@ This is the metrics and entities plugin where you add can add transforms for your project and group those transforms into modules. You can also re-use existing transforms in your -modules as well. +newly created modules as well. ## Turn on experimental flags During at least phase 1 of this development, please add these to your `kibana.dev.yml` file to turn on the feature: @@ -309,16 +309,14 @@ are notes during the phased approach. As we approach production and the feature left over TODO's in the code base. - Add these properties to the route which are: - - disable_transforms/exclude flag to exclude 1 or more transforms within a module, - - pipeline flag, - - Change the REST routes on post to change the indexes for whichever indexes you want - - Unit tests to ensure the data of the mapping.json includes the correct fields such as - _meta, at least one alias, a mapping section, etc... - - Add text/keyword and other things to the mappings (not just keyword maybe?) ... At least review the mappings one more time - - Add a sort of @timestamp to the output destination indexes? - - Add the REST Kibana security based tags if needed and push those to any plugins using this plugin. Something like: tags: ['access:metricsEntities-read'] and ['access:metricsEntities-all'], - - Add schema validation choosing some schema library (io-ts or Kibana Schema or ... ) - - Add unit tests - - Add e2e tests - - Move ui code into this plugin from security_solutions? (maybe?) - - UI code could be within `kibana/packages` instead of in here directly and I think we will be better off. + - disable_transforms/exclude flag to exclude 1 or more transforms within a module + - pipeline flag + - Change the REST routes on post to change the indexes for whichever indexes you want +- Unit tests to ensure the data of the mapping.json includes the correct fields such as _meta, at least one alias, a mapping section, etc... +- Add text/keyword and other things to the mappings (not just keyword maybe?) ... At least review the mappings one more time +- Add a sort of @timestamp to the output destination indexes? +- Add the REST Kibana security based tags if needed and push those to any plugins using this plugin. Something like: tags: ['access:metricsEntities-read'] and ['access:metricsEntities-all'], +- Add schema validation choosing some schema library (io-ts or Kibana Schema or ... ) +- Add unit tests +- Add e2e tests +- Any UI code should not end up here. There is none right now, but all UI code should be within a kbn package or security_solutions diff --git a/x-pack/plugins/ml/public/application/components/data_grid/types.ts b/x-pack/plugins/ml/public/application/components/data_grid/types.ts index 9dcd6abb432b..47684ee307e9 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/types.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/types.ts @@ -82,6 +82,7 @@ export interface UseIndexDataReturnType | 'resultsField' > { renderCellValue: RenderCellValue; + indexPatternFields?: string[]; } export interface UseDataGridReturnType { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 669b95cbaeb8..bac6b0b9274f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -102,7 +102,7 @@ export enum INDEX_STATUS { export interface FieldSelectionItem { name: string; - mappings_types: string[]; + mappings_types?: string[]; is_included: boolean; is_required: boolean; feature_type?: string; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index cc01a8c3f940..47f7c2621802 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -115,6 +115,7 @@ export const ConfigurationStepForm: FC = ({ const [fetchingExplainData, setFetchingExplainData] = useState(false); const [maxDistinctValuesError, setMaxDistinctValuesError] = useState(); const [unsupportedFieldsError, setUnsupportedFieldsError] = useState(); + const [noDocsContainMappedFields, setNoDocsContainMappedFields] = useState(false); const [minimumFieldsRequiredMessage, setMinimumFieldsRequiredMessage] = useState< undefined | string >(); @@ -261,9 +262,13 @@ export const ConfigurationStepForm: FC = ({ formToUse.includes = [...includes, dependentVariable]; } - const { success, expectedMemory, fieldSelection, errorMessage } = await fetchExplainData( - formToUse - ); + const { + success, + expectedMemory, + fieldSelection, + errorMessage, + noDocsContainMappedFields: noDocsWithFields, + } = await fetchExplainData(formToUse); if (success) { if (shouldUpdateEstimatedMml) { @@ -286,6 +291,7 @@ export const ConfigurationStepForm: FC = ({ setFieldOptionsFetchFail(false); setMaxDistinctValuesError(undefined); setUnsupportedFieldsError(undefined); + setNoDocsContainMappedFields(false); setIncludesTableItems(fieldSelection ? fieldSelection : []); } @@ -315,6 +321,7 @@ export const ConfigurationStepForm: FC = ({ setFieldOptionsFetchFail(true); setMaxDistinctValuesError(maxDistinctValuesErrorMessage); setUnsupportedFieldsError(unsupportedFieldsErrorMessage); + setNoDocsContainMappedFields(noDocsWithFields); setFetchingExplainData(false); setFormState({ ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: fallbackModelMemoryLimit } : {}), @@ -326,6 +333,17 @@ export const ConfigurationStepForm: FC = ({ setFormState({ sourceIndex: currentIndexPattern.title }); }, []); + const indexPatternFieldsTableItems = useMemo(() => { + if (indexData?.indexPatternFields !== undefined) { + return indexData.indexPatternFields.map((field) => ({ + name: field, + is_included: false, + is_required: false, + })); + } + return []; + }, [`${indexData?.indexPatternFields}`]); + useEffect(() => { if (typeof savedSearchQueryStr === 'string') { setFormState({ jobConfigQuery: savedSearchQuery, jobConfigQueryString: savedSearchQueryStr }); @@ -399,7 +417,12 @@ export const ConfigurationStepForm: FC = ({ ? [...updatedIncludes, dependentVariable] : updatedIncludes; - const { success, fieldSelection, errorMessage } = await fetchExplainData(formCopy); + const { + success, + fieldSelection, + errorMessage, + noDocsContainMappedFields: noDocsWithFields, + } = await fetchExplainData(formCopy); if (success) { // update the field selection table const hasRequiredFields = fieldSelection.some( @@ -423,6 +446,7 @@ export const ConfigurationStepForm: FC = ({ setIncludesTableItems(updatedFieldSelection ? updatedFieldSelection : fieldSelection); setMaxDistinctValuesError(undefined); setUnsupportedFieldsError(undefined); + setNoDocsContainMappedFields(noDocsWithFields); setFormState({ includes: updatedIncludes, requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, @@ -444,6 +468,7 @@ export const ConfigurationStepForm: FC = ({ setMaxDistinctValuesError(maxDistinctValuesErrorMessage); setUnsupportedFieldsError(unsupportedFieldsErrorMessage); + setNoDocsContainMappedFields(noDocsWithFields); } } } @@ -501,6 +526,11 @@ export const ConfigurationStepForm: FC = ({ // `undefined` means uninitialized, `null` means initialized but not used. if (savedSearchQuery === undefined) return null; + const tableItems = + includesTableItems.length > 0 && !noDocsContainMappedFields + ? includesTableItems + : indexPatternFieldsTableItems; + return ( @@ -649,7 +679,7 @@ export const ConfigurationStepForm: FC = ({ includes={includes} minimumFieldsRequiredMessage={minimumFieldsRequiredMessage} setMinimumFieldsRequiredMessage={setMinimumFieldsRequiredMessage} - tableItems={includesTableItems} + tableItems={firstUpdate.current ? includesTableItems : tableItems} unsupportedFieldsError={unsupportedFieldsError} setUnsupportedFieldsError={setUnsupportedFieldsError} setFormState={setFormState} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts index ec567f1f9615..7c83b0af1510 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts @@ -27,6 +27,7 @@ export const fetchExplainData = async (formState: State['form']) => { let success = true; let expectedMemory = ''; let fieldSelection: FieldSelectionItem[] = []; + let noDocsContainMappedFields = false; try { delete jobConfig.dest; @@ -45,11 +46,19 @@ export const fetchExplainData = async (formState: State['form']) => { } } + if ( + errorMessage.includes('status_exception') && + errorMessage.includes('Unable to estimate memory usage as no documents') + ) { + noDocsContainMappedFields = true; + } + return { success, expectedMemory, fieldSelection, errorMessage, errorReason, + noDocsContainMappedFields, }; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts index ddf88ce79ab5..b3034d910c7d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts @@ -255,6 +255,7 @@ export const useIndexData = ( return { ...dataGrid, + indexPatternFields, renderCellValue, }; }; diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index d959328218a1..82f8a90fafb7 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -40,7 +40,6 @@ import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; import { mlEscape } from '../util/string_utils'; import { FormattedTooltip, MlTooltipComponent } from '../components/chart_tooltip/chart_tooltip'; import { formatHumanReadableDateTime } from '../../../common/util/date_utils'; -import { getFormattedSeverityScore } from '../../../common/util/anomaly_utils'; import './_explorer.scss'; import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; @@ -62,6 +61,9 @@ declare global { } } +function getFormattedSeverityScore(score: number): string { + return String(parseInt(String(score), 10)); +} /** * Ignore insignificant resize, e.g. browser scrollbar appearance. */ @@ -122,7 +124,7 @@ const SwimLaneTooltip = (fieldName?: string): FC<{ values: TooltipValue[] }> => label: i18n.translate('xpack.ml.explorer.swimlane.maxAnomalyScoreLabel', { defaultMessage: 'Max anomaly score', }), - value: cell.formattedValue, + value: cell.formattedValue === '0' ? ' < 1' : cell.formattedValue, color: cell.color, // @ts-ignore seriesIdentifier: { @@ -408,73 +410,75 @@ export const SwimlaneContainer: FC = ({ grow={false} > <> -

- {showSwimlane && !isLoading && ( - - - - - - )} +
+
+ {showSwimlane && !isLoading && ( + + - {isLoading && ( - - + + )} + + {isLoading && ( + + + + )} + {!isLoading && !showSwimlane && ( + {noDataWarning}} /> - - )} - {!isLoading && !showSwimlane && ( - {noDataWarning}} - /> - )} + )} +
{swimlaneType === SWIMLANE_TYPE.OVERALL && showSwimlane && diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/overlay_range.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/overlay_range.tsx index 254f21186f5f..22e92df0097d 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/overlay_range.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/overlay_range.tsx @@ -9,6 +9,7 @@ import React, { FC } from 'react'; import { EuiIcon } from '@elastic/eui'; import { RectAnnotation, LineAnnotation, AnnotationDomainType, Position } from '@elastic/charts'; import { timeFormatter } from '../../../../../../../../common/util/date_utils'; +import { useCurrentEuiTheme } from '../../../../../../components/color_range_legend'; interface Props { overlayKey: number; @@ -19,12 +20,14 @@ interface Props { } export const OverlayRange: FC = ({ overlayKey, start, end, color, showMarker = true }) => { + const { euiTheme } = useCurrentEuiTheme(); + return ( <> = ({ overlayKey, start, end, color, showMar }, }} markerPosition={Position.Bottom} - hideTooltips={true} - marker={ + hideTooltips + marker={showMarker ? : undefined} + markerBody={ showMarker ? ( -
-
- -
-
{timeFormatter(start)}
+
+ {timeFormatter(start)}
) : undefined } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/charts/loading_wrapper/loading_wrapper.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/charts/loading_wrapper/loading_wrapper.tsx index 743b364a7e2c..65d6f950ebda 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/charts/loading_wrapper/loading_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/charts/loading_wrapper/loading_wrapper.tsx @@ -26,7 +26,7 @@ export const LoadingWrapper: FC = ({ hasData, loading = false, height, ch transition: 'opacity 0.2s', }} > - {children} + {loading && !hasData ? null : children}
{loading === true && ( ; +}; + +const RealDate = Date; + +jest.mock('../lib/alerts/fetch_ccr_read_exceptions', () => ({ + fetchCCRReadExceptions: jest.fn(), +})); +jest.mock('../lib/alerts/fetch_clusters', () => ({ + fetchClusters: jest.fn(), +})); + +jest.mock('../static_globals', () => ({ + Globals: { + app: { + getLogger: () => ({ debug: jest.fn() }), + url: 'http://localhost:5601', + config: { + ui: { + ccs: { enabled: true }, + metricbeat: { index: 'metricbeat-*' }, + container: { elasticsearch: { enabled: false } }, + }, + }, + }, + }, +})); + +describe('CCRReadExceptionsAlert', () => { + it('should have defaults', () => { + const alert = new CCRReadExceptionsAlert() as ICCRReadExceptionsAlertMock; + expect(alert.alertOptions.id).toBe(ALERT_CCR_READ_EXCEPTIONS); + expect(alert.alertOptions.name).toBe('CCR read exceptions'); + expect(alert.alertOptions.throttle).toBe('6h'); + expect(alert.alertOptions.defaultParams).toStrictEqual({ + duration: '1h', + }); + expect(alert.alertOptions.actionVariables).toStrictEqual([ + { + name: 'remoteCluster', + description: 'The remote cluster experiencing CCR read exceptions.', + }, + { + name: 'followerIndex', + description: 'The follower index reporting CCR read exceptions.', + }, + { + name: 'internalShortMessage', + description: 'The short internal message generated by Elastic.', + }, + { + name: 'internalFullMessage', + description: 'The full internal message generated by Elastic.', + }, + { name: 'state', description: 'The current state of the alert.' }, + { name: 'clusterName', description: 'The cluster to which the node(s) belongs.' }, + { name: 'action', description: 'The recommended action for this alert.' }, + { + name: 'actionPlain', + description: 'The recommended action for this alert, without any markdown.', + }, + ]); + }); + describe('execute', () => { + const FakeDate = function () {}; + FakeDate.prototype.valueOf = () => 1; + + const clusterUuid = 'abc123'; + const clusterName = 'testCluster'; + const nodeId = 'myNodeId'; + const nodeName = 'myNodeName'; + const remoteCluster = 'BcK-0pmsQniyPQfZuauuXw_remote_cluster_1'; + const followerIndex = '.follower_index_1'; + const leaderIndex = '.leader_index_1'; + const readExceptions = [ + { + exception: { + type: 'read_exceptions_type_1', + reason: 'read_exceptions_reason_1', + }, + }, + ]; + const stat = { + remoteCluster, + followerIndex, + leaderIndex, + read_exceptions: readExceptions, + clusterUuid, + nodeId, + nodeName, + }; + + const replaceState = jest.fn(); + const scheduleActions = jest.fn(); + const getState = jest.fn(); + const executorOptions = { + services: { + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + alertInstanceFactory: jest.fn().mockImplementation(() => { + return { + replaceState, + scheduleActions, + getState, + }; + }), + }, + state: {}, + }; + + beforeEach(() => { + Date = FakeDate as DateConstructor; + (fetchCCRReadExceptions as jest.Mock).mockImplementation(() => { + return [stat]; + }); + (fetchClusters as jest.Mock).mockImplementation(() => { + return [{ clusterUuid, clusterName }]; + }); + }); + + afterEach(() => { + Date = RealDate; + replaceState.mockReset(); + scheduleActions.mockReset(); + getState.mockReset(); + }); + + it('should fire actions', async () => { + const alert = new CCRReadExceptionsAlert() as ICCRReadExceptionsAlertMock; + const type = alert.getAlertType(); + await type.executor({ + ...executorOptions, + params: alert.alertOptions.defaultParams, + } as any); + expect(scheduleActions).toHaveBeenCalledWith('default', { + internalFullMessage: `CCR read exceptions alert is firing for the following remote cluster: ${remoteCluster}. Current 'follower_index' index affected: ${followerIndex}. [View CCR stats](http://localhost:5601/app/monitoring#/elasticsearch/ccr?_g=(cluster_uuid:${clusterUuid}))`, + internalShortMessage: `CCR read exceptions alert is firing for the following remote cluster: ${remoteCluster}. Verify follower and leader index relationships on the affected remote cluster.`, + action: `[View CCR stats](http://localhost:5601/app/monitoring#/elasticsearch/ccr?_g=(cluster_uuid:${clusterUuid}))`, + actionPlain: + 'Verify follower and leader index relationships on the affected remote cluster.', + clusterName, + state: 'firing', + remoteCluster, + remoteClusters: remoteCluster, + followerIndex, + followerIndices: followerIndex, + }); + }); + + it('should handle ccs', async () => { + const ccs = 'testCluster'; + (fetchCCRReadExceptions as jest.Mock).mockImplementation(() => { + return [ + { + ...stat, + ccs, + }, + ]; + }); + const alert = new CCRReadExceptionsAlert() as ICCRReadExceptionsAlertMock; + const type = alert.getAlertType(); + await type.executor({ + ...executorOptions, + params: alert.alertOptions.defaultParams, + } as any); + expect(scheduleActions).toHaveBeenCalledWith('default', { + internalFullMessage: `CCR read exceptions alert is firing for the following remote cluster: ${remoteCluster}. Current 'follower_index' index affected: ${followerIndex}. [View CCR stats](http://localhost:5601/app/monitoring#/elasticsearch/ccr?_g=(cluster_uuid:${clusterUuid},ccs:testCluster))`, + internalShortMessage: `CCR read exceptions alert is firing for the following remote cluster: ${remoteCluster}. Verify follower and leader index relationships on the affected remote cluster.`, + action: `[View CCR stats](http://localhost:5601/app/monitoring#/elasticsearch/ccr?_g=(cluster_uuid:${clusterUuid},ccs:testCluster))`, + actionPlain: + 'Verify follower and leader index relationships on the affected remote cluster.', + clusterName, + state: 'firing', + remoteCluster, + remoteClusters: remoteCluster, + followerIndex, + followerIndices: followerIndex, + }); + }); + }); +}); diff --git a/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.ts b/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.ts index 2995566c7c09..28f562b2cb13 100644 --- a/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.ts @@ -47,20 +47,20 @@ export class CCRReadExceptionsAlert extends BaseAlert { }, actionVariables: [ { - name: 'remoteClusters', + name: 'remoteCluster', description: i18n.translate( - 'xpack.monitoring.alerts.ccrReadExceptions.actionVariables.remoteClusters', + 'xpack.monitoring.alerts.ccrReadExceptions.actionVariables.remoteCluster', { - defaultMessage: 'List of remote clusters that are experiencing CCR read exceptions.', + defaultMessage: 'The remote cluster experiencing CCR read exceptions.', } ), }, { - name: 'followerIndices', + name: 'followerIndex', description: i18n.translate( - 'xpack.monitoring.alerts.ccrReadExceptions.actionVariables.followerIndices', + 'xpack.monitoring.alerts.ccrReadExceptions.actionVariables.followerIndex', { - defaultMessage: 'List of follower indices reporting CCR read exceptions.', + defaultMessage: 'The follower index reporting CCR read exceptions.', } ), }, @@ -229,12 +229,11 @@ export class CCRReadExceptionsAlert extends BaseAlert { item: AlertData | null, cluster: AlertCluster ) { - const remoteClustersList = alertStates - .map((alertState) => (alertState.meta as CCRReadExceptionsUIMeta).remoteCluster) - .join(', '); - const followerIndicesList = alertStates - .map((alertState) => (alertState.meta as CCRReadExceptionsUIMeta).followerIndex) - .join(', '); + if (alertStates.length === 0) { + return; + } + const CCRReadExceptionsMeta = alertStates[0].meta as CCRReadExceptionsUIMeta; + const { remoteCluster, followerIndex } = CCRReadExceptionsMeta; const shortActionText = i18n.translate( 'xpack.monitoring.alerts.ccrReadExceptions.shortAction', @@ -258,9 +257,9 @@ export class CCRReadExceptionsAlert extends BaseAlert { const internalShortMessage = i18n.translate( 'xpack.monitoring.alerts.ccrReadExceptions.firing.internalShortMessage', { - defaultMessage: `CCR read exceptions alert is firing for the following remote cluster: {remoteClustersList}. {shortActionText}`, + defaultMessage: `CCR read exceptions alert is firing for the following remote cluster: {remoteCluster}. {shortActionText}`, values: { - remoteClustersList, + remoteCluster, shortActionText, }, } @@ -268,11 +267,11 @@ export class CCRReadExceptionsAlert extends BaseAlert { const internalFullMessage = i18n.translate( 'xpack.monitoring.alerts.ccrReadExceptions.firing.internalFullMessage', { - defaultMessage: `CCR read exceptions alert is firing for the following remote cluster: {remoteClustersList}. Current 'follower_index' index affected: {followerIndicesList}. {action}`, + defaultMessage: `CCR read exceptions alert is firing for the following remote cluster: {remoteCluster}. Current 'follower_index' index affected: {followerIndex}. {action}`, values: { action, - remoteClustersList, - followerIndicesList, + remoteCluster, + followerIndex, }, } ); @@ -281,8 +280,14 @@ export class CCRReadExceptionsAlert extends BaseAlert { internalShortMessage, internalFullMessage, state: AlertingDefaults.ALERT_STATE.firing, - remoteClusters: remoteClustersList, - followerIndices: followerIndicesList, + remoteCluster, + followerIndex, + /* continue to send "remoteClusters" and "followerIndices" values for users still using it though + we have replaced it with "remoteCluster" and "followerIndex" in the template due to alerts per index instead of all indices + see https://github.com/elastic/kibana/issues/100136#issuecomment-865229431 + */ + remoteClusters: remoteCluster, + followerIndices: followerIndex, clusterName: cluster.clusterName, action, actionPlain: shortActionText, diff --git a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.test.ts new file mode 100644 index 000000000000..18987a24e552 --- /dev/null +++ b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.test.ts @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LargeShardSizeAlert } from './large_shard_size_alert'; +import { ALERT_LARGE_SHARD_SIZE } from '../../common/constants'; +import { fetchIndexShardSize } from '../lib/alerts/fetch_index_shard_size'; +import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; + +type ILargeShardSizeAlertMock = LargeShardSizeAlert & { + defaultParams: { + threshold: number; + duration: string; + }; +} & { + actionVariables: Array<{ + name: string; + description: string; + }>; +}; + +const RealDate = Date; + +jest.mock('../lib/alerts/fetch_index_shard_size', () => ({ + fetchIndexShardSize: jest.fn(), +})); +jest.mock('../lib/alerts/fetch_clusters', () => ({ + fetchClusters: jest.fn(), +})); + +jest.mock('../static_globals', () => ({ + Globals: { + app: { + getLogger: () => ({ debug: jest.fn() }), + url: 'http://localhost:5601', + config: { + ui: { + ccs: { enabled: true }, + metricbeat: { index: 'metricbeat-*' }, + container: { elasticsearch: { enabled: false } }, + }, + }, + }, + }, +})); + +describe('LargeShardSizeAlert', () => { + it('should have defaults', () => { + const alert = new LargeShardSizeAlert() as ILargeShardSizeAlertMock; + expect(alert.alertOptions.id).toBe(ALERT_LARGE_SHARD_SIZE); + expect(alert.alertOptions.name).toBe('Shard size'); + expect(alert.alertOptions.throttle).toBe('12h'); + expect(alert.alertOptions.defaultParams).toStrictEqual({ + threshold: 55, + indexPattern: '-.*', + }); + expect(alert.alertOptions.actionVariables).toStrictEqual([ + { name: 'shardIndex', description: 'The index experiencing large average shard size.' }, + { + name: 'internalShortMessage', + description: 'The short internal message generated by Elastic.', + }, + { + name: 'internalFullMessage', + description: 'The full internal message generated by Elastic.', + }, + { name: 'state', description: 'The current state of the alert.' }, + { name: 'clusterName', description: 'The cluster to which the node(s) belongs.' }, + { name: 'action', description: 'The recommended action for this alert.' }, + { + name: 'actionPlain', + description: 'The recommended action for this alert, without any markdown.', + }, + ]); + }); + describe('execute', () => { + const FakeDate = function () {}; + FakeDate.prototype.valueOf = () => 1; + + const shardIndex = 'apm-8.0.0-onboarding-2021.06.30'; + const shardSize = 0; + const clusterUuid = 'abc123'; + const clusterName = 'testCluster'; + const nodeId = 'myNodeId'; + const nodeName = 'myNodeName'; + const stat = { + shardIndex, + shardSize, + clusterUuid, + nodeId, + nodeName, + }; + + const replaceState = jest.fn(); + const scheduleActions = jest.fn(); + const getState = jest.fn(); + const executorOptions = { + services: { + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + alertInstanceFactory: jest.fn().mockImplementation(() => { + return { + replaceState, + scheduleActions, + getState, + }; + }), + }, + state: {}, + }; + + beforeEach(() => { + Date = FakeDate as DateConstructor; + (fetchIndexShardSize as jest.Mock).mockImplementation(() => { + return [stat]; + }); + (fetchClusters as jest.Mock).mockImplementation(() => { + return [{ clusterUuid, clusterName }]; + }); + }); + + afterEach(() => { + Date = RealDate; + replaceState.mockReset(); + scheduleActions.mockReset(); + getState.mockReset(); + }); + + it('should fire actions', async () => { + const alert = new LargeShardSizeAlert() as ILargeShardSizeAlertMock; + const type = alert.getAlertType(); + await type.executor({ + ...executorOptions, + params: alert.alertOptions.defaultParams, + } as any); + expect(scheduleActions).toHaveBeenCalledWith('default', { + internalFullMessage: `Large shard size alert is firing for the following index: ${shardIndex}. [View index shard size stats](http://localhost:5601/app/monitoring#/elasticsearch/indices/${shardIndex}?_g=(cluster_uuid:${clusterUuid}))`, + internalShortMessage: `Large shard size alert is firing for the following index: ${shardIndex}. Investigate indices with large shard sizes.`, + action: `[View index shard size stats](http://localhost:5601/app/monitoring#/elasticsearch/indices/${shardIndex}?_g=(cluster_uuid:${clusterUuid}))`, + actionPlain: 'Investigate indices with large shard sizes.', + clusterName, + state: 'firing', + shardIndex, + shardIndices: shardIndex, + }); + }); + + it('should handle ccs', async () => { + const ccs = 'testCluster'; + (fetchIndexShardSize as jest.Mock).mockImplementation(() => { + return [ + { + ...stat, + ccs, + }, + ]; + }); + const alert = new LargeShardSizeAlert() as ILargeShardSizeAlertMock; + const type = alert.getAlertType(); + await type.executor({ + ...executorOptions, + params: alert.alertOptions.defaultParams, + } as any); + expect(scheduleActions).toHaveBeenCalledWith('default', { + internalFullMessage: `Large shard size alert is firing for the following index: ${shardIndex}. [View index shard size stats](http://localhost:5601/app/monitoring#/elasticsearch/indices/${shardIndex}?_g=(cluster_uuid:${clusterUuid},ccs:testCluster))`, + internalShortMessage: `Large shard size alert is firing for the following index: ${shardIndex}. Investigate indices with large shard sizes.`, + action: `[View index shard size stats](http://localhost:5601/app/monitoring#/elasticsearch/indices/${shardIndex}?_g=(cluster_uuid:${clusterUuid},ccs:testCluster))`, + actionPlain: 'Investigate indices with large shard sizes.', + clusterName, + state: 'firing', + shardIndex, + shardIndices: shardIndex, + }); + }); + }); +}); diff --git a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts index 75e22fb41025..a365e530cbd0 100644 --- a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts @@ -211,6 +211,11 @@ export class LargeShardSizeAlert extends BaseAlert { internalShortMessage, internalFullMessage, state: AlertingDefaults.ALERT_STATE.firing, + /* continue to send "shardIndices" values for users still using it though + we have replaced it with shardIndex in the template due to alerts per index instead of all indices + see https://github.com/elastic/kibana/issues/100136#issuecomment-865229431 + */ + shardIndices: shardIndex, shardIndex, clusterName: cluster.clusterName, action, diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index d13140f0be16..6bd96e012548 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -18,6 +18,7 @@ "data", "features", "ruleRegistry", + "timelines", "triggersActionsUi" ], "ui": true, diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx index c7faa28b0468..53b5300e556c 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx @@ -31,7 +31,7 @@ import { } from '@kbn/rule-data-utils/target/technical_field_names'; import moment from 'moment-timezone'; import React, { useMemo } from 'react'; -import type { TopAlertResponse } from '../'; +import type { TopAlert, TopAlertResponse } from '../'; import { useKibana, useUiSetting } from '../../../../../../../src/plugins/kibana_react/public'; import { asDuration } from '../../../../common/utils/formatters'; import type { ObservabilityRuleTypeRegistry } from '../../../rules/create_observability_rule_type_registry'; @@ -39,6 +39,7 @@ import { decorateResponse } from '../decorate_response'; import { SeverityBadge } from '../severity_badge'; type AlertsFlyoutProps = { + alert?: TopAlert; alerts?: TopAlertResponse[]; isInApp?: boolean; observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; @@ -46,6 +47,7 @@ type AlertsFlyoutProps = { } & EuiFlyoutProps; export function AlertsFlyout({ + alert, alerts, isInApp = false, observabilityRuleTypeRegistry, @@ -59,9 +61,12 @@ export function AlertsFlyout({ const decoratedAlerts = useMemo(() => { return decorateResponse(alerts ?? [], observabilityRuleTypeRegistry); }, [alerts, observabilityRuleTypeRegistry]); - const alert = decoratedAlerts?.find((a) => a.fields[ALERT_UUID] === selectedAlertId); - if (!alert) { + let alertData = alert; + if (!alertData) { + alertData = decoratedAlerts?.find((a) => a.fields[ALERT_UUID] === selectedAlertId); + } + if (!alertData) { return null; } @@ -70,45 +75,45 @@ export function AlertsFlyout({ title: i18n.translate('xpack.observability.alertsFlyout.statusLabel', { defaultMessage: 'Status', }), - description: alert.active ? 'Active' : 'Recovered', + description: alertData.active ? 'Active' : 'Recovered', }, { title: i18n.translate('xpack.observability.alertsFlyout.severityLabel', { defaultMessage: 'Severity', }), - description: , + description: , }, { title: i18n.translate('xpack.observability.alertsFlyout.triggeredLabel', { defaultMessage: 'Triggered', }), description: ( - {moment(alert.start).format(dateFormat)} + {moment(alertData.start).format(dateFormat)} ), }, { title: i18n.translate('xpack.observability.alertsFlyout.durationLabel', { defaultMessage: 'Duration', }), - description: asDuration(alert.fields[ALERT_DURATION], { extended: true }), + description: asDuration(alertData.fields[ALERT_DURATION], { extended: true }), }, { title: i18n.translate('xpack.observability.alertsFlyout.expectedValueLabel', { defaultMessage: 'Expected value', }), - description: alert.fields[ALERT_EVALUATION_THRESHOLD] ?? '-', + description: alertData.fields[ALERT_EVALUATION_THRESHOLD] ?? '-', }, { title: i18n.translate('xpack.observability.alertsFlyout.actualValueLabel', { defaultMessage: 'Actual value', }), - description: alert.fields[ALERT_EVALUATION_VALUE] ?? '-', + description: alertData.fields[ALERT_EVALUATION_VALUE] ?? '-', }, { title: i18n.translate('xpack.observability.alertsFlyout.ruleTypeLabel', { defaultMessage: 'Rule type', }), - description: alert.fields[RULE_CATEGORY] ?? '-', + description: alertData.fields[RULE_CATEGORY] ?? '-', }, ]; @@ -116,10 +121,10 @@ export function AlertsFlyout({ -

{alert.fields[RULE_NAME]}

+

{alertData.fields[RULE_NAME]}

- {alert.reason} + {alertData.reason}
@@ -129,11 +134,11 @@ export function AlertsFlyout({ listItems={overviewListItems} /> - {alert.link && !isInApp && ( + {alertData.link && !isInApp && ( - + View in app diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx index c0a08fa7faac..b2d44f9a598d 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx @@ -7,17 +7,17 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo, useState } from 'react'; -import { SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public'; +import { IIndexPattern, SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -import { useFetcher } from '../../hooks/use_fetcher'; -import { callObservabilityApi } from '../../services/call_observability_api'; export function AlertsSearchBar({ + dynamicIndexPattern, rangeFrom, rangeTo, onQueryChange, query, }: { + dynamicIndexPattern: IIndexPattern[]; rangeFrom?: string; rangeTo?: string; query?: string; @@ -31,16 +31,9 @@ export function AlertsSearchBar({ }, []); const [queryLanguage, setQueryLanguage] = useState<'lucene' | 'kuery'>('kuery'); - const { data: dynamicIndexPattern } = useFetcher(({ signal }) => { - return callObservabilityApi({ - signal, - endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern', - }); - }, []); - return ( void) => void; +} + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick & ColumnHeaderOptions +> = [ + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.statusColumnDescription', { + defaultMessage: 'Status', + }), + id: ALERT_STATUS, + initialWidth: 79, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.triggeredColumnDescription', { + defaultMessage: 'Triggered', + }), + id: ALERT_START, + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.durationColumnDescription', { + defaultMessage: 'Duration', + }), + id: ALERT_DURATION, + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.severityColumnDescription', { + defaultMessage: 'Severity', + }), + id: ALERT_SEVERITY_LEVEL, + initialWidth: 102, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.reasonColumnDescription', { + defaultMessage: 'Reason', + }), + linkField: '*', + id: RULE_NAME, + initialWidth: 400, + }, +]; + +const NO_ROW_RENDER: RowRenderer[] = []; + +const trailingControlColumns: never[] = []; + +export function AlertsTableTGrid(props: AlertsTableTGridProps) { + const { core, observabilityRuleTypeRegistry } = usePluginContext(); + const { prepend } = core.http.basePath; + const { indexName, rangeFrom, rangeTo, kuery, status, setRefetch } = props; + const [flyoutAlert, setFlyoutAlert] = useState(undefined); + const handleFlyoutClose = () => setFlyoutAlert(undefined); + const { timelines } = useKibana<{ timelines: TimelinesUIStart }>().services; + + const leadingControlColumns = [ + { + id: 'expand', + width: 40, + headerCellRender: () => null, + rowCellRender: ({ data }: ActionProps) => { + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + return ( + setFlyoutAlert(alert)} + /> + ); + }, + }, + { + id: 'view_in_app', + width: 40, + headerCellRender: () => null, + rowCellRender: ({ data }: ActionProps) => { + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + return ( + + ); + }, + }, + ]; + + return ( + <> + {flyoutAlert && ( + + + + )} + {timelines.getTGrid<'standalone'>({ + type: 'standalone', + columns, + deletedEventIds: [], + end: rangeTo, + filters: [], + indexNames: [indexName], + itemsPerPage: 10, + itemsPerPageOptions: [10, 25, 50], + loadingText: i18n.translate('xpack.observability.alertsTable.loadingTextLabel', { + defaultMessage: 'loading alerts', + }), + footerText: i18n.translate('xpack.observability.alertsTable.footerTextLabel', { + defaultMessage: 'alerts', + }), + query: { + query: `${ALERT_STATUS}: ${status}${kuery !== '' ? ` and ${kuery}` : ''}`, + language: 'kuery', + }, + renderCellValue: getRenderCellValue({ rangeFrom, rangeTo, setFlyoutAlert }), + rowRenderers: NO_ROW_RENDER, + start: rangeFrom, + setRefetch, + sort: [ + { + columnId: '@timestamp', + columnType: 'date', + sortDirection: 'desc', + }, + ], + leadingControlColumns, + trailingControlColumns, + unit: (totalAlerts: number) => + i18n.translate('xpack.observability.alertsTable.showingAlertsTitle', { + values: { totalAlerts }, + defaultMessage: '{totalAlerts, plural, =1 {alert} other {alerts}}', + }), + })} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx new file mode 100644 index 000000000000..38919857e86c --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { RULE_ID, RULE_NAME } from '@kbn/rule-data-utils/target/technical_field_names'; +import React, { useState } from 'react'; +import { format, parse } from 'url'; + +import { parseTechnicalFields } from '../../../../rule_registry/common/parse_technical_fields'; +import type { ActionProps } from '../../../../timelines/common'; +import { asDuration, asPercent } from '../../../common/utils/formatters'; +import { usePluginContext } from '../../hooks/use_plugin_context'; + +export function RowCellActionsRender({ data }: ActionProps) { + const { core, observabilityRuleTypeRegistry } = usePluginContext(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { prepend } = core.http.basePath; + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const parsedFields = parseTechnicalFields(dataFieldEs); + const formatter = observabilityRuleTypeRegistry.getFormatter(parsedFields[RULE_ID]!); + const formatted = { + link: undefined, + reason: parsedFields[RULE_NAME]!, + ...(formatter?.({ fields: parsedFields, formatters: { asDuration, asPercent } }) ?? {}), + }; + + const parsedLink = formatted.link ? parse(formatted.link, true) : undefined; + const link = parsedLink + ? format({ + ...parsedLink, + query: { + ...parsedLink.query, + rangeFrom: 'now-24h', + rangeTo: 'now', + }, + }) + : undefined; + return ( +
+ setIsPopoverOpen(!isPopoverOpen)} + /> + } + closePopover={() => setIsPopoverOpen(false)} + > + Actions +
+ + + + + + + {i18n.translate('xpack.observability.alertsTable.viewInAppButtonLabel', { + defaultMessage: 'View in app', + })} + + + +
+
+
+ ); +} diff --git a/x-pack/plugins/observability/public/pages/alerts/index.tsx b/x-pack/plugins/observability/public/pages/alerts/index.tsx index 6f696a70665c..fed9ee0be3a4 100644 --- a/x-pack/plugins/observability/public/pages/alerts/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/index.tsx @@ -7,21 +7,20 @@ import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { useHistory } from 'react-router-dom'; import { ParsedTechnicalFields } from '../../../../rule_registry/common/parse_technical_fields'; import type { AlertStatus } from '../../../common/typings'; import { ExperimentalBadge } from '../../components/shared/experimental_badge'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; -import { useFetcher } from '../../hooks/use_fetcher'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { RouteParams } from '../../routes'; -import { callObservabilityApi } from '../../services/call_observability_api'; import type { ObservabilityAPIReturnType } from '../../services/call_observability_api/types'; -import { getAbsoluteDateRange } from '../../utils/date'; import { AlertsSearchBar } from './alerts_search_bar'; -import { AlertsTable } from './alerts_table'; +import { AlertsTableTGrid } from './alerts_table_t_grid'; import { StatusFilter } from './status_filter'; +import { useFetcher } from '../../hooks/use_fetcher'; +import { callObservabilityApi } from '../../services/call_observability_api'; export type TopAlertResponse = ObservabilityAPIReturnType<'GET /api/observability/rules/alerts/top'>[number]; @@ -41,6 +40,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { const { core, ObservabilityPageTemplate } = usePluginContext(); const { prepend } = core.http.basePath; const history = useHistory(); + const refetch = useRef<() => void>(); const { query: { rangeFrom = 'now-15m', rangeTo = 'now', kuery = '', status = 'open' }, } = routeParams; @@ -59,37 +59,52 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { '/app/management/insightsAndAlerting/triggersActions/alerts' ); - const { data: alerts } = useFetcher( - ({ signal }) => { - const { start, end } = getAbsoluteDateRange({ rangeFrom, rangeTo }); + const { data: dynamicIndexPatternResp } = useFetcher(({ signal }) => { + return callObservabilityApi({ + signal, + endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern', + }); + }, []); + + const dynamicIndexPattern = useMemo( + () => (dynamicIndexPatternResp ? [dynamicIndexPatternResp] : []), + [dynamicIndexPatternResp] + ); + + const setStatusFilter = useCallback( + (value: AlertStatus) => { + const nextSearchParams = new URLSearchParams(history.location.search); + nextSearchParams.set('status', value); + history.push({ + ...history.location, + search: nextSearchParams.toString(), + }); + }, + [history] + ); - if (!start || !end) { - return; + const onQueryChange = useCallback( + ({ dateRange, query }) => { + if (rangeFrom === dateRange.from && rangeTo === dateRange.to && kuery === (query ?? '')) { + return refetch.current && refetch.current(); } - return callObservabilityApi({ - signal, - endpoint: 'GET /api/observability/rules/alerts/top', - params: { - query: { - start, - end, - kuery, - status, - }, - }, + const nextSearchParams = new URLSearchParams(history.location.search); + + nextSearchParams.set('rangeFrom', dateRange.from); + nextSearchParams.set('rangeTo', dateRange.to); + nextSearchParams.set('kuery', query ?? ''); + + history.push({ + ...history.location, + search: nextSearchParams.toString(), }); }, - [kuery, rangeFrom, rangeTo, status] + [history, rangeFrom, rangeTo, kuery] ); - function setStatusFilter(value: AlertStatus) { - const nextSearchParams = new URLSearchParams(history.location.search); - nextSearchParams.set('status', value); - history.push({ - ...history.location, - search: nextSearchParams.toString(), - }); - } + const setRefetch = useCallback((ref) => { + refetch.current = ref; + }, []); return ( { - const nextSearchParams = new URLSearchParams(history.location.search); - - nextSearchParams.set('rangeFrom', dateRange.from); - nextSearchParams.set('rangeTo', dateRange.to); - nextSearchParams.set('kuery', query ?? ''); - - history.push({ - ...history.location, - search: nextSearchParams.toString(), - }); - }} + onQueryChange={onQueryChange} /> @@ -162,7 +167,14 @@ export function AlertsPage({ routeParams }: AlertsPageProps) {
- + 0 ? dynamicIndexPattern[0].title : ''} + rangeFrom={rangeFrom} + rangeTo={rangeTo} + kuery={kuery} + status={status} + setRefetch={setRefetch} + />
diff --git a/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx new file mode 100644 index 000000000000..1cd86631197c --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIconTip, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { + ALERT_DURATION, + ALERT_SEVERITY_LEVEL, + ALERT_STATUS, + ALERT_START, + RULE_NAME, +} from '@kbn/rule-data-utils/target/technical_field_names'; + +import type { CellValueElementProps, TimelineNonEcsData } from '../../../../timelines/common'; +import { TimestampTooltip } from '../../components/shared/timestamp_tooltip'; +import { asDuration } from '../../../common/utils/formatters'; +import { SeverityBadge } from './severity_badge'; +import { TopAlert } from '.'; +import { decorateResponse } from './decorate_response'; +import { usePluginContext } from '../../hooks/use_plugin_context'; + +const getMappedNonEcsValue = ({ + data, + fieldName, +}: { + data: TimelineNonEcsData[]; + fieldName: string; +}): string[] | undefined => { + const item = data.find((d) => d.field === fieldName); + if (item != null && item.value != null) { + return item.value; + } + return undefined; +}; + +/** + * This implementation of `EuiDataGrid`'s `renderCellValue` + * accepts `EuiDataGridCellValueElementProps`, plus `data` + * from the TGrid + */ +export const getRenderCellValue = ({ + rangeTo, + rangeFrom, + setFlyoutAlert, +}: { + rangeTo: string; + rangeFrom: string; + setFlyoutAlert: (data: TopAlert) => void; +}) => { + return ({ columnId, data, linkValues }: CellValueElementProps) => { + const { observabilityRuleTypeRegistry } = usePluginContext(); + const value = getMappedNonEcsValue({ + data, + fieldName: columnId, + })?.reduce((x) => x[0]); + + switch (columnId) { + case ALERT_STATUS: + return value !== 'closed' ? ( + + ) : ( + + ); + case ALERT_START: + return ; + case ALERT_DURATION: + return asDuration(Number(value), { extended: true }); + case ALERT_SEVERITY_LEVEL: + return ; + case RULE_NAME: + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + + return ( + setFlyoutAlert && setFlyoutAlert(alert)}>{alert.reason} + ); + default: + return <>{value}; + } + }; +}; diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index b6ed0a0a3d17..8aa184bca913 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -27,6 +27,7 @@ { "path": "../cases/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, { "path": "../rule_registry/tsconfig.json" }, + { "path": "../timelines/tsconfig.json"}, { "path": "../translations/tsconfig.json" } ] } diff --git a/x-pack/plugins/osquery/public/plugin.ts b/x-pack/plugins/osquery/public/plugin.ts index abdea2df3b2b..631f3adba4c4 100644 --- a/x-pack/plugins/osquery/public/plugin.ts +++ b/x-pack/plugins/osquery/public/plugin.ts @@ -21,12 +21,11 @@ import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { OsqueryPluginSetup, OsqueryPluginStart, - // SetupPlugins, StartPlugins, AppPluginStartDependencies, } from './types'; import { OSQUERY_INTEGRATION_NAME, PLUGIN_NAME } from '../common'; -import { epmRouteService, GetPackagesResponse } from '../../fleet/common'; +import { Installation } from '../../fleet/common'; import { LazyOsqueryManagedPolicyCreateImportExtension, LazyOsqueryManagedPolicyEditExtension, @@ -47,12 +46,9 @@ export function toggleOsqueryPlugin( } http - .fetch(epmRouteService.getListPath(), { query: { experimental: true } }) - .then(({ response }) => { - const installed = response.find( - (integration) => - integration?.name === OSQUERY_INTEGRATION_NAME && integration?.status === 'installed' - ); + .fetch(`/internal/osquery/status`) + .then((response) => { + const installed = response?.install_status === 'installed'; if (installed && registerExtension) { registerExtension({ @@ -137,24 +133,26 @@ export class OsqueryPlugin implements Plugin(); + if (!config.enabled) { + return {}; + } + if (plugins.fleet) { const { registerExtension } = plugins.fleet; - if (config.enabled) { - toggleOsqueryPlugin(this.appUpdater$, core.http, registerExtension); + toggleOsqueryPlugin(this.appUpdater$, core.http, registerExtension); - registerExtension({ - package: OSQUERY_INTEGRATION_NAME, - view: 'package-policy-create', - Component: LazyOsqueryManagedPolicyCreateImportExtension, - }); + registerExtension({ + package: OSQUERY_INTEGRATION_NAME, + view: 'package-policy-create', + Component: LazyOsqueryManagedPolicyCreateImportExtension, + }); - registerExtension({ - package: OSQUERY_INTEGRATION_NAME, - view: 'package-policy-edit', - Component: LazyOsqueryManagedPolicyEditExtension, - }); - } + registerExtension({ + package: OSQUERY_INTEGRATION_NAME, + view: 'package-policy-edit', + Component: LazyOsqueryManagedPolicyEditExtension, + }); } else { this.appUpdater$.next(() => ({ status: AppStatus.inaccessible, diff --git a/x-pack/plugins/osquery/server/routes/index.ts b/x-pack/plugins/osquery/server/routes/index.ts index 7eee5b94fd84..dd11141b2553 100644 --- a/x-pack/plugins/osquery/server/routes/index.ts +++ b/x-pack/plugins/osquery/server/routes/index.ts @@ -9,12 +9,14 @@ import { IRouter } from '../../../../../src/core/server'; import { initActionRoutes } from './action'; import { OsqueryAppContext } from '../lib/osquery_app_context_services'; import { initSavedQueryRoutes } from './saved_query'; +import { initStatusRoutes } from './status'; import { initPackRoutes } from './pack'; export const defineRoutes = (router: IRouter, context: OsqueryAppContext) => { const config = context.config(); initActionRoutes(router, context); + initStatusRoutes(router, context); if (config.packs) { initPackRoutes(router); diff --git a/x-pack/plugins/osquery/server/routes/status/create_status_route.ts b/x-pack/plugins/osquery/server/routes/status/create_status_route.ts new file mode 100644 index 000000000000..d7ea49c6152c --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/status/create_status_route.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { OSQUERY_INTEGRATION_NAME } from '../../../common'; +import { IRouter } from '../../../../../../src/core/server'; +import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; + +export const createStatusRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { + router.get( + { + path: '/internal/osquery/status', + validate: false, + }, + async (context, request, response) => { + const soClient = context.core.savedObjects.client; + const isSuperUser = osqueryContext.security.authc + .getCurrentUser(request) + ?.roles.includes('superuser'); + + if (!isSuperUser) { + return response.ok({ body: undefined }); + } + + const packageInfo = await osqueryContext.service + .getPackageService() + ?.getInstallation({ savedObjectsClient: soClient, pkgName: OSQUERY_INTEGRATION_NAME }); + + return response.ok({ body: packageInfo }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/status/index.ts b/x-pack/plugins/osquery/server/routes/status/index.ts new file mode 100644 index 000000000000..9e85613165b1 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/status/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from '../../../../../../src/core/server'; +import { createStatusRoute } from './create_status_route'; +import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; + +export const initStatusRoutes = (router: IRouter, context: OsqueryAppContext) => { + createStatusRoute(router, context); +}; diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 6efaf42a5ad1..193f3f2a971e 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -65,6 +65,7 @@ export interface ReportSource { objectType: string; title: string; layout?: LayoutParams; + isDeprecated?: boolean; }; meta: { objectType: string; layout?: string }; browser_type: string; @@ -128,6 +129,7 @@ export interface ReportApiJSON { layout?: LayoutParams; title: string; browserTimezone?: string; + isDeprecated?: boolean; }; meta: { layout?: string; diff --git a/x-pack/plugins/reporting/server/export_types/csv/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv/create_job.ts index 876d190c9eee..56d6facea921 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/create_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/create_job.ts @@ -20,6 +20,9 @@ export const createJobFnFactory: CreateJobFnFactory< const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJob(jobParams, context, request) { + logger.warn( + `The "/generate/csv" endpoint is deprecated and will be removed in Kibana 8.0. Please recreate the POST URL used to automate this CSV export.` + ); const serializedEncryptedHeaders = await crypto.encrypt(request.headers); const savedObjectsClient = context.core.savedObjects.client; @@ -29,6 +32,7 @@ export const createJobFnFactory: CreateJobFnFactory< )) as unknown) as IndexPatternSavedObjectDeprecatedCSV; return { + isDeprecated: true, headers: serializedEncryptedHeaders, spaceId: reporting.getSpaceId(request, logger), indexPatternSavedObject, diff --git a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts index 1db62f818216..2fec34470ff1 100644 --- a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts +++ b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { UnwrapPromise } from '@kbn/utility-types'; -import { i18n } from '@kbn/i18n'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; +import { i18n } from '@kbn/i18n'; +import { UnwrapPromise } from '@kbn/utility-types'; import { ElasticsearchClient } from 'src/core/server'; import { ReportingCore } from '../../'; import { ReportDocument } from '../../lib/store'; @@ -87,6 +87,7 @@ export function jobsQueryFactory(reportingCore: ReportingCore) { elasticsearchClient.search({ body, index: getIndex() }) ); + // FIXME: return the info in ReportApiJSON format; return response?.body.hits?.hits ?? []; }, @@ -139,6 +140,7 @@ export function jobsQueryFactory(reportingCore: ReportingCore) { return; } + // FIXME: return the info in ReportApiJSON format; return response.body.hits.hits[0] as ReportDocument; }, diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index 7df1dce597d5..da228b09f79d 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -62,6 +62,7 @@ export { BaseParams }; export interface BasePayload extends BaseParams { headers: string; spaceId?: string; + isDeprecated?: boolean; } // default fn type for CreateJobFnFactory diff --git a/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts index 039424d34bfa..fe3504c84115 100644 --- a/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts +++ b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts @@ -6,22 +6,56 @@ */ import { Optional } from 'utility-types'; import { mapValues, pickBy } from 'lodash'; +import { either } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; import { FieldMap } from './types'; +const NumberFromString = new t.Type( + 'NumberFromString', + (u): u is number => typeof u === 'number', + (u, c) => + either.chain(t.string.validate(u, c), (s) => { + const d = Number(s); + return isNaN(d) ? t.failure(u, c) : t.success(d); + }), + (a) => a +); + +const BooleanFromString = new t.Type( + 'BooleanFromString', + (u): u is boolean => typeof u === 'boolean', + (u, c) => + either.chain(t.string.validate(u, c), (s) => { + switch (s.toLowerCase().trim()) { + case '1': + case 'true': + case 'yes': + return t.success(true); + case '0': + case 'false': + case 'no': + case null: + return t.success(false); + default: + return t.failure(u, c); + } + }), + (a) => a +); + const esFieldTypeMap = { keyword: t.string, text: t.string, date: t.string, - boolean: t.boolean, - byte: t.number, - long: t.number, - integer: t.number, - short: t.number, - double: t.number, - float: t.number, - scaled_float: t.number, - unsigned_long: t.number, + boolean: t.union([t.number, BooleanFromString]), + byte: t.union([t.number, NumberFromString]), + long: t.union([t.number, NumberFromString]), + integer: t.union([t.number, NumberFromString]), + short: t.union([t.number, NumberFromString]), + double: t.union([t.number, NumberFromString]), + float: t.union([t.number, NumberFromString]), + scaled_float: t.union([t.number, NumberFromString]), + unsigned_long: t.union([t.number, NumberFromString]), flattened: t.record(t.string, t.array(t.string)), }; diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/index.ts b/x-pack/plugins/rule_registry/server/rule_data_client/index.ts index ffc926fc74b5..a9e559a6b193 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/index.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/index.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { isEmpty } from 'lodash'; -import type { estypes } from '@elastic/elasticsearch'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { IndexPatternsFetcher } from '../../../../../src/plugins/data/server'; import { RuleDataWriteDisabledError } from '../rule_data_plugin_service/errors'; @@ -100,7 +98,7 @@ export class RuleDataClient implements IRuleDataClient { response.body.items.length > 0 && response.body.items?.[0]?.index?.error?.type === 'index_not_found_exception' ) { - return this.createOrUpdateWriteTarget({ namespace }).then(() => { + return this.createWriteTargetIfNeeded({ namespace }).then(() => { return clusterClient.bulk(requestWithDefaultParameters); }); } @@ -113,7 +111,7 @@ export class RuleDataClient implements IRuleDataClient { }; } - async createOrUpdateWriteTarget({ namespace }: { namespace?: string }) { + async createWriteTargetIfNeeded({ namespace }: { namespace?: string }) { const alias = getNamespacedAlias({ alias: this.options.alias, namespace }); const clusterClient = await this.getClusterClient(); @@ -138,25 +136,10 @@ export class RuleDataClient implements IRuleDataClient { }); } catch (err) { // something might have created the index already, that sounds OK - if (err?.meta?.body?.type !== 'resource_already_exists_exception') { + if (err?.meta?.body?.error?.type !== 'resource_already_exists_exception') { throw err; } } } - - const { body: simulateResponse } = await clusterClient.transport.request({ - method: 'POST', - path: `/_index_template/_simulate_index/${concreteIndexName}`, - }); - - const mappings: estypes.MappingTypeMapping = simulateResponse.template.mappings; - - if (isEmpty(mappings)) { - throw new Error( - 'No mappings would be generated for this index, possibly due to failed/misconfigured bootstrapping' - ); - } - - await clusterClient.indices.putMapping({ index: `${alias}*`, body: mappings }); } } diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/types.ts b/x-pack/plugins/rule_registry/server/rule_data_client/types.ts index 46a37abcd1ff..3b90079ec523 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/types.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/types.ts @@ -34,7 +34,7 @@ export interface RuleDataWriter { export interface IRuleDataClient { getReader(options?: { namespace?: string }): RuleDataReader; getWriter(options?: { namespace?: string }): RuleDataWriter; - createOrUpdateWriteTarget(options: { namespace?: string }): Promise; + createWriteTargetIfNeeded(options: { namespace?: string }): Promise; } export interface RuleDataClientConstructorOptions { diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts index abb56f3102a4..33ff5281147e 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts @@ -7,6 +7,7 @@ import { ClusterPutComponentTemplate } from '@elastic/elasticsearch/api/requestParams'; import { estypes } from '@elastic/elasticsearch'; import { ElasticsearchClient, Logger } from 'kibana/server'; +import { get, isEmpty } from 'lodash'; import { technicalComponentTemplate } from '../../common/assets/component_templates/technical_component_template'; import { DEFAULT_ILM_POLICY_ID, @@ -18,6 +19,7 @@ import { defaultLifecyclePolicy } from '../../common/assets/lifecycle_policies/d import { ClusterPutComponentTemplateBody, PutIndexTemplateRequest } from '../../common/types'; import { RuleDataClient } from '../rule_data_client'; import { RuleDataWriteDisabledError } from './errors'; +import { incrementIndexName } from './utils'; const BOOTSTRAP_TIMEOUT = 60000; @@ -109,6 +111,14 @@ export class RuleDataPluginService { const clusterClient = await this.getClusterClient(); this.options.logger.debug(`Installing index template ${template.name}`); + const { body: simulateResponse } = await clusterClient.indices.simulateTemplate(template); + const mappings: estypes.MappingTypeMapping = simulateResponse.template.mappings; + + if (isEmpty(mappings)) { + throw new Error( + 'No mappings would be generated for this index, possibly due to failed/misconfigured bootstrapping' + ); + } return clusterClient.indices.putIndexTemplate(template); } @@ -120,6 +130,42 @@ export class RuleDataPluginService { return clusterClient.ilm.putLifecycle(policy); } + private async updateAliasWriteIndexMapping({ index, alias }: { index: string; alias: string }) { + const clusterClient = await this.getClusterClient(); + + const simulatedIndexMapping = await clusterClient.indices.simulateIndexTemplate({ + name: index, + }); + const simulatedMapping = get(simulatedIndexMapping, ['body', 'template', 'mappings']); + try { + await clusterClient.indices.putMapping({ + index, + body: simulatedMapping, + }); + return; + } catch (err) { + if (err.meta?.body?.error?.type !== 'illegal_argument_exception') { + this.options.logger.error(`Failed to PUT mapping for alias ${alias}: ${err.message}`); + return; + } + const newIndexName = incrementIndexName(index); + if (newIndexName == null) { + this.options.logger.error(`Failed to increment write index name for alias: ${alias}`); + return; + } + try { + await clusterClient.indices.rollover({ + alias, + new_index: newIndexName, + }); + } catch (e) { + if (e?.meta?.body?.error?.type !== 'resource_already_exists_exception') { + this.options.logger.error(`Failed to rollover index for alias ${alias}: ${e.message}`); + } + } + } + } + async createOrUpdateComponentTemplate( template: ClusterPutComponentTemplate ) { @@ -137,6 +183,25 @@ export class RuleDataPluginService { return this._createOrUpdateLifecyclePolicy(policy); } + async updateIndexMappingsMatchingPattern(pattern: string) { + await this.wait(); + const clusterClient = await this.getClusterClient(); + const { body: aliasesResponse } = await clusterClient.indices.getAlias({ index: pattern }); + const writeIndicesAndAliases: Array<{ index: string; alias: string }> = []; + Object.entries(aliasesResponse).forEach(([index, aliases]) => { + Object.entries(aliases.aliases).forEach(([aliasName, aliasProperties]) => { + if (aliasProperties.is_write_index) { + writeIndicesAndAliases.push({ index, alias: aliasName }); + } + }); + }); + await Promise.all( + writeIndicesAndAliases.map((indexAndAlias) => + this.updateAliasWriteIndexMapping(indexAndAlias) + ) + ); + } + isReady() { return this.signal.isReady(); } diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/utils.test.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/utils.test.ts new file mode 100644 index 000000000000..37212c46d46a --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/utils.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { incrementIndexName } from './utils'; + +describe('incrementIndexName', () => { + it('should increment 000001 to 000002', () => { + const oldIndex = '.alerts-mock-000001'; + const newIndex = incrementIndexName(oldIndex); + expect(newIndex).toEqual('.alerts-mock-000002'); + }); + + it('should increment 000010 to 000011', () => { + const oldIndex = '.alerts-mock-000010'; + const newIndex = incrementIndexName(oldIndex); + expect(newIndex).toEqual('.alerts-mock-000011'); + }); + + it('should return undefined if oldIndex does not end in a number', () => { + const oldIndex = '.alerts-mock-string'; + const newIndex = incrementIndexName(oldIndex); + expect(newIndex).toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/utils.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/utils.ts new file mode 100644 index 000000000000..aaab338ff858 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/utils.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function incrementIndexName(oldIndex: string) { + const baseIndexString = oldIndex.slice(0, -6); + const newIndexNumber = Number(oldIndex.slice(-6)) + 1; + if (isNaN(newIndexNumber)) { + return undefined; + } + return baseIndexString + String(newIndexNumber).padStart(6, '0'); +} diff --git a/x-pack/plugins/security_solution/cypress/README.md b/x-pack/plugins/security_solution/cypress/README.md index 0713716a15d5..1b486ca3a5fc 100644 --- a/x-pack/plugins/security_solution/cypress/README.md +++ b/x-pack/plugins/security_solution/cypress/README.md @@ -115,8 +115,42 @@ cd x-pack/plugins/security_solution CYPRESS_BASE_URL=http(s)://:@ CYPRESS_ELASTICSEARCH_URL=http(s)://:@ CYPRESS_ELASTICSEARCH_USERNAME= CYPRESS_ELASTICSEARCH_PASSWORD=password yarn cypress:run:firefox ``` +#### CCS Custom Target + Headless + +This test execution requires two clusters configured for CCS. See [Search across clusters](https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-cross-cluster-search.html) for instructions on how to prepare such setup. + +The instructions below assume: +* Search cluster is on server1 +* Remote cluster is on server2 +* Remote cluster is accessible from the search cluster with name `remote` +* Security and TLS are enabled + +```shell +# bootstrap Kibana from the project root +yarn kbn bootstrap + +# launch the Cypress test runner with overridden environment variables +cd x-pack/plugins/security_solution +CYPRESS_ELASTICSEARCH_USERNAME="user" \ +CYPRESS_ELASTICSEARCH_PASSWORD="pass" \ +CYPRESS_BASE_URL="https://user:pass@server1:5601" \ +CYPRESS_ELASTICSEARCH_URL="https://user:pass@server1:9200" \ +CYPRESS_CCS_KIBANA_URL="https://user:pass@server2:5601" \ +CYPRESS_CCS_ELASTICSEARCH_URL="https://user:pass@server2:9200" \ +CYPRESS_CCS_REMOTE_NAME="remote" \ +yarn cypress:run:ccs +``` + +Similar sequence, just ending with `yarn cypress:open:ccs`, can be used for interactive test running via Cypress UI. + +Appending `--browser firefox` to the `yarn cypress:run:ccs` command above will run the tests on Firefox instead of Chrome. + ## Folder Structure +### ccs_integration/ + +Contains the specs that are executed in a Cross Cluster Search configuration, typically during integration tests. + ### integration/ Cypress convention. Contains the specs that are going to be executed. @@ -208,6 +242,44 @@ Because of `cy.exec`, used to invoke `es_archiver`, it's necessary to override i > Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes TLS connections and HTTPS requests insecure by disabling certificate verification. +### CCS + +Tests running in CCS configuration need to care about two aspects: + +1. data (eg. to trigger alerts) is generated/loaded on the remote cluster +2. queries (eg. detection rules) refer to remote indices + +Incorrect handling of the above points might result in false positives, in that the remote cluster is not involved but the test passes anyway. + +#### Remote data loading + +Helpers `esArchiverCCSLoad` and `esArchiverCCSUnload` are provided by [cypress/tasks/es_archiver.ts](https://github.com/elastic/kibana/blob/master/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts): + +```javascript +import { esArchiverCCSLoad, esArchiverCCSUnload } from '../../tasks/es_archiver'; +``` + +They will use the `CYPRESS_CCS_*_URL` environment variables for accessing the remote cluster. Complex tests involving local and remote data can interleave them with `esArchiverLoad` and `esArchiverUnload` as needed. + +#### Remote indices queries + +Queries accessing remote indices follow the usual `:` notation but should not hard-code the remote name in the test itself. + +For such reason the environemnt variable `CYPRESS_CCS_REMOTE_NAME` is defined and, in the case of detection rules, used as shown below: + +```javascript +const ccsRemoteName: string = Cypress.env('CCS_REMOTE_NAME'); + +export const unmappedCCSRule: CustomRule = { + customQuery: '*:*', + index: [`${ccsRemoteName}:unmapped*`], + ... +}; + +``` + +Similar approach should be used in defining all index patterns, rules, and queries to be applied on remote data. + ## Development Best Practices ### Clean up the state diff --git a/x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts b/x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts new file mode 100644 index 000000000000..f87399a66690 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CELL_TEXT, JSON_LINES, TABLE_ROWS } from '../../screens/alerts_details'; + +import { + expandFirstAlert, + waitForAlertsIndexToBeCreated, + waitForAlertsPanelToBeLoaded, +} from '../../tasks/alerts'; +import { openJsonView, openTable, scrollJsonViewToBottom } from '../../tasks/alerts_details'; +import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; +import { cleanKibana } from '../../tasks/common'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { esArchiverCCSLoad, esArchiverCCSUnload } from '../../tasks/es_archiver'; + +import { unmappedCCSRule } from '../../objects/rule'; + +import { ALERTS_URL } from '../../urls/navigation'; + +describe('Alert details with unmapped fields', () => { + beforeEach(() => { + cleanKibana(); + esArchiverCCSLoad('unmapped_fields'); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + createCustomRuleActivated(unmappedCCSRule); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + waitForAlertsPanelToBeLoaded(); + expandFirstAlert(); + }); + + afterEach(() => { + esArchiverCCSUnload('unmapped_fields'); + }); + + it('Displays the unmapped field on the JSON view', () => { + const expectedUnmappedField = { line: 2, text: ' "unmapped": "This is the unmapped field"' }; + + openJsonView(); + scrollJsonViewToBottom(); + + cy.get(JSON_LINES).then((elements) => { + const length = elements.length; + cy.wrap(elements) + .eq(length - expectedUnmappedField.line) + .should('have.text', expectedUnmappedField.text); + }); + }); + + it('Displays the unmapped field on the table', () => { + const expectedUnmmappedField = { + row: 55, + field: 'unmapped', + text: 'This is the unmapped field', + }; + + openTable(); + + cy.get(TABLE_ROWS) + .eq(expectedUnmmappedField.row) + .within(() => { + cy.get(CELL_TEXT).eq(0).should('have.text', expectedUnmmappedField.field); + cy.get(CELL_TEXT).eq(1).should('have.text', expectedUnmmappedField.text); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.ts new file mode 100644 index 000000000000..fdc4bce677f7 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { newRule } from '../../objects/rule'; +import { RULES_MONIROTING_TABLE, RULE_NAME } from '../../screens/alerts_detection_rules'; +import { goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; +import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; +import { cleanKibana, reload } from '../../tasks/common'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { ALERTS_URL } from '../../urls/navigation'; + +describe('Rules talbes links', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + goToManageAlertsDetectionRules(); + waitForAlertsIndexToBeCreated(); + createCustomRuleActivated(newRule, 'rule1'); + + reload(); + }); + + it('should render correct link for rule name - rules', () => { + cy.get(RULE_NAME).first().click(); + cy.url().should('contain', 'rules/id/'); + }); + + it('should render correct link for rule name - rule monitoring', () => { + cy.get(RULES_MONIROTING_TABLE).first().click(); + cy.get(RULE_NAME).first().click(); + cy.url().should('contain', 'rules/id/'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts index c3e04aaaf6a1..9986d9d2afbd 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts @@ -63,8 +63,9 @@ describe('Row renderers', () => { cy.get(TIMELINE_ROW_RENDERERS_SEARCHBOX).should('exist'); cy.get(TIMELINE_ROW_RENDERERS_SEARCHBOX).type('flow'); - cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).first().uncheck(); + // Intercepts should be before click handlers that activate them rather than afterwards or you have race conditions cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); + cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).first().uncheck(); cy.wait('@updateTimeline').then((interception) => { expect(interception.request.body.timeline.excludedRowRendererIds).to.contain('netflow'); @@ -84,6 +85,9 @@ describe('Row renderers', () => { cy.get(TIMELINE_ROW_RENDERERS_DISABLE_ALL_BTN).should('exist'); cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).should('be.checked'); + // Intercepts should be before click handlers that activate them rather than afterwards or you have race conditions + cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); + // Keep clicking on the disable all button until the first element of all the elements are no longer checked. // In cases where the click handler is not present on the page just yet, this will cause the button to be clicked // multiple times until it sees that the click took effect. You could go through the whole list but I just check @@ -95,7 +99,6 @@ describe('Row renderers', () => { }) .should('not.be.checked'); - cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); cy.wait('@updateTimeline').its('response.statusCode').should('eq', 200); cy.wait('@updateTimeline').then((interception) => { diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 9a8626f2a0d7..3383ef4996ea 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -16,6 +16,8 @@ export const totalNumberOfPrebuiltRulesInEsArchive = 127; export const totalNumberOfPrebuiltRulesInEsArchiveCustomRule = 145; +const ccsRemoteName: string = Cypress.env('CCS_REMOTE_NAME'); + interface MitreAttackTechnique { name: string; subtechniques: string[]; @@ -198,6 +200,24 @@ export const unmappedRule: CustomRule = { maxSignals: 100, }; +export const unmappedCCSRule: CustomRule = { + customQuery: '*:*', + index: [`${ccsRemoteName}:unmapped*`], + name: 'Rule with unmapped fields', + description: 'The new rule description.', + severity: 'High', + riskScore: '17', + tags: ['test', 'newRule'], + referenceUrls: ['http://example.com/', 'https://example.com/'], + falsePositivesExamples: ['False1', 'False2'], + mitre: [mitre1, mitre2], + note: '# test markdown', + runsEvery, + lookBack, + timeline, + maxSignals: 100, +}; + export const existingRule: CustomRule = { customQuery: 'host.name: *', name: 'Rule 1', diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index ba071184d98e..0bf0e5a09e32 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -72,6 +72,8 @@ export const RULES_TABLE = '[data-test-subj="rules-table"]'; export const RULES_ROW = '.euiTableRow'; +export const RULES_MONIROTING_TABLE = '[data-test-subj="allRulesTableTab-monitoring"]'; + export const SEVENTH_RULE = 6; export const SEVERITY = '[data-test-subj="severity"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts index 94ac8003c0d8..83ec1536baf0 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts @@ -11,6 +11,8 @@ const ES_ARCHIVE_DIR = '../../test/security_solution_cypress/es_archives'; const CONFIG_PATH = '../../test/functional/config.js'; const ES_URL = Cypress.env('ELASTICSEARCH_URL'); const KIBANA_URL = Cypress.config().baseUrl; +const CCS_ES_URL = Cypress.env('CCS_ELASTICSEARCH_URL'); +const CCS_KIBANA_URL = Cypress.env('CCS_KIBANA_URL'); // Otherwise cy.exec would inject NODE_TLS_REJECT_UNAUTHORIZED=0 and node would abort if used over https const NODE_TLS_REJECT_UNAUTHORIZED = '1'; @@ -37,3 +39,19 @@ export const esArchiverResetKibana = () => { { env: { NODE_TLS_REJECT_UNAUTHORIZED }, failOnNonZeroExit: false } ); }; + +export const esArchiverCCSLoad = (folder: string) => { + const path = Path.join(ES_ARCHIVE_DIR, folder); + cy.exec( + `node ../../../scripts/es_archiver load "${path}" --config "${CONFIG_PATH}" --es-url "${CCS_ES_URL}" --kibana-url "${CCS_KIBANA_URL}"`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } + ); +}; + +export const esArchiverCCSUnload = (folder: string) => { + const path = Path.join(ES_ARCHIVE_DIR, folder); + cy.exec( + `node ../../../scripts/es_archiver unload "${path}" --config "${CONFIG_PATH}" --es-url "${CCS_ES_URL}" --kibana-url "${CCS_KIBANA_URL}"`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } + ); +}; diff --git a/x-pack/plugins/security_solution/cypress/upgrade_integration/custom_query_rule.spec.ts b/x-pack/plugins/security_solution/cypress/upgrade_integration/custom_query_rule.spec.ts new file mode 100644 index 000000000000..2718c0735a67 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/upgrade_integration/custom_query_rule.spec.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RULE_NAME } from '../screens/alerts_detection_rules'; + +import { + goToManageAlertsDetectionRules, + waitForAlertsIndexToBeCreated, + waitForAlertsPanelToBeLoaded, +} from '../tasks/alerts'; +import { waitForRulesTableToBeLoaded } from '../tasks/alerts_detection_rules'; +import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; + +import { ALERTS_URL } from '../urls/navigation'; + +describe('After an upgrade, the cusom query rule', () => { + it('Displays the rule', function () { + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + goToManageAlertsDetectionRules(); + waitForRulesTableToBeLoaded(); + cy.get(RULE_NAME).should('have.text', 'Custom query rule for upgrade'); + }); +}); diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index 104c6120ecb3..3a0eb1a5458a 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -9,12 +9,15 @@ "build-beat-doc": "node scripts/beat_docs/build.js && node ../../../scripts/eslint ./server/utils/beat_schema/fields.ts --fix", "cypress": "../../../node_modules/.bin/cypress", "cypress:open": "yarn cypress open --config-file ./cypress/cypress.json", + "cypress:open:ccs": "yarn cypress:open --config integrationFolder=./cypress/ccs_integration", "cypress:open-as-ci": "node ../../../scripts/functional_tests --config ../../test/security_solution_cypress/visual_config.ts", "cypress:run": "yarn cypress:run:reporter --browser chrome --headless --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:firefox": "yarn cypress:run:reporter --browser firefox --headless --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:reporter": "yarn cypress run --config-file ./cypress/cypress.json --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json", + "cypress:run:ccs": "yarn cypress:run:reporter --browser chrome --headless --config integrationFolder=./cypress/ccs_integration", "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config.ts", "cypress:run-as-ci:firefox": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/config.firefox.ts", + "cypress:run:upgrade": "yarn cypress:run:reporter --browser chrome --headless --config integrationFolder=./cypress/upgrade_integration", "junit:merge": "../../../node_modules/.bin/mochawesome-merge ../../../target/kibana-security-solution/cypress/results/mochawesome*.json > ../../../target/kibana-security-solution/cypress/results/output.json && ../../../node_modules/.bin/marge ../../../target/kibana-security-solution/cypress/results/output.json --reportDir ../../../target/kibana-security-solution/cypress/results && mkdir -p ../../../target/junit && cp ../../../target/kibana-security-solution/cypress/results/*.xml ../../../target/junit/", "test:generate": "node scripts/endpoint/resolver_generator" } diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx index a5e0c90402df..ebd25eef87cb 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx @@ -9,8 +9,6 @@ import React, { memo, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { AppLocation } from '../../../../common/endpoint/types'; -import { timelineActions } from '../../../timelines/store/timeline'; -import { TimelineId } from '../../../../../timelines/common'; /** * This component should be used above all routes, but below the Provider. @@ -20,10 +18,6 @@ export const RouteCapture = memo(({ children }) => { const location: AppLocation = useLocation(); const dispatch = useDispatch(); - useEffect(() => { - dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: false })); - }, [dispatch, location.pathname]); - useEffect(() => { dispatch({ type: 'userChangedUrl', payload: location }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx index be45e16e456d..3a1a29b63ead 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx @@ -42,6 +42,7 @@ import { endpointAlertCheck } from '../../utils/endpoint_alert_check'; const StyledEuiDescriptionList = styled(EuiDescriptionList)` padding: 24px 4px 4px; + word-break: break-word; `; const fields = [ diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 90a4e67d76b9..ccba97f6a794 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -179,7 +179,7 @@ describe('EventsViewer', () => { mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]); }); - test('it renders the "Showing..." subtitle with the expected event count', () => { + test('it renders the "Showing..." subtitle with the expected event count by default', () => { const wrapper = mount( @@ -190,6 +190,19 @@ describe('EventsViewer', () => { ); }); + test('should not render the "Showing..." subtitle with the expected event count if showTotalCount is set to false ', () => { + const disableSubTitle = { + ...eventsViewerDefaultProps, + showTotalCount: false, + }; + const wrapper = mount( + + + + ); + expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().text()).toEqual(''); + }); + test('it renders the Fields Browser as a settings gear', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index c2f170c58043..b8b6b9766bdd 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -135,6 +135,7 @@ interface Props { rowRenderers: RowRenderer[]; start: string; sort: Sort[]; + showTotalCount?: boolean; utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; // If truthy, the graph viewer (Resolver) is showing graphEventId: string | undefined; @@ -163,6 +164,7 @@ const EventsViewerComponent: React.FC = ({ rowRenderers, start, sort, + showTotalCount = true, utilityBar, graphEventId, }) => { @@ -253,8 +255,12 @@ const EventsViewerComponent: React.FC = ({ const subtitle = useMemo( () => - `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit(totalCountMinusDeleted)}`, - [totalCountMinusDeleted, unit] + showTotalCount + ? `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit( + totalCountMinusDeleted + )}` + : null, + [showTotalCount, totalCountMinusDeleted, unit] ); const nonDeletedEvents = useMemo(() => events.filter((e) => !deletedEventIds.includes(e._id)), [ diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 32aa716d4bce..bfc14a0f0c68 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -10,6 +10,7 @@ import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import styled from 'styled-components'; +import { isEmpty } from 'lodash/fp'; import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; import { ControlColumnProps, RowRenderer, TimelineId } from '../../../../common/types/timeline'; @@ -40,6 +41,7 @@ export interface OwnProps { id: TimelineId; scopeId: SourcererScopeName; start: string; + showTotalCount?: boolean; headerFilterGroup?: React.ReactNode; pageFilters?: Filter[]; onRuleChange?: () => void; @@ -176,6 +178,7 @@ const StatefulEventsViewerComponent: React.FC = ({ rowRenderers={rowRenderers} start={start} sort={sort} + showTotalCount={isEmpty(graphEventId) ? true : false} utilityBar={utilityBar} graphEventId={graphEventId} /> diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx index b40799895e8a..18b99adca3a5 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx @@ -59,6 +59,14 @@ jest.mock('../../lib/kibana', () => ({ }, })); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => jest.fn(), + }; +}); + describe('UrlStateContainer', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx index e178aba188d1..3175656f1207 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx @@ -31,6 +31,14 @@ jest.mock('../../lib/kibana', () => ({ }), })); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => jest.fn(), + }; +}); + describe('UrlStateContainer - lodash.throttle mocked to test update url', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx index 487463dfd9d7..87e17ba7691c 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx @@ -9,6 +9,7 @@ import { difference, isEmpty } from 'lodash/fp'; import { useEffect, useRef, useState } from 'react'; import deepEqual from 'fast-deep-equal'; +import { useDispatch } from 'react-redux'; import { useKibana } from '../../lib/kibana'; import { CONSTANTS, UrlStateType } from './constants'; import { @@ -31,6 +32,8 @@ import { UrlState, } from './types'; import { TimelineUrl } from '../../../timelines/store/timeline/model'; +import { timelineActions } from '../../../timelines/store/timeline'; +import { TimelineId } from '../../../../../timelines/common'; function usePrevious(value: PreviousLocationUrlState) { const ref = useRef(value); @@ -71,6 +74,7 @@ export const useUrlStateHooks = ({ const [isInitializing, setIsInitializing] = useState(true); const { filterManager, savedQueries } = useKibana().services.data.query; const prevProps = usePrevious({ pathName, pageName, urlState }); + const dispatch = useDispatch(); const handleInitialize = (type: UrlStateType, needUpdate?: boolean) => { let mySearch = search; @@ -222,9 +226,10 @@ export const useUrlStateHooks = ({ }); } else if (pathName !== prevProps.pathName) { handleInitialize(type, isDetectionsPages(pageName)); + dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: false })); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isInitializing, history, pathName, pageName, prevProps, urlState]); + }, [isInitializing, history, pathName, pageName, prevProps, urlState, dispatch]); useEffect(() => { document.title = `${getTitle(pageName, detailName, navTabs)} - Kibana`; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index 8d0492267258..c6145a70ec8d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -313,7 +313,7 @@ export const getColumns = ({ }; export const getMonitoringColumns = ( - history: H.History, + navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise, formatUrl: FormatUrl ): RulesStatusesColumns[] => { const cols: RulesStatusesColumns[] = [ @@ -326,7 +326,10 @@ export const getMonitoringColumns = ( data-test-subj="ruleName" onClick={(ev: { preventDefault: () => void }) => { ev.preventDefault(); - history.push(getRuleDetailsUrl(item.id)); + navigateToApp(APP_ID, { + deepLinkId: SecurityPageName.rules, + path: getRuleDetailsUrl(item.id), + }); }} href={formatUrl(getRuleDetailsUrl(item.id))} > diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx index 77ca5be0c0ac..22281fa2c868 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -300,8 +300,8 @@ export const RulesTables = React.memo( reFetchRules, ]); - const monitoringColumns = useMemo(() => getMonitoringColumns(history, formatUrl), [ - history, + const monitoringColumns = useMemo(() => getMonitoringColumns(navigateToApp, formatUrl), [ + navigateToApp, formatUrl, ]); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx index 973dbc41925d..86bd8b5f47b0 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx @@ -65,6 +65,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ }) => { const dispatch = useDispatch(); const { globalFullScreen } = useGlobalFullScreen(); + useEffect(() => { dispatch( timelineActions.initializeTGridSettings({ diff --git a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx index 77806fbc9267..a3b1597377fa 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx @@ -86,7 +86,7 @@ const PolicyEmptyState = React.memo<{ diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx index d448b7644cc2..9ad2549c8564 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx @@ -29,14 +29,14 @@ export const EventFiltersListEmptyState = memo<{

} body={ } actions={ @@ -48,7 +48,7 @@ export const EventFiltersListEmptyState = memo<{ > } diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx index c45741c1520b..9f81d2552052 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx @@ -94,12 +94,12 @@ export const EventFiltersFlyout: React.FC = memo( {id ? ( ) : ( )} diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx index 1f3b721fd51e..2d608bdc6e15 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx @@ -211,7 +211,7 @@ export const EventFiltersListPage = memo(() => { > ) diff --git a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx index 6a00afde7c59..715663b60c7d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx @@ -35,7 +35,7 @@ export const EndpointNotice = memo<{ onDismiss: () => void }>(({ onDismiss }) => } @@ -51,7 +51,7 @@ export const EndpointNotice = memo<{ onDismiss: () => void }>(({ onDismiss }) => diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx index 5f7be2ac2e6b..304e8b99135d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx @@ -6,12 +6,14 @@ */ import React from 'react'; -import { ThreatIntelLinkPanelProps } from './index'; +import { ThreatIntelLinkPanelProps } from '.'; import { useCtiEventCounts } from '../../containers/overview_cti_links/use_cti_event_counts'; import { CtiNoEvents } from './cti_no_events'; import { CtiWithEvents } from './cti_with_events'; -export const CtiEnabledModuleComponent: React.FC = (props) => { +export type CtiEnabledModuleProps = Omit; + +export const CtiEnabledModuleComponent: React.FC = (props) => { const { eventCountsByDataset, totalCount } = useCtiEventCounts(props); const { to, from } = props; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_no_events.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_no_events.tsx index 3adccb4f4e3f..9792b5044eab 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_no_events.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_no_events.tsx @@ -23,7 +23,7 @@ const warning = ( export const CtiNoEventsComponent = ({ to, from }: { to: string; from: string }) => { const { buttonHref, listItems, isDashboardPluginDisabled } = useCtiDashboardLinks( - { ...emptyEventCountsByDataset }, + emptyEventCountsByDataset, to, from ); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_with_events.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_with_events.tsx index f9640e9a232f..b2f7c7d761d2 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_with_events.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_with_events.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { isEqual } from 'lodash'; import { useCtiDashboardLinks } from '../../containers/overview_cti_links'; import { ThreatIntelPanelView } from './threat_intel_panel_view'; @@ -38,4 +39,11 @@ export const CtiWithEventsComponent = ({ CtiWithEventsComponent.displayName = 'CtiWithEvents'; -export const CtiWithEvents = React.memo(CtiWithEventsComponent); +export const CtiWithEvents = React.memo( + CtiWithEventsComponent, + (prevProps, nextProps) => + prevProps.to === nextProps.to && + prevProps.from === nextProps.from && + prevProps.totalCount === nextProps.totalCount && + isEqual(prevProps.eventCountsByDataset, nextProps.eventCountsByDataset) +); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.test.tsx index ca3d0ddde401..56bd7c0c0dd0 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.test.tsx @@ -20,14 +20,9 @@ import { SUB_PLUGINS_REDUCER, } from '../../../common/mock'; import { mockTheme, mockProps } from './mock'; -import { useIsThreatIntelModuleEnabled } from '../../containers/overview_cti_links/use_is_threat_intel_module_enabled'; jest.mock('../../../common/lib/kibana'); -jest.mock('../../containers/overview_cti_links/use_is_threat_intel_module_enabled'); -const useIsThreatIntelModuleEnabledMock = useIsThreatIntelModuleEnabled as jest.Mock; -useIsThreatIntelModuleEnabledMock.mockReturnValue(true); - describe('ThreatIntelLinkPanel', () => { const state: State = mockGlobalState; @@ -44,7 +39,7 @@ describe('ThreatIntelLinkPanel', () => { - + @@ -54,12 +49,11 @@ describe('ThreatIntelLinkPanel', () => { }); it('renders CtiDisabledModule when Threat Intel module is disabled', () => { - useIsThreatIntelModuleEnabledMock.mockReturnValueOnce(false); const wrapper = mount( - + @@ -69,12 +63,11 @@ describe('ThreatIntelLinkPanel', () => { }); it('renders null while Threat Intel module state is loading', () => { - useIsThreatIntelModuleEnabledMock.mockReturnValueOnce(undefined); const wrapper = mount( - + diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx index 1ae00face7c8..0c50bbf145b1 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx @@ -8,19 +8,18 @@ import React from 'react'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; -import { useIsThreatIntelModuleEnabled } from '../../containers/overview_cti_links/use_is_threat_intel_module_enabled'; import { CtiEnabledModule } from './cti_enabled_module'; import { CtiDisabledModule } from './cti_disabled_module'; export type ThreatIntelLinkPanelProps = Pick< GlobalTimeArgs, 'from' | 'to' | 'deleteQuery' | 'setQuery' ->; +> & { + isThreatIntelModuleEnabled: boolean | undefined; +}; const ThreatIntelLinkPanelComponent: React.FC = (props) => { - const isThreatIntelModuleEnabled = useIsThreatIntelModuleEnabled(); - - switch (isThreatIntelModuleEnabled) { + switch (props.isThreatIntelModuleEnabled) { case true: return ; case false: diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/use_cti_event_counts.ts b/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/use_cti_event_counts.ts index cc06f593a06c..65e79ac6b617 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/use_cti_event_counts.ts +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/use_cti_event_counts.ts @@ -6,19 +6,14 @@ */ import { useEffect, useState, useMemo } from 'react'; -import { ThreatIntelLinkPanelProps } from '../../components/overview_cti_links'; import { useRequestEventCounts } from './use_request_event_counts'; import { emptyEventCountsByDataset } from './helpers'; +import { CtiEnabledModuleProps } from '../../components/overview_cti_links/cti_enabled_module'; export const ID = 'ctiEventCountQuery'; const PREFIX = 'threatintel.'; -export const useCtiEventCounts = ({ - deleteQuery, - from, - setQuery, - to, -}: ThreatIntelLinkPanelProps) => { +export const useCtiEventCounts = ({ deleteQuery, from, setQuery, to }: CtiEnabledModuleProps) => { const [isInitialLoading, setIsInitialLoading] = useState(true); const [loading, { data, inspect, totalCount, refetch }] = useRequestEventCounts(to, from); diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index f003e6084b3c..cc8c0bfcf7f6 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -87,6 +87,7 @@ describe('Overview', () => { }, ]); }); + describe('rendering', () => { test('it DOES NOT render the Getting started text when an index is available', () => { mockUseSourcererScope.mockReturnValue({ @@ -277,4 +278,18 @@ describe('Overview', () => { }); }); }); + + describe('Threat Intel Dashboard Links', () => { + it('invokes useIsThreatIntelModuleEnabled hook only once', () => { + useIsThreatIntelModuleEnabledMock.mockClear(); + mount( + + + + + + ); + expect(useIsThreatIntelModuleEnabledMock).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 3c8612ed6cd9..174141db9bfb 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -33,6 +33,7 @@ import { Sourcerer } from '../../common/components/sourcerer'; import { SourcererScopeName } from '../../common/store/sourcerer/model'; import { useDeepEqualSelector } from '../../common/hooks/use_selector'; import { ThreatIntelLinkPanel } from '../components/overview_cti_links'; +import { useIsThreatIntelModuleEnabled } from '../containers/overview_cti_links/use_is_threat_intel_module_enabled'; const SidebarFlexItem = styled(EuiFlexItem)` margin-right: 24px; @@ -70,6 +71,8 @@ const OverviewComponent = () => { addMessage('management', 'dismissEndpointNotice'); }, [addMessage]); const { allEnabled: isIngestEnabled } = useIngestEnabledCheck(); + const isThreatIntelModuleEnabled = useIsThreatIntelModuleEnabled(); + return ( <> {indicesExist ? ( @@ -143,6 +146,7 @@ const OverviewComponent = () => { ` + & .euiRangeTrack:after { + left: -65px; + transform: rotate(90deg); + } +`; interface StyledGraphControlProps { $backgroundColor: string; $iconColor: string; @@ -275,7 +283,7 @@ export const GraphControls = React.memo( > - = ({ timelineId }) => { [timelineType] ); - const content = useMemo(() => (title.length ? title : placeholder), [title, placeholder]); + const content = useMemo(() => title || placeholder, [title, placeholder]); return ( @@ -239,7 +239,7 @@ const TimelineDescriptionComponent: React.FC = ({ timelineId ); return ( - {description.length ? ( + {description ? ( {description} diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx index 14ebfbc20d9c..3c9d9161a7a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx @@ -25,6 +25,8 @@ interface FlyoutPaneComponentProps { const StyledEuiFlyout = styled(EuiFlyout)` animation: none; + min-width: 150px; + z-index: ${({ theme }) => theme.eui.euiZLevel6}; `; const FlyoutPaneComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx index ac6f6e52db1e..b71cbb4c082e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx @@ -130,7 +130,7 @@ export const EventsCountComponent = ({ itemsCount: number; onClick: () => void; serverSideEventCount: number; - footerText: string; + footerText: string | React.ReactNode; }) => { const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ serverSideEventCount, @@ -164,7 +164,13 @@ export const EventsCountComponent = ({ > - + + {totalCount} {footerText} + + } + > {totalCount} diff --git a/x-pack/plugins/security_solution/public/transforms/utils/adjust_timerange.test.ts b/x-pack/plugins/security_solution/public/transforms/utils/adjust_timerange.test.ts new file mode 100644 index 000000000000..8dc4210494ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/transforms/utils/adjust_timerange.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { adjustTimeRange } from './adjust_timerange'; +import moment from 'moment'; + +/** Get the return type of adjustTimeRange for TypeScript checks against expected */ +type ReturnTypeAdjustTimeRange = ReturnType; + +describe('adjust_timerange', () => { + beforeEach(() => { + // Adds extra switch to suppress deprecation warnings that moment does not expose in TypeScript + (moment as typeof moment & { + suppressDeprecationWarnings: boolean; + }).suppressDeprecationWarnings = true; + }); + + afterEach(() => { + // Adds extra switch to suppress deprecation warnings that moment does not expose in TypeScript + (moment as typeof moment & { + suppressDeprecationWarnings: boolean; + }).suppressDeprecationWarnings = false; + }); + + test('it will adjust the time range from by rounding down by an hour within "from"', () => { + expect( + adjustTimeRange({ + interval: '5m', + to: '2021-07-06T22:07:56.972Z', + from: '2021-07-06T22:07:56.972Z', + }) + ).toMatchObject>({ + timeRangeAdjusted: { + interval: '5m', + to: '2021-07-06T22:07:56.972Z', + from: '2021-07-06T22:00:00.000Z', // <-- Rounded down by an hour + }, + }); + }); + + test('it will compute the duration between to and and from', () => { + expect( + adjustTimeRange({ + interval: '5m', + to: '2021-07-06T22:08:56.972Z', + from: '2021-07-06T22:07:56.972Z', + }).duration?.asMinutes() + ).toEqual(1); + }); + + test('it will return "undefined" if the to and from are invalid dateMath parsable', () => { + expect( + adjustTimeRange({ + interval: '5m', + to: 'now-invalid', + from: 'now-invalid2', + }) + ).toMatchObject>({ + timeRangeAdjusted: undefined, + duration: undefined, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/transforms/utils/adjust_timerange.ts b/x-pack/plugins/security_solution/public/transforms/utils/adjust_timerange.ts index 5c99524694a6..e9334379d7b0 100644 --- a/x-pack/plugins/security_solution/public/transforms/utils/adjust_timerange.ts +++ b/x-pack/plugins/security_solution/public/transforms/utils/adjust_timerange.ts @@ -9,11 +9,21 @@ import dateMath from '@elastic/datemath'; import moment, { Duration } from 'moment'; import type { TimerangeInput } from '../../../common/search_strategy'; -export type ParseTimeRange = ( - timeRange: TimerangeInput -) => { timeRangeAdjusted: TimerangeInput | undefined; duration: Duration | undefined }; +export interface TimeRangeAdjusted { + timeRangeAdjusted: TimerangeInput | undefined; + duration: Duration | undefined; +} -export const adjustTimeRange: ParseTimeRange = (timerange) => { +/** + * Adjusts a given timerange by rounding the "from" down by an hour and returning + * the duration between "to" and "from". The duration is typically analyzed to determine + * if the adjustment should be made or not. Although we check "to" and use "to" for duration + * we are careful to still return "to: timerange.to", which is the original input to be careful + * about accidental bugs from trying to over parse or change relative date time ranges. + * @param timerange The timeRange to determine if we adjust or not + * @returns The time input adjustment and a duration + */ +export const adjustTimeRange = (timerange: TimerangeInput): TimeRangeAdjusted => { const from = dateMath.parse(timerange.from); const to = dateMath.parse(timerange.to); if (from == null || to == null) { diff --git a/x-pack/plugins/security_solution/public/transforms/utils/create_indices_from_prefix.test.ts b/x-pack/plugins/security_solution/public/transforms/utils/create_indices_from_prefix.test.ts new file mode 100644 index 000000000000..2cdb1c4ebc2c --- /dev/null +++ b/x-pack/plugins/security_solution/public/transforms/utils/create_indices_from_prefix.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createIndicesFromPrefix } from './create_indices_from_prefix'; + +/** Get the return type of createIndicesFromPrefix for TypeScript checks against expected */ +type ReturnTypeCreateIndicesFromPrefix = ReturnType; + +describe('create_indices_from_prefix', () => { + test('returns empty array given an empty array', () => { + expect( + createIndicesFromPrefix({ + transformIndices: [], + prefix: 'prefix', + }) + ).toEqual([]); + }); + + test('returns expected prefix given a set of indices', () => { + expect( + createIndicesFromPrefix({ + transformIndices: ['index_1', 'index_2'], + prefix: 'prefix', + }) + ).toEqual(['.estc_prefix_index_1', '.estc_prefix_index_2']); + }); +}); diff --git a/x-pack/plugins/security_solution/public/transforms/utils/create_indices_from_prefix.ts b/x-pack/plugins/security_solution/public/transforms/utils/create_indices_from_prefix.ts index 1f3f3959f2aa..de63933ba447 100644 --- a/x-pack/plugins/security_solution/public/transforms/utils/create_indices_from_prefix.ts +++ b/x-pack/plugins/security_solution/public/transforms/utils/create_indices_from_prefix.ts @@ -7,6 +7,13 @@ import { ELASTIC_NAME } from '../../../common/constants'; +/** + * Given a set of input indices and a prefix this will return the elastic name + * concatenated with the prefix. + * @param transformIndices The indices to add the prefix to + * @param prefix The prefix to add along with the elastic name + * @returns The indices with the prefix string + */ export const createIndicesFromPrefix = ({ transformIndices, prefix, diff --git a/x-pack/plugins/security_solution/public/transforms/utils/get_settings_match.test.ts b/x-pack/plugins/security_solution/public/transforms/utils/get_settings_match.test.ts new file mode 100644 index 000000000000..a58757c26162 --- /dev/null +++ b/x-pack/plugins/security_solution/public/transforms/utils/get_settings_match.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getSettingsMatch } from './get_settings_match'; +import { getTransformConfigSchemaMock } from './transform_config_schema.mock'; + +/** Get the return type of createIndicesFromPrefix for TypeScript checks against expected */ +type ReturnTypeCreateIndicesFromPrefix = ReturnType; + +describe('get_settings_match', () => { + test('it returns undefined given an empty array of indices', () => { + expect( + getSettingsMatch({ + indices: [], + transformSettings: getTransformConfigSchemaMock(), + }) + ).toEqual(undefined); + }); + + test('it returns a setting given an index pattern that matches', () => { + expect( + getSettingsMatch({ + indices: [ + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + transformSettings: getTransformConfigSchemaMock(), + }) + ).toEqual(getTransformConfigSchemaMock().settings[0]); + }); + + test('it returns a setting given an index pattern that matches even if the indices are different order', () => { + expect( + getSettingsMatch({ + indices: [ + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'auditbeat-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + transformSettings: getTransformConfigSchemaMock(), + }) + ).toEqual(getTransformConfigSchemaMock().settings[0]); + }); + + test('it returns a setting given an index pattern that matches and removes any that have a dash in them meaning to subtract them', () => { + expect( + getSettingsMatch({ + indices: [ + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'auditbeat-*', + 'packetbeat-*', + 'winlogbeat-*', + '-subtract-1', // extra dashed one that should still allow a match + '-subtract-2', // extra dashed one that should still allow a match + ], + transformSettings: getTransformConfigSchemaMock(), + }) + ).toEqual(getTransformConfigSchemaMock().settings[0]); + }); + + test('it returns "undefined" given a set of indices that do not match a setting', () => { + expect( + getSettingsMatch({ + indices: ['endgame-*', 'filebeat-*', 'logs-*', 'auditbeat-*', 'packetbeat-*'], + transformSettings: getTransformConfigSchemaMock(), + }) + ).toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/security_solution/public/transforms/utils/get_settings_match.ts b/x-pack/plugins/security_solution/public/transforms/utils/get_settings_match.ts index 1546dddb60ca..ed7be4a530dc 100644 --- a/x-pack/plugins/security_solution/public/transforms/utils/get_settings_match.ts +++ b/x-pack/plugins/security_solution/public/transforms/utils/get_settings_match.ts @@ -7,6 +7,12 @@ import { TransformConfigSchema } from '../../../common/transforms/types'; +/** + * Given a transform setting and indices this will return either the particular setting + * that matches the index or it will return undefined if it is not found + * @param indices The indices to check against the transform + * @returns Either the setting if it matches or an undefined if it cannot find one + */ export const getSettingsMatch = ({ indices, transformSettings, diff --git a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes.test.ts b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes.test.ts new file mode 100644 index 000000000000..5c6e10ae8f7c --- /dev/null +++ b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes.test.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getTransformChanges } from './get_transform_changes'; +import { getTransformConfigSchemaMock } from './transform_config_schema.mock'; +import { + HostsKpiQueries, + HostsQueries, + MatrixHistogramQuery, + MatrixHistogramQueryEntities, + MatrixHistogramType, + NetworkKpiQueries, + NetworkQueries, +} from '../../../common/search_strategy'; + +/** Get the return type of createIndicesFromPrefix for TypeScript checks against expected */ +type ReturnTypeGetTransformChanges = ReturnType; + +describe('get_transform_changes', () => { + describe('kpi transforms', () => { + test('it gets a transform change for kpiHosts', () => { + expect( + getTransformChanges({ + factoryQueryType: HostsKpiQueries.kpiHosts, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: HostsKpiQueries.kpiHostsEntities, + indices: ['.estc_all_host_ent*'], + }); + }); + + test('it gets a transform change for kpiAuthentications', () => { + expect( + getTransformChanges({ + factoryQueryType: HostsKpiQueries.kpiAuthentications, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: HostsKpiQueries.kpiAuthenticationsEntities, + indices: ['.estc_all_user_ent*'], + }); + }); + + test('it gets a transform change for kpiUniqueIps', () => { + expect( + getTransformChanges({ + factoryQueryType: HostsKpiQueries.kpiUniqueIps, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: HostsKpiQueries.kpiUniqueIpsEntities, + indices: ['.estc_all_src_ip_ent*', '.estc_all_dest_ip_ent*'], + }); + }); + }); + + describe('host transforms', () => { + test('it gets a transform change for hosts', () => { + expect( + getTransformChanges({ + factoryQueryType: HostsQueries.hosts, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: HostsQueries.hostsEntities, + indices: ['.estc_all_host_ent*'], + }); + }); + + test('it gets a transform change for authentications', () => { + expect( + getTransformChanges({ + factoryQueryType: HostsQueries.authentications, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: HostsQueries.authenticationsEntities, + indices: ['.estc_all_user_ent*'], + }); + }); + }); + + describe('network transforms', () => { + test('it gets a transform change for topCountries', () => { + expect( + getTransformChanges({ + factoryQueryType: NetworkQueries.topCountries, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: NetworkQueries.topCountriesEntities, + indices: ['.estc_all_src_iso_ent*', '.estc_all_dest_iso_ent*'], + }); + }); + + test('it gets a transform change for topNFlow', () => { + expect( + getTransformChanges({ + factoryQueryType: NetworkQueries.topNFlow, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: NetworkQueries.topNFlowEntities, + indices: ['.estc_all_src_ip_ent*', '.estc_all_dest_ip_ent*'], + }); + }); + + test('it gets a transform change for dns', () => { + expect( + getTransformChanges({ + factoryQueryType: NetworkKpiQueries.dns, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: NetworkKpiQueries.dnsEntities, + indices: ['.estc_all_ip_met*'], + }); + }); + + test('it gets a transform change for networkEvents', () => { + expect( + getTransformChanges({ + factoryQueryType: NetworkKpiQueries.networkEvents, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: NetworkKpiQueries.networkEventsEntities, + indices: ['.estc_all_ip_met*'], + }); + }); + + test('it gets a transform change for tlsHandshakes', () => { + expect( + getTransformChanges({ + factoryQueryType: NetworkKpiQueries.tlsHandshakes, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: NetworkKpiQueries.tlsHandshakesEntities, + indices: ['.estc_all_ip_met*'], + }); + }); + }); + + describe('matrix transforms', () => { + test('it gets a transform change for authentications', () => { + expect( + getTransformChanges({ + factoryQueryType: MatrixHistogramQuery, + histogramType: MatrixHistogramType.authentications, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + histogramType: MatrixHistogramType.authenticationsEntities, + factoryQueryType: MatrixHistogramQueryEntities, + indices: ['.estc_all_user_met*'], + }); + }); + }); + + describe('unsupported/undefined transforms', () => { + test('it returned unsupported/undefined for firstOrLastSeen since there are not transforms for it', () => { + expect( + getTransformChanges({ + factoryQueryType: HostsQueries.firstOrLastSeen, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual(undefined); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_hosts.test.ts b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_hosts.test.ts new file mode 100644 index 000000000000..e76c2ee2575f --- /dev/null +++ b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_hosts.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getTransformChangesForHosts } from './get_transform_changes_for_hosts'; +import { HostsQueries } from '../../../common/search_strategy/security_solution/hosts'; +import { getTransformConfigSchemaMock } from './transform_config_schema.mock'; + +/** Get the return type of getTransformChangesForHosts for TypeScript checks against expected */ +type ReturnTypeGetTransformChangesForHosts = ReturnType; + +describe('get_transform_changes_for_host', () => { + test('it gets a transform change for hosts', () => { + expect( + getTransformChangesForHosts({ + factoryQueryType: HostsQueries.hosts, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: HostsQueries.hostsEntities, + indices: ['.estc_all_host_ent*'], + }); + }); + + test('it gets a transform change for authentications', () => { + expect( + getTransformChangesForHosts({ + factoryQueryType: HostsQueries.authentications, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: HostsQueries.authenticationsEntities, + indices: ['.estc_all_user_ent*'], + }); + }); + + test('it returns an "undefined" for another value', () => { + expect( + getTransformChangesForHosts({ + factoryQueryType: HostsQueries.firstOrLastSeen, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_hosts.ts b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_hosts.ts index fef884dd6761..95a5e04bd9e5 100644 --- a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_hosts.ts +++ b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_hosts.ts @@ -9,6 +9,13 @@ import { HostsQueries } from '../../../common/search_strategy'; import { createIndicesFromPrefix } from './create_indices_from_prefix'; import { GetTransformChanges } from './types'; +/** + * Given a factory query type this will return the transform changes such as the transform indices if it matches + * the correct type, otherwise it will return "undefined" + * @param factoryQueryType The query type to check if we have a transform for it and are capable of rendering one or not + * @param settings The settings configuration to get the prefix from + * @returns The transform type if we have one, otherwise undefined + */ export const getTransformChangesForHosts: GetTransformChanges = ({ factoryQueryType, settings, diff --git a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_kpi.test.ts b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_kpi.test.ts new file mode 100644 index 000000000000..561874930fea --- /dev/null +++ b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_kpi.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getTransformChangesForKpi } from './get_transform_changes_for_kpi'; +import { HostsKpiQueries } from '../../../common/search_strategy'; +import { HostsQueries } from '../../../common/search_strategy/security_solution/hosts'; +import { getTransformConfigSchemaMock } from './transform_config_schema.mock'; + +/** Get the return type of getTransformChangesForKpi for TypeScript checks against expected */ +type ReturnTypeGetTransformChangesForKpi = ReturnType; + +describe('get_transform_changes_for_kpi', () => { + test('it gets a transform change for kpiHosts', () => { + expect( + getTransformChangesForKpi({ + factoryQueryType: HostsKpiQueries.kpiHosts, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: HostsKpiQueries.kpiHostsEntities, + indices: ['.estc_all_host_ent*'], + }); + }); + + test('it gets a transform change for kpiAuthentications', () => { + expect( + getTransformChangesForKpi({ + factoryQueryType: HostsKpiQueries.kpiAuthentications, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: HostsKpiQueries.kpiAuthenticationsEntities, + indices: ['.estc_all_user_ent*'], + }); + }); + + test('it gets a transform change for kpiUniqueIps', () => { + expect( + getTransformChangesForKpi({ + factoryQueryType: HostsKpiQueries.kpiUniqueIps, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: HostsKpiQueries.kpiUniqueIpsEntities, + indices: ['.estc_all_src_ip_ent*', '.estc_all_dest_ip_ent*'], + }); + }); + + test('it returns an "undefined" for another value', () => { + expect( + getTransformChangesForKpi({ + factoryQueryType: HostsQueries.firstOrLastSeen, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_kpi.ts b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_kpi.ts index 620e794dd0a7..800d3189e0c7 100644 --- a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_kpi.ts +++ b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_kpi.ts @@ -8,6 +8,13 @@ import { HostsKpiQueries } from '../../../common/search_strategy'; import { createIndicesFromPrefix } from './create_indices_from_prefix'; import { GetTransformChanges } from './types'; +/** + * Given a factory query type this will return the transform changes such as the transform indices if it matches + * the correct type, otherwise it will return "undefined" + * @param factoryQueryType The query type to check if we have a transform for it and are capable of rendering one or not + * @param settings The settings configuration to get the prefix from + * @returns The transform type if we have one, otherwise undefined + */ export const getTransformChangesForKpi: GetTransformChanges = ({ factoryQueryType, settings }) => { switch (factoryQueryType) { case HostsKpiQueries.kpiHosts: { diff --git a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_matrix_histogram.test.ts b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_matrix_histogram.test.ts new file mode 100644 index 000000000000..539fb960e441 --- /dev/null +++ b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_matrix_histogram.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + MatrixHistogramType, + MatrixHistogramQuery, + MatrixHistogramQueryEntities, +} from '../../../common/search_strategy'; +import { getTransformChangesForMatrixHistogram } from './get_transform_changes_for_matrix_histogram'; +import { HostsQueries } from '../../../common/search_strategy/security_solution/hosts'; +import { getTransformConfigSchemaMock } from './transform_config_schema.mock'; + +/** Get the return type of getTransformChangesForMatrixHistogram for TypeScript checks against expected */ +type ReturnTypeGetTransformChangesForMatrixHistogram = ReturnType< + typeof getTransformChangesForMatrixHistogram +>; + +describe('get_transform_changes_for_matrix_histogram', () => { + test('it gets a transform change for authentications', () => { + expect( + getTransformChangesForMatrixHistogram({ + factoryQueryType: MatrixHistogramQuery, + histogramType: MatrixHistogramType.authentications, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + histogramType: MatrixHistogramType.authenticationsEntities, + factoryQueryType: MatrixHistogramQueryEntities, + indices: ['.estc_all_user_met*'], + }); + }); + + test('it returns an "undefined" for another value', () => { + expect( + getTransformChangesForMatrixHistogram({ + factoryQueryType: HostsQueries.firstOrLastSeen, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_matrix_histogram.ts b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_matrix_histogram.ts index fca29b2e97d8..36e8c2203200 100644 --- a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_matrix_histogram.ts +++ b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_matrix_histogram.ts @@ -13,6 +13,14 @@ import { import { createIndicesFromPrefix } from './create_indices_from_prefix'; import { GetTransformChanges } from './types'; +/** + * Given a factory query type this will return the transform changes such as the transform indices if it matches + * the correct type, otherwise it will return "undefined" + * @param factoryQueryType The query type to check if we have a transform for it and are capable of rendering one or not + * @param histogramType The histogram type to check if we have a transform for it and are capable fo rendering one or not + * @param settings The settings configuration to get the prefix from + * @returns The transform type if we have one, otherwise undefined + */ export const getTransformChangesForMatrixHistogram: GetTransformChanges = ({ factoryQueryType, settings, diff --git a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_network.test.ts b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_network.test.ts new file mode 100644 index 000000000000..ffac8853f6e6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_network.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getTransformChangesForNetwork } from './get_transform_changes_for_network'; +import { NetworkKpiQueries, NetworkQueries } from '../../../common/search_strategy'; +import { HostsQueries } from '../../../common/search_strategy/security_solution/hosts'; +import { getTransformConfigSchemaMock } from './transform_config_schema.mock'; + +/** Get the return type of getTransformChangesForNetwork for TypeScript checks against expected */ +type ReturnTypeGetTransformChangesForNetwork = ReturnType; + +describe('get_transform_changes_for_network', () => { + test('it gets a transform change for topCountries', () => { + expect( + getTransformChangesForNetwork({ + factoryQueryType: NetworkQueries.topCountries, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: NetworkQueries.topCountriesEntities, + indices: ['.estc_all_src_iso_ent*', '.estc_all_dest_iso_ent*'], + }); + }); + + test('it gets a transform change for topNFlow', () => { + expect( + getTransformChangesForNetwork({ + factoryQueryType: NetworkQueries.topNFlow, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: NetworkQueries.topNFlowEntities, + indices: ['.estc_all_src_ip_ent*', '.estc_all_dest_ip_ent*'], + }); + }); + + test('it gets a transform change for dns', () => { + expect( + getTransformChangesForNetwork({ + factoryQueryType: NetworkKpiQueries.dns, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: NetworkKpiQueries.dnsEntities, + indices: ['.estc_all_ip_met*'], + }); + }); + + test('it gets a transform change for networkEvents', () => { + expect( + getTransformChangesForNetwork({ + factoryQueryType: NetworkKpiQueries.networkEvents, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: NetworkKpiQueries.networkEventsEntities, + indices: ['.estc_all_ip_met*'], + }); + }); + + test('it gets a transform change for tlsHandshakes', () => { + expect( + getTransformChangesForNetwork({ + factoryQueryType: NetworkKpiQueries.tlsHandshakes, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: NetworkKpiQueries.tlsHandshakesEntities, + indices: ['.estc_all_ip_met*'], + }); + }); + + test('it gets a transform change for uniquePrivateIps', () => { + expect( + getTransformChangesForNetwork({ + factoryQueryType: NetworkKpiQueries.uniquePrivateIps, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual({ + factoryQueryType: NetworkKpiQueries.uniquePrivateIpsEntities, + indices: ['.estc_all_src_ip_ent*', '.estc_all_dest_ip_ent*'], + }); + }); + + test('it returns an "undefined" for another value', () => { + expect( + getTransformChangesForNetwork({ + factoryQueryType: HostsQueries.firstOrLastSeen, + settings: getTransformConfigSchemaMock().settings[0], + }) + ).toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_network.ts b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_network.ts index 1b1ef815f82b..a1ec0783235f 100644 --- a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_network.ts +++ b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_for_network.ts @@ -9,6 +9,13 @@ import { NetworkKpiQueries, NetworkQueries } from '../../../common/search_strate import { createIndicesFromPrefix } from './create_indices_from_prefix'; import { GetTransformChanges } from './types'; +/** + * Given a factory query type this will return the transform changes such as the transform indices if it matches + * the correct type, otherwise it will return "undefined" + * @param factoryQueryType The query type to check if we have a transform for it and are capable of rendering one or not + * @param settings The settings configuration to get the prefix from + * @returns The transform type if we have one, otherwise undefined + */ export const getTransformChangesForNetwork: GetTransformChanges = ({ factoryQueryType, settings, diff --git a/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_if_they_exist.test.ts b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_if_they_exist.test.ts new file mode 100644 index 000000000000..e5d290500499 --- /dev/null +++ b/x-pack/plugins/security_solution/public/transforms/utils/get_transform_changes_if_they_exist.test.ts @@ -0,0 +1,301 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getTransformChangesIfTheyExist } from './get_transform_changes_if_they_exist'; +import { HostsKpiQueries, HostsQueries } from '../../../common/search_strategy'; +import moment from 'moment'; +import { getTransformConfigSchemaMock } from './transform_config_schema.mock'; + +/** Get the return type of createIndicesFromPrefix for TypeScript checks against expected */ +type ReturnTypeGetTransformChangesIfTheyExist = ReturnType; + +describe('get_transform_changes_if_they_exist', () => { + beforeEach(() => { + // Adds extra switch to suppress deprecation warnings that moment does not expose in TypeScript + (moment as typeof moment & { + suppressDeprecationWarnings: boolean; + }).suppressDeprecationWarnings = true; + }); + + afterEach(() => { + // Adds extra switch to suppress deprecation warnings that moment does not expose in TypeScript + (moment as typeof moment & { + suppressDeprecationWarnings: boolean; + }).suppressDeprecationWarnings = false; + }); + + describe('transformSettings enabled conditional logic', () => { + test('returns transformed settings if our settings is enabled', () => { + expect( + getTransformChangesIfTheyExist({ + factoryQueryType: HostsQueries.authentications, + indices: ['auditbeat-*'], + transformSettings: { ...getTransformConfigSchemaMock(), enabled: true }, // sets enabled to true + filterQuery: undefined, + histogramType: undefined, + timerange: { + to: '2021-07-06T23:49:38.643Z', + from: '2021-07-06T20:49:38.643Z', + interval: '5m', + }, + }) + ).toMatchObject>({ + indices: ['.estc_all_user_ent*'], + factoryQueryType: HostsQueries.authenticationsEntities, + }); + }); + + test('returns regular settings if our settings is disabled', () => { + expect( + getTransformChangesIfTheyExist({ + factoryQueryType: HostsQueries.authentications, + indices: ['auditbeat-*'], + transformSettings: { ...getTransformConfigSchemaMock(), enabled: false }, // sets enabled to false + filterQuery: undefined, + histogramType: undefined, + timerange: { + to: '2021-07-06T23:49:38.643Z', + from: '2021-07-06T20:49:38.643Z', + interval: '5m', + }, + }) + ).toMatchObject>({ + indices: ['auditbeat-*'], + factoryQueryType: HostsQueries.authentications, + }); + }); + }); + + describe('filter query compatibility conditional logic', () => { + test('returns regular settings if filter is set to something other than match_all', () => { + expect( + getTransformChangesIfTheyExist({ + factoryQueryType: HostsQueries.authentications, + indices: ['auditbeat-*'], + transformSettings: getTransformConfigSchemaMock(), + filterQuery: { + bool: { + must: [], + filter: [{ match_none: {} }], // match_none shouldn't return transform + should: [], + must_not: [], + }, + }, + histogramType: undefined, + timerange: { + to: '2021-07-06T23:49:38.643Z', + from: '2021-07-06T20:49:38.643Z', + interval: '5m', + }, + }) + ).toMatchObject>({ + indices: ['auditbeat-*'], + factoryQueryType: HostsQueries.authentications, + }); + }); + + test('returns transformed settings if filter is set to something such as match_all', () => { + expect( + getTransformChangesIfTheyExist({ + factoryQueryType: HostsQueries.authentications, + indices: ['auditbeat-*'], + transformSettings: getTransformConfigSchemaMock(), + filterQuery: { + bool: { + must: [], + filter: [{ match_all: {} }], // match_all should return transform + should: [], + must_not: [], + }, + }, + histogramType: undefined, + timerange: { + to: '2021-07-06T23:49:38.643Z', + from: '2021-07-06T20:49:38.643Z', + interval: '5m', + }, + }) + ).toMatchObject>({ + indices: ['.estc_all_user_ent*'], + factoryQueryType: HostsQueries.authenticationsEntities, + }); + }); + + test('returns transformed settings if filter is set to undefined', () => { + expect( + getTransformChangesIfTheyExist({ + factoryQueryType: HostsQueries.authentications, + indices: ['auditbeat-*'], + transformSettings: getTransformConfigSchemaMock(), + filterQuery: undefined, // undefined should return transform + histogramType: undefined, + timerange: { + to: '2021-07-06T23:49:38.643Z', + from: '2021-07-06T20:49:38.643Z', + interval: '5m', + }, + }) + ).toMatchObject>({ + indices: ['.estc_all_user_ent*'], + factoryQueryType: HostsQueries.authenticationsEntities, + }); + }); + }); + + describe('timerange adjustments conditional logic', () => { + test('returns regular settings if timerange is less than an hour', () => { + expect( + getTransformChangesIfTheyExist({ + factoryQueryType: HostsQueries.authentications, + indices: ['auditbeat-*'], + transformSettings: getTransformConfigSchemaMock(), + filterQuery: undefined, + histogramType: undefined, + timerange: { + to: '2021-07-06T23:49:38.643Z', // Less than hour + from: '2021-07-06T23:39:38.643Z', // Less than hour + interval: '5m', + }, + }) + ).toMatchObject>({ + indices: ['auditbeat-*'], + factoryQueryType: HostsQueries.authentications, + }); + }); + + test('returns regular settings if timerange is invalid', () => { + expect( + getTransformChangesIfTheyExist({ + factoryQueryType: HostsQueries.authentications, + indices: ['auditbeat-*'], + transformSettings: getTransformConfigSchemaMock(), + filterQuery: undefined, + histogramType: undefined, + timerange: { + to: 'now-invalid', // invalid to + from: 'now-invalid2', // invalid from + interval: '5m', + }, + }) + ).toMatchObject>({ + indices: ['auditbeat-*'], + factoryQueryType: HostsQueries.authentications, + }); + }); + + test('returns transformed settings if timerange is greater than an hour', () => { + expect( + getTransformChangesIfTheyExist({ + factoryQueryType: HostsQueries.authentications, + indices: ['auditbeat-*'], + transformSettings: getTransformConfigSchemaMock(), + filterQuery: undefined, + histogramType: undefined, + timerange: { + to: '2021-07-06T23:49:38.643Z', // Greater than an hour + from: '2021-07-06T20:49:38.643Z', // Greater than an hour + interval: '5m', + }, + }) + ).toMatchObject>({ + indices: ['.estc_all_user_ent*'], + factoryQueryType: HostsQueries.authenticationsEntities, + }); + }); + }); + + describe('settings match conditional logic', () => { + test('it returns regular settings if settings do not match', () => { + expect( + getTransformChangesIfTheyExist({ + factoryQueryType: HostsQueries.authentications, + indices: ['should-not-match-*'], // index doesn't match anything + transformSettings: getTransformConfigSchemaMock(), + filterQuery: undefined, + histogramType: undefined, + timerange: { + to: '2021-07-06T23:49:38.643Z', + from: '2021-07-06T20:49:38.643Z', + interval: '5m', + }, + }) + ).toMatchObject>({ + indices: ['should-not-match-*'], + factoryQueryType: HostsQueries.authentications, + }); + }); + + test('it returns transformed settings if settings do match', () => { + expect( + getTransformChangesIfTheyExist({ + factoryQueryType: HostsQueries.authentications, + indices: [ + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + '-subtract-something', + ], + transformSettings: getTransformConfigSchemaMock(), + filterQuery: undefined, + histogramType: undefined, + timerange: { + to: '2021-07-06T23:49:38.643Z', + from: '2021-07-06T20:49:38.643Z', + interval: '5m', + }, + }) + ).toMatchObject>({ + indices: ['.estc_all_user_ent*'], + factoryQueryType: HostsQueries.authenticationsEntities, + }); + }); + }); + + describe('transform changes conditional logic', () => { + test('it returns regular settings if it does not match a transform factory type', () => { + expect( + getTransformChangesIfTheyExist({ + factoryQueryType: HostsQueries.firstOrLastSeen, // query type not used for any transforms yet + indices: ['auditbeat-*'], + transformSettings: getTransformConfigSchemaMock(), + filterQuery: undefined, + histogramType: undefined, + timerange: { + to: '2021-07-06T23:49:38.643Z', + from: '2021-07-06T20:49:38.643Z', + interval: '5m', + }, + }) + ).toMatchObject>({ + indices: ['auditbeat-*'], + factoryQueryType: HostsQueries.firstOrLastSeen, + }); + }); + + test('it returns transformed settings if it does match a transform factory type', () => { + expect( + getTransformChangesIfTheyExist({ + factoryQueryType: HostsKpiQueries.kpiHosts, // valid kpiHosts for a transform + indices: ['auditbeat-*'], + transformSettings: getTransformConfigSchemaMock(), + filterQuery: undefined, + histogramType: undefined, + timerange: { + to: '2021-07-06T23:49:38.643Z', + from: '2021-07-06T20:49:38.643Z', + interval: '5m', + }, + }) + ).toMatchObject>({ + indices: ['.estc_all_host_ent*'], + factoryQueryType: HostsKpiQueries.kpiHostsEntities, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/transforms/utils/is_filter_query_compatible.test.ts b/x-pack/plugins/security_solution/public/transforms/utils/is_filter_query_compatible.test.ts new file mode 100644 index 000000000000..74dd1373a4d3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/transforms/utils/is_filter_query_compatible.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isFilterQueryCompatible } from './is_filter_query_compatible'; + +describe('is_filter_query_compatible', () => { + test('returns true if given an undefined', () => { + expect(isFilterQueryCompatible(undefined)).toEqual(true); + }); + + test('returns "true" if given a match all object', () => { + expect( + isFilterQueryCompatible({ + bool: { + must: [], + filter: [{ match_all: {} }], + should: [], + must_not: [], + }, + }) + ).toEqual(true); + }); + + test('returns "false" if given a match all object with something inside of it such as match_none', () => { + expect( + isFilterQueryCompatible({ + bool: { + must: [], + filter: [{ match_none: {} }], + should: [], + must_not: [], + }, + }) + ).toEqual(false); + }); + + test('returns "true" if given empty array for a filter', () => { + expect( + isFilterQueryCompatible({ + bool: { + must: [], + filter: [], + should: [], + must_not: [], + }, + }) + ).toEqual(true); + }); + + test('returns "true" if given match all object as a string', () => { + expect( + isFilterQueryCompatible( + JSON.stringify({ + bool: { + must: [], + filter: [], + should: [], + must_not: [], + }, + }) + ) + ).toEqual(true); + }); + + test('returns "true" if given empty array for a filter as a string', () => { + expect( + isFilterQueryCompatible( + JSON.stringify({ + bool: { + must: [], + filter: [], + should: [], + must_not: [], + }, + }) + ) + ).toEqual(true); + }); + + test('returns "false" if given an invalid string', () => { + expect(isFilterQueryCompatible('invalid string')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/security_solution/public/transforms/utils/is_filter_query_compatible.ts b/x-pack/plugins/security_solution/public/transforms/utils/is_filter_query_compatible.ts index 31c264bce3cd..2f2c9ee7f2de 100644 --- a/x-pack/plugins/security_solution/public/transforms/utils/is_filter_query_compatible.ts +++ b/x-pack/plugins/security_solution/public/transforms/utils/is_filter_query_compatible.ts @@ -5,17 +5,50 @@ * 2.0. */ +import { isEqual } from 'lodash/fp'; import { ESQuery } from '../../../common/typed_json'; -export const isFilterQueryCompatible = (filterQuery: ESQuery | string | undefined) => { +/** + * Array of query compatible objects which are at the moment all + * simple empty or match all based objects + */ +const queryCompatibleStrings: ESQuery[] = [ + { + bool: { + must: [], + filter: [{ match_all: {} }], + should: [], + must_not: [], + }, + }, + { + bool: { + must: [], + filter: [], + should: [], + must_not: [], + }, + }, +]; + +/** + * Returns true if the filter query matches against one of the compatible strings, otherwise + * false. Right now we only check if the filter query is empty, or a match all in order to activate + * the transform. + * @param filterQuery The filterQuery to check against and return true if it matches, otherwise false. + * @returns true if the filter is compatible, otherwise false. + */ +export const isFilterQueryCompatible = (filterQuery: ESQuery | string | undefined): boolean => { if (filterQuery === undefined) { return true; } else if (typeof filterQuery === 'string') { - return ( - filterQuery === '{"bool":{"must":[],"filter":[{"match_all":{}}],"should":[],"must_not":[]}}' - ); + try { + const filterQueryObject = JSON.parse(filterQuery); + return queryCompatibleStrings.some((entry) => isEqual(entry, filterQueryObject)); + } catch (error) { + return false; + } } else { - // TODO: Can we check here and return if it matches a string or other signature? - return false; + return queryCompatibleStrings.some((entry) => isEqual(entry, filterQuery)); } }; diff --git a/x-pack/plugins/security_solution/public/transforms/utils/transform_config_schema.mock.ts b/x-pack/plugins/security_solution/public/transforms/utils/transform_config_schema.mock.ts new file mode 100644 index 000000000000..ef3d4bfe6f00 --- /dev/null +++ b/x-pack/plugins/security_solution/public/transforms/utils/transform_config_schema.mock.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { TransformConfigSchema } from '../../../common/transforms/types'; + +/** + * Mock for the TransformConfigSchema. + * @returns A transform config schema mock + */ +export const getTransformConfigSchemaMock = (): TransformConfigSchema => ({ + enabled: true, + auto_start: true, + auto_create: true, + query: { + range: { + '@timestamp': { + gte: 'now-1d/d', + format: 'strict_date_optional_time', + }, + }, + }, + retention_policy: { + time: { + field: '@timestamp', + max_age: '1w', + }, + }, + max_page_search_size: 5000, + settings: [ + { + prefix: 'all', + indices: ['auditbeat-*', 'endgame-*', 'filebeat-*', 'logs-*', 'packetbeat-*', 'winlogbeat-*'], + data_sources: [ + ['auditbeat-*', 'endgame-*', 'filebeat-*', 'logs-*', 'packetbeat-*', 'winlogbeat-*'], + ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], + ['auditbeat-*'], + ], + }, + ], +}); diff --git a/x-pack/plugins/security_solution/server/config.ts b/x-pack/plugins/security_solution/server/config.ts index d19c36ad21ed..8018a2f050fc 100644 --- a/x-pack/plugins/security_solution/server/config.ts +++ b/x-pack/plugins/security_solution/server/config.ts @@ -60,19 +60,10 @@ export const configSchema = schema.object({ endpointResultListDefaultFirstPageIndex: schema.number({ defaultValue: 0 }), endpointResultListDefaultPageSize: schema.number({ defaultValue: 10 }), - /** - * Alert Endpoint Configuration - */ - alertResultListDefaultDateRange: schema.object({ - from: schema.string({ defaultValue: 'now-15m' }), - to: schema.string({ defaultValue: 'now' }), - }), - /** * Artifacts Configuration */ packagerTaskInterval: schema.string({ defaultValue: '60s' }), - validateArtifactDownloads: schema.boolean({ defaultValue: true }), }); export const createConfig = (context: PluginInitializerContext) => diff --git a/x-pack/plugins/security_solution/server/endpoint/config.test.ts b/x-pack/plugins/security_solution/server/endpoint/config.test.ts deleted file mode 100644 index 08e4e2cd3313..000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/config.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EndpointConfigSchema, EndpointConfigType } from './config'; - -describe('test config schema', () => { - it('test config defaults', () => { - const config: EndpointConfigType = EndpointConfigSchema.validate({}); - expect(config.enabled).toEqual(false); - expect(config.endpointResultListDefaultPageSize).toEqual(10); - expect(config.endpointResultListDefaultFirstPageIndex).toEqual(0); - }); -}); diff --git a/x-pack/plugins/security_solution/server/endpoint/config.ts b/x-pack/plugins/security_solution/server/endpoint/config.ts deleted file mode 100644 index 43f713a9f686..000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/config.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema, TypeOf } from '@kbn/config-schema'; -import { Observable } from 'rxjs'; -import { PluginInitializerContext } from 'kibana/server'; - -export type EndpointConfigType = ReturnType extends Observable - ? P - : ReturnType; - -export const EndpointConfigSchema = schema.object({ - enabled: schema.boolean({ defaultValue: false }), - - /** - * Host Configuration - */ - endpointResultListDefaultFirstPageIndex: schema.number({ defaultValue: 0 }), - endpointResultListDefaultPageSize: schema.number({ defaultValue: 10 }), - - /** - * Alert Configuration - */ - alertResultListDefaultDateRange: schema.object({ - from: schema.string({ defaultValue: 'now-15m' }), - to: schema.string({ defaultValue: 'now' }), - }), - - /** - * Artifacts Configuration - */ - packagerTaskInterval: schema.string({ defaultValue: '60s' }), - validateArtifactDownloads: schema.boolean({ defaultValue: true }), -}); - -export function createConfig$(context: PluginInitializerContext) { - return context.config.create>(); -} 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 926b300a6679..d8e7a813c37c 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 @@ -67,10 +67,9 @@ export const createMetadataService = (packageService: PackageService): MetadataS } if (version === MetadataQueryStrategyVersions.VERSION_2 || !version) { - const assets = await packageService.getInstalledEsAssetReferences( - savedObjectsClient, - 'endpoint' - ); + const assets = + (await packageService.getInstallation({ savedObjectsClient, pkgName: 'endpoint' })) + ?.installed_es ?? []; const expectedTransformAssets = assets.filter( (ref) => ref.type === ElasticsearchAssetType.transform && diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index 08a09b427feb..2af6a944985a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -108,7 +108,7 @@ export const createMockEndpointAppContextServiceStartContract = (): jest.Mocked< export const createMockPackageService = (): jest.Mocked => { return { - getInstalledEsAssetReferences: jest.fn(), + getInstallation: jest.fn(), }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index 3b88aac8d93f..d6660effde38 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -129,17 +129,29 @@ describe('Host Isolation', () => { endpointAppContextService = new EndpointAppContextService(); const mockSavedObjectClient = savedObjectsClientMock.create(); const mockPackageService = createMockPackageService(); - mockPackageService.getInstalledEsAssetReferences.mockReturnValue( - Promise.resolve([ - { - id: 'logs-endpoint.events.security', - type: ElasticsearchAssetType.indexTemplate, - }, - { - id: `${metadataTransformPrefix}-0.16.0-dev.0`, - type: ElasticsearchAssetType.transform, - }, - ]) + mockPackageService.getInstallation.mockReturnValue( + Promise.resolve({ + installed_kibana: [], + package_assets: [], + es_index_patterns: {}, + name: '', + version: '', + install_status: 'installed', + install_version: '', + install_started_at: '', + install_source: 'registry', + installed_es: [ + { + dupa: true, + id: 'logs-endpoint.events.security', + type: ElasticsearchAssetType.indexTemplate, + }, + { + id: `${metadataTransformPrefix}-0.16.0-dev.0`, + type: ElasticsearchAssetType.transform, + }, + ], + }) ); licenseEmitter = new Subject(); licenseService = new LicenseService(); 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 e6d6879ba184..5250f7c49d6a 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 @@ -38,11 +38,7 @@ import { } from '../../endpoint_app_context_services'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; -import { - Agent, - ElasticsearchAssetType, - EsAssetReference, -} from '../../../../../fleet/common/types/models'; +import { Agent, ElasticsearchAssetType } from '../../../../../fleet/common/types/models'; import { createV1SearchResponse, createV2SearchResponse } from './support/test_support'; import { PackageService } from '../../../../../fleet/server/services'; import { @@ -106,9 +102,7 @@ describe('test endpoint route', () => { beforeEach(() => { endpointAppContextService = new EndpointAppContextService(); mockPackageService = createMockPackageService(); - mockPackageService.getInstalledEsAssetReferences.mockReturnValue( - Promise.resolve(([] as unknown) as EsAssetReference[]) - ); + mockPackageService.getInstallation.mockReturnValue(Promise.resolve(undefined)); endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); mockAgentService = startContract.agentService!; @@ -196,17 +190,28 @@ describe('test endpoint route', () => { beforeEach(() => { endpointAppContextService = new EndpointAppContextService(); mockPackageService = createMockPackageService(); - mockPackageService.getInstalledEsAssetReferences.mockReturnValue( - Promise.resolve([ - { - id: 'logs-endpoint.events.security', - type: ElasticsearchAssetType.indexTemplate, - }, - { - id: `${metadataTransformPrefix}-0.16.0-dev.0`, - type: ElasticsearchAssetType.transform, - }, - ]) + mockPackageService.getInstallation.mockReturnValue( + Promise.resolve({ + installed_kibana: [], + package_assets: [], + es_index_patterns: {}, + name: '', + version: '', + install_status: 'installed', + install_version: '', + install_started_at: '', + install_source: 'registry', + installed_es: [ + { + id: 'logs-endpoint.events.security', + type: ElasticsearchAssetType.indexTemplate, + }, + { + id: `${metadataTransformPrefix}-0.16.0-dev.0`, + type: ElasticsearchAssetType.transform, + }, + ], + }) ); endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); mockAgentService = startContract.agentService!; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata_v1.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata_v1.test.ts index 0d56514e7d39..29b2c231cc4a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata_v1.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata_v1.test.ts @@ -43,7 +43,7 @@ import { import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; -import { Agent, EsAssetReference } from '../../../../../fleet/common/types/models'; +import { Agent } from '../../../../../fleet/common/types/models'; import { createV1SearchResponse } from './support/test_support'; import { PackageService } from '../../../../../fleet/server/services'; import type { SecuritySolutionPluginRouter } from '../../../types'; @@ -82,9 +82,7 @@ describe('test endpoint route v1', () => { mockResponse = httpServerMock.createResponseFactory(); endpointAppContextService = new EndpointAppContextService(); mockPackageService = createMockPackageService(); - mockPackageService.getInstalledEsAssetReferences.mockReturnValue( - Promise.resolve(([] as unknown) as EsAssetReference[]) - ); + mockPackageService.getInstallation.mockReturnValue(Promise.resolve(undefined)); startContract = createMockEndpointAppContextServiceStartContract(); endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); mockAgentService = startContract.agentService!; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts index 084105b7d1c4..f376b353531c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts @@ -24,12 +24,7 @@ export const createMockConfig = (): ConfigType => ({ enableExperimental: [], endpointResultListDefaultFirstPageIndex: 0, endpointResultListDefaultPageSize: 10, - alertResultListDefaultDateRange: { - from: 'now-15m', - to: 'now', - }, packagerTaskInterval: '60s', - validateArtifactDownloads: true, alertMergeStrategy: 'missingFields', }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disabling_windows_defender_powershell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disabling_windows_defender_powershell.json new file mode 100644 index 000000000000..0222561c624a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disabling_windows_defender_powershell.json @@ -0,0 +1,59 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies use of the Set-MpPreference PowerShell command to disable or weaken certain Windows Defender settings.", + "false_positives": [ + "Planned Windows Defender configuration changes." + ], + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*", + "logs-windows.*" + ], + "language": "eql", + "license": "Elastic License v2", + "name": "Disabling Windows Defender Security Settings via PowerShell", + "query": "process where event.type == \"start\" and\n (process.name : (\"powershell.exe\", \"pwsh.exe\") or process.pe.original_file_name == \"PowerShell.EXE\") and\n process.args : \"Set-MpPreference\" and process.args : (\"-Disable*\", \"Disabled\", \"NeverSend\", \"-Exclusion*\")\n", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/defender/set-mppreference?view=windowsserver2019-ps" + ], + "risk_score": 47, + "rule_id": "c8cccb06-faf2-4cd5-886e-2c9636cfcb87", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/", + "subtechnique": [ + { + "id": "T1562.001", + "name": "Disable or Modify Tools", + "reference": "https://attack.mitre.org/techniques/T1562/001/" + } + ] + } + ] + } + ], + "timestamp_override": "event.ingested", + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_enable_network_discovery_with_netsh.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_enable_network_discovery_with_netsh.json new file mode 100644 index 000000000000..5fcbec498a17 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_enable_network_discovery_with_netsh.json @@ -0,0 +1,56 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies use of the netsh.exe program to enable host discovery via the network. Attackers can use this command-line tool to weaken the host firewall settings.", + "false_positives": [ + "Host Windows Firewall planned system administration changes." + ], + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*", + "logs-windows.*" + ], + "language": "eql", + "license": "Elastic License v2", + "name": "Enable Host Network Discovery via Netsh", + "query": "process where event.type == \"start\" and\nprocess.name : \"netsh.exe\" and\nprocess.args : (\"firewall\", \"advfirewall\") and process.args : \"group=Network Discovery\" and process.args : \"enable=Yes\"\n", + "risk_score": 47, + "rule_id": "8b4f0816-6a65-4630-86a6-c21c179c0d09", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/", + "subtechnique": [ + { + "id": "T1562.001", + "name": "Disable or Modify Tools", + "reference": "https://attack.mitre.org/techniques/T1562/001/" + } + ] + } + ] + } + ], + "timestamp_override": "event.ingested", + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_windefend_unusual_path.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_windefend_unusual_path.json new file mode 100644 index 000000000000..7812dee8235c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_windefend_unusual_path.json @@ -0,0 +1,59 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a Windows trusted program that is known to be vulnerable to DLL Search Order Hijacking starting after being renamed or from a non-standard path. This is uncommon behavior and may indicate an attempt to evade defenses via side-loading a malicious DLL within the memory space of one of those processes.", + "false_positives": [ + "Microsoft Antimalware Service Executable installed on non default installation path." + ], + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*", + "logs-windows.*" + ], + "language": "eql", + "license": "Elastic License v2", + "name": "Potential DLL Side-Loading via Microsoft Antimalware Service Executable", + "query": "process where event.type == \"start\" and\n (process.pe.original_file_name == \"MsMpEng.exe\" and not process.name : \"MsMpEng.exe\") or\n (process.name : \"MsMpEng.exe\" and not\n process.executable : (\"?:\\\\ProgramData\\\\Microsoft\\\\Windows Defender\\\\*.exe\",\n \"?:\\\\Program Files\\\\Windows Defender\\\\*.exe\",\n \"?:\\\\Program Files (x86)\\\\Windows Defender\\\\*.exe\"))\n", + "references": [ + "https://news.sophos.com/en-us/2021/07/04/independence-day-revil-uses-supply-chain-exploit-to-attack-hundreds-of-businesses/" + ], + "risk_score": 73, + "rule_id": "053a0387-f3b5-4ba5-8245-8002cca2bd08", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1574", + "name": "Hijack Execution Flow", + "reference": "https://attack.mitre.org/techniques/T1574/", + "subtechnique": [ + { + "id": "T1574.002", + "name": "DLL Side-Loading", + "reference": "https://attack.mitre.org/techniques/T1574/002/" + } + ] + } + ] + } + ], + "timestamp_override": "event.ingested", + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_vm_export_failure.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_vm_export_failure.json index 2bf25435b84d..787cac39c30e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_vm_export_failure.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_vm_export_failure.json @@ -16,7 +16,7 @@ "language": "kuery", "license": "Elastic License v2", "name": "AWS EC2 VM Export Failure", - "note": "## Config\nThe AWS Fleet integration, Filebeat module, or similarly structured data is required to be compatible with this rule.", + "note": "## Config\n\nThe AWS Fleet integration, Filebeat module, or similarly structured data is required to be compatible with this rule.", "query": "event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.action:CreateInstanceExportTask and event.outcome:failure\n", "references": [ "https://docs.aws.amazon.com/vm-import/latest/userguide/vmexport.html#export-instance" @@ -66,5 +66,5 @@ ], "timestamp_override": "event.ingested", "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts index 4a6bea85dc6b..49cb1012e86a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts @@ -562,7 +562,14 @@ import rule549 from './ml_auth_rare_user_logon.json'; import rule550 from './ml_auth_spike_in_failed_logon_events.json'; import rule551 from './ml_auth_spike_in_logon_events.json'; import rule552 from './ml_auth_spike_in_logon_events_from_a_source_ip.json'; -import rule553 from './persistence_via_bits_job_notify_command.json'; +import rule553 from './privilege_escalation_printspooler_malicious_driver_file_changes.json'; +import rule554 from './privilege_escalation_printspooler_malicious_registry_modification.json'; +import rule555 from './privilege_escalation_printspooler_suspicious_file_deletion.json'; +import rule556 from './privilege_escalation_unusual_printspooler_childprocess.json'; +import rule557 from './defense_evasion_disabling_windows_defender_powershell.json'; +import rule558 from './defense_evasion_enable_network_discovery_with_netsh.json'; +import rule559 from './defense_evasion_execution_windefend_unusual_path.json'; +import rule560 from './persistence_via_bits_job_notify_command.json'; export const rawRules = [ rule1, @@ -1118,4 +1125,11 @@ export const rawRules = [ rule551, rule552, rule553, + rule554, + rule555, + rule556, + rule557, + rule558, + rule559, + rule560, ]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_malicious_driver_file_changes.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_malicious_driver_file_changes.json new file mode 100644 index 000000000000..215397251e8f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_malicious_driver_file_changes.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects the creation or modification of a print driver with an unusual file name. This may indicate attempts to exploit privilege escalation vulnerabilities related to the Print Spooler service. For more information refer to CVE-2021-34527 and verify that the impacted system is investigated.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*", + "logs-windows.*" + ], + "language": "eql", + "license": "Elastic License v2", + "name": "Potential PrintNightmare File Modification", + "query": "/* This rule is compatible with both Sysmon and Elastic Endpoint */\n\nfile where process.name : \"spoolsv.exe\" and \n file.name : (\"kernelbase.dll\", \"ntdll.dll\", \"kernel32.dll\", \"winhttp.dll\", \"user32.dll\") and\n file.path : \"?:\\\\Windows\\\\System32\\\\spool\\\\drivers\\\\x64\\\\3\\\\*\"\n", + "references": [ + "https://msrc.microsoft.com/update-guide/vulnerability/CVE-2021-34527", + "https://github.com/afwu/PrintNightmare" + ], + "risk_score": 73, + "rule_id": "5e87f165-45c2-4b80-bfa5-52822552c997", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1068", + "name": "Exploitation for Privilege Escalation", + "reference": "https://attack.mitre.org/techniques/T1068/" + } + ] + } + ], + "timestamp_override": "event.ingested", + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_malicious_registry_modification.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_malicious_registry_modification.json new file mode 100644 index 000000000000..c0ebdf3cac6b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_malicious_registry_modification.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to exploit privilege escalation vulnerabilities related to the Print Spooler service. For more information refer to CVE-2021-34527 and verify that the impacted system is investigated.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "logs-windows.*" + ], + "language": "eql", + "license": "Elastic License v2", + "name": "Potential PrintNightmare Exploit Registry Modification", + "query": "/* This rule is not compatible with Sysmon due to schema issues */\n\nregistry where process.name : \"spoolsv.exe\" and\n (registry.path : \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\Print\\\\Environments\\\\Windows*\\\\Drivers\\\\Version-3\\\\mimikatz*\\\\Data File\" or\n (registry.path : \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\Print\\\\Environments\\\\Windows*\\\\Drivers\\\\Version-3\\\\*\\\\Configuration File\" and\n registry.data.strings : (\"kernelbase.dll\", \"ntdll.dll\", \"kernel32.dll\", \"winhttp.dll\", \"user32.dll\")))\n", + "references": [ + "https://msrc.microsoft.com/update-guide/vulnerability/CVE-2021-34527", + "https://github.com/afwu/PrintNightmare" + ], + "risk_score": 73, + "rule_id": "6506c9fd-229e-4722-8f0f-69be759afd2a", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1068", + "name": "Exploitation for Privilege Escalation", + "reference": "https://attack.mitre.org/techniques/T1068/" + } + ] + } + ], + "timestamp_override": "event.ingested", + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_suspicious_file_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_suspicious_file_deletion.json new file mode 100644 index 000000000000..b4d930f99aac --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_suspicious_file_deletion.json @@ -0,0 +1,53 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects deletion of print driver files by an unusual process. This may indicate a clean up attempt post successful privilege escalation via Print Spooler service related vulnerabilities.", + "false_positives": [ + "Uninstall or manual deletion of a legitimate printing driver files. Verify the printer file metadata such as manufacturer and signature information." + ], + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*", + "logs-windows.*" + ], + "language": "eql", + "license": "Elastic License v2", + "name": "Suspicious Print Spooler File Deletion", + "query": "file where event.type : \"deletion\" and\n not process.name : (\"spoolsv.exe\", \"dllhost.exe\", \"explorer.exe\") and\n file.path : \"?:\\\\Windows\\\\System32\\\\spool\\\\drivers\\\\x64\\\\3\\\\*.dll\"\n", + "references": [ + "https://msrc.microsoft.com/update-guide/vulnerability/CVE-2021-34527", + "https://github.com/afwu/PrintNightmare" + ], + "risk_score": 47, + "rule_id": "c4818812-d44f-47be-aaef-4cfb2f9cc799", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1068", + "name": "Exploitation for Privilege Escalation", + "reference": "https://attack.mitre.org/techniques/T1068/" + } + ] + } + ], + "timestamp_override": "event.ingested", + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_printspooler_childprocess.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_printspooler_childprocess.json new file mode 100644 index 000000000000..c63518ddc81c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_printspooler_childprocess.json @@ -0,0 +1,53 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects unusual Print Spooler service (spoolsv.exe) child processes. This may indicate an attempt to exploit privilege escalation vulnerabilities related to the Printing Service on Windows.", + "false_positives": [ + "Install or update of a legitimate printing driver. Verify the printer driver file metadata such as manufacturer and signature information." + ], + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*", + "logs-windows.*" + ], + "language": "eql", + "license": "Elastic License v2", + "name": "Unusual Print Spooler Child Process", + "query": "process where event.type == \"start\" and\n process.parent.name : \"spoolsv.exe\" and user.id : \"S-1-5-18\" and\n\n /* exclusions for FP control below */\n not process.name : (\"splwow64.exe\", \"PDFCreator.exe\", \"acrodist.exe\", \"spoolsv.exe\", \"msiexec.exe\", \"route.exe\", \"WerFault.exe\") and\n not process.command_line : \"*\\\\WINDOWS\\\\system32\\\\spool\\\\DRIVERS*\" and\n not (process.name : \"net.exe\" and process.command_line : (\"*stop*\", \"*start*\")) and\n not (process.name : (\"cmd.exe\", \"powershell.exe\") and process.command_line : (\"*.spl*\", \"*program files*\", \"*route add*\")) and\n not (process.name : \"netsh.exe\" and process.command_line : (\"*add portopening*\", \"*rule name*\")) and\n not (process.name : \"regsvr32.exe\" and process.command_line : \"*PrintConfig.dll*\")\n", + "references": [ + "https://msrc.microsoft.com/update-guide/vulnerability/CVE-2021-34527", + "https://github.com/afwu/PrintNightmare" + ], + "risk_score": 47, + "rule_id": "ee5300a7-7e31-4a72-a258-250abb8b3aa1", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1068", + "name": "Exploitation for Privilege Escalation", + "reference": "https://attack.mitre.org/techniques/T1068/" + } + ] + } + ], + "timestamp_override": "event.ingested", + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 2f3850ff49f4..4389b2261174 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -197,10 +197,10 @@ export class Plugin implements IPlugin { - const componentTemplateName = ruleDataService.getFullAssetName( - 'security-solution-mappings' - ); + const componentTemplateName = ruleDataService.getFullAssetName('security.alerts-mappings'); if (!ruleDataService.isWriteEnabled()) { return; @@ -219,9 +219,9 @@ export class Plugin implements IPlugin initializeRuleDataTemplatesPromise ); diff --git a/x-pack/plugins/timelines/public/components/index.tsx b/x-pack/plugins/timelines/public/components/index.tsx index b242c0ec2a4a..8bb4e6cb4585 100644 --- a/x-pack/plugins/timelines/public/components/index.tsx +++ b/x-pack/plugins/timelines/public/components/index.tsx @@ -26,13 +26,15 @@ type TGridComponent = TGridProps & { store?: Store; storage: Storage; data?: DataPublicPluginStart; + setStore: (store: Store) => void; }; export const TGrid = (props: TGridComponent) => { - const { store, storage, ...tGridProps } = props; + const { store, storage, setStore, ...tGridProps } = props; let tGridStore = store; if (!tGridStore && props.type === 'standalone') { tGridStore = createStore(initialTGridState, storage); + setStore(tGridStore); } let browserFields = EMPTY_BROWSER_FIELDS; if ((tGridProps as TGridIntegratedProps).browserFields != null) { diff --git a/x-pack/plugins/timelines/public/components/loading/index.tsx b/x-pack/plugins/timelines/public/components/loading/index.tsx index 59cc18767af2..652cb6a5dae3 100644 --- a/x-pack/plugins/timelines/public/components/loading/index.tsx +++ b/x-pack/plugins/timelines/public/components/loading/index.tsx @@ -17,7 +17,7 @@ SpinnerFlexItem.displayName = 'SpinnerFlexItem'; export interface LoadingPanelProps { dataTestSubj?: string; - text: string; + text: string | React.ReactNode; height: number | string; showBorder?: boolean; width: number | string; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts index fc566da8c58a..6c793e132b7e 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts @@ -23,17 +23,18 @@ export const getColumnHeaders = ( headers: ColumnHeaderOptions[], browserFields: BrowserFields ): ColumnHeaderOptions[] => { - return headers.map((header) => { - const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] - - return { - ...header, - ...get( - [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], - browserFields - ), - }; - }); + return headers + ? headers.map((header) => { + const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] + return { + ...header, + ...get( + [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], + browserFields + ), + }; + }) + : []; }; export const getColumnWidthFromType = (type: string): number => diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx index 23e94b92eaf3..c164d0026fdf 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx @@ -135,7 +135,7 @@ const TgridActionTdCell = ({ rowIndex, hasRowRenderers, onRuleChange, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, showNotes = false, tabType, @@ -267,7 +267,7 @@ export const DataDrivenColumns = React.memo( hasRowRenderers, onRuleChange, renderCellValue, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, tabType, timelineId, diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx index dca3b84eb84b..2db1bde08bd0 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx @@ -58,7 +58,7 @@ export const EventColumnView = React.memo( hasRowRenderers, onRuleChange, renderCellValue, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, tabType, timelineId, @@ -82,7 +82,6 @@ export const EventColumnView = React.memo( .join(' '), [columnHeaders, data] ); - const leadingActionCells = useMemo( () => leadingControlColumns ? leadingControlColumns.map((column) => column.rowCellRender) : [], diff --git a/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx index 2978759b6d14..b7fb0b40c034 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx @@ -110,7 +110,7 @@ export const EventsCountComponent = ({ itemsCount: number; onClick: () => void; serverSideEventCount: number; - footerText: string; + footerText: string | React.ReactNode; }) => { const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ serverSideEventCount, @@ -144,7 +144,7 @@ export const EventsCountComponent = ({ > - + {totalCount} @@ -305,7 +305,7 @@ export const FooterComponent = ({ data-test-subj="LoadingPanelTimeline" height="35px" showBorder={false} - text={`${loadingText}...`} + text={loadingText} width="100%" /> diff --git a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx index 75aae2ed55c4..c267a0e57dd2 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx @@ -40,6 +40,7 @@ import { StatefulBody } from '../body'; import { Footer, footerHeight } from '../footer'; import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../styles'; import * as i18n from './translations'; +import { InspectButtonContainer } from '../../inspect'; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px const UTILITY_BAR_HEIGHT = 19; // px @@ -103,7 +104,9 @@ export interface TGridStandaloneProps { columns: ColumnHeaderOptions[]; deletedEventIds: Readonly; end: string; + loadingText: React.ReactNode; filters: Filter[]; + footerText: React.ReactNode; headerFilterGroup?: React.ReactNode; height?: number; indexNames: string[]; @@ -113,6 +116,7 @@ export interface TGridStandaloneProps { onRuleChange?: () => void; renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; + setRefetch: (ref: () => void) => void; start: string; sort: SortColumnTimeline[]; utilityBar?: (refetch: Refetch, totalCount: number) => React.ReactNode; @@ -120,13 +124,17 @@ export interface TGridStandaloneProps { leadingControlColumns: ControlColumnProps[]; trailingControlColumns: ControlColumnProps[]; data?: DataPublicPluginStart; + unit: (total: number) => React.ReactNode; } +const basicUnit = (n: number) => i18n.UNIT(n); const TGridStandaloneComponent: React.FC = ({ columns, deletedEventIds, end, + loadingText, filters, + footerText, headerFilterGroup, indexNames, itemsPerPage, @@ -135,6 +143,7 @@ const TGridStandaloneComponent: React.FC = ({ query, renderCellValue, rowRenderers, + setRefetch, start, sort, utilityBar, @@ -142,6 +151,7 @@ const TGridStandaloneComponent: React.FC = ({ leadingControlColumns, trailingControlColumns, data, + unit = basicUnit, }) => { const dispatch = useDispatch(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; @@ -155,7 +165,6 @@ const TGridStandaloneComponent: React.FC = ({ queryFields, title, } = useDeepEqualSelector((state) => getTGrid(state, STANDALONE_ID ?? '')); - const unit = useMemo(() => (n: number) => i18n.UNIT(n), []); useEffect(() => { dispatch(tGridActions.updateIsLoading({ id: STANDALONE_ID, isLoading: isQueryLoading })); }, [dispatch, isQueryLoading]); @@ -216,6 +225,7 @@ const TGridStandaloneComponent: React.FC = ({ skip: !canQueryTimeline, data, }); + setRefetch(refetch); const totalCountMinusDeleted = useMemo( () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), @@ -268,71 +278,81 @@ const TGridStandaloneComponent: React.FC = ({ showCheckboxes: false, }) ); + dispatch( + tGridActions.initializeTGridSettings({ + footerText, + id: STANDALONE_ID, + loadingText, + unit, + }) + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( - - {canQueryTimeline ? ( - <> - - {HeaderSectionContent} - - {utilityBar && !resolverIsShowing(graphEventId) && ( - {utilityBar?.(refetch, totalCountMinusDeleted)} - )} - - - - -